From 55e780a48cc7fd70c13797d44893e81b075ff72e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:37:36 +0200 Subject: [PATCH 001/129] feat(storage): add opt-in auto-cleanup policy (phase 3) Persist a default-OFF archived cleanup policy, evaluate it on startup/schedule/manual runs via Phase 2 helpers, and surface controls on the Storage page. --- .../src/content/docs/guides/web-dashboard.md | 2 +- .../content/docs/ja/guides/web-dashboard.md | 2 +- .../content/docs/ko/guides/web-dashboard.md | 2 +- .../content/docs/reference/configuration.md | 1 + .../content/docs/ru/guides/web-dashboard.md | 2 +- .../docs/zh-cn/guides/web-dashboard.md | 2 +- gui/src/i18n/de.ts | 36 ++ gui/src/i18n/en.ts | 36 ++ gui/src/i18n/ja.ts | 36 ++ gui/src/i18n/ko.ts | 36 ++ gui/src/i18n/ru.ts | 36 ++ gui/src/i18n/zh.ts | 36 ++ gui/src/pages/Storage.tsx | 362 +++++++++++++- src/server/index.ts | 10 + src/server/management/logs-usage-routes.ts | 74 +++ src/storage/policy-scheduler.ts | 23 + src/storage/policy.ts | 450 ++++++++++++++++++ src/types.ts | 25 + tests/api-storage-policy.test.ts | 171 +++++++ tests/storage-policy.test.ts | 374 +++++++++++++++ 20 files changed, 1710 insertions(+), 6 deletions(-) create mode 100644 src/storage/policy-scheduler.ts create mode 100644 src/storage/policy.ts create mode 100644 tests/api-storage-policy.test.ts create mode 100644 tests/storage-policy.test.ts diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 194e54f57..730425fe0 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -39,7 +39,7 @@ bun run dev:gui | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | | **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | -| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. Active sessions stay read-only. Cleanup is refused while Codex holds the newest/active `state_*.sqlite` locked. Quarantined files are **not** restorable from the dashboard — recover manually from `.trash//` using `manifest.json` if needed. | +| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Active sessions stay read-only. Cleanup is refused while Codex holds the newest/active `state_*.sqlite` locked. Quarantined files are **not** restorable from the dashboard — recover manually from `.trash//` using `manifest.json` if needed. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). | ### Linking to a section diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index a83bad455..737a4342f 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -39,7 +39,7 @@ bun run dev:gui | **モデル** | ネイティブ GPT とルーティングモデルをオン/オフし、プロバイダー許可リストとコンテキスト上限、v1/base/v2、v2 スレッド数を設定します。 | | **ログ** | トークン、要求された強度と(利用可能な場合は)実際に送信された強度、実際のモデル、プロバイダー、状態、リクエスト ID、所要時間、エラー詳細を含む最近のリクエストを自動更新します。アダプターが reasoning パラメーターを送信した場合、詳細表示に正確な wire field も表示されます。 | | **使用量 / デバッグ** | トークン使用量の測定範囲と推移を見るか、オプションのプロバイダートランスポート/使用量抽出診断をオンにします。 | -| **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中は拒否。隔離分はダッシュボードから復元不可 — 必要なら `.trash//` と `manifest.json` から手動復旧。 | +| **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。**自動クリーンアップ方針**はオプトインで**既定 OFF**(`storageCleanupPolicy.enabled`)。Storage ページでしきい値/目標/スケジュール/モードを設定するか **今すぐ実行**。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中は拒否。隔離分はダッシュボードから復元不可 — 必要なら `.trash//` と `manifest.json` から手動復旧。 | | **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。 | ### セクションへのリンク diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index c4bb2e904..6d7ca6baf 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -39,7 +39,7 @@ bun run dev:gui | **Models** | 네이티브 GPT와 라우팅 모델을 켜고 끄고, 프로바이더 allowlist와 컨텍스트 상한, v1/base/v2, v2 thread 수를 설정합니다. | | **Logs** | 토큰, 요청한 강도와 (사용 가능한 경우) 실제 전송 강도, 실제 모델, 프로바이더, 상태, 요청 id, 소요 시간, 오류 상세가 포함된 최근 요청을 자동 갱신합니다. 어댑터가 reasoning 매개변수를 전송한 경우 상세 보기에 정확한 wire field도 표시됩니다. 클라이언트가 보낸 불투명 대화/세션 id로 필터하면 현재 로드된 Logs 링의 토큰·추정 정가 합계를 볼 수 있습니다. | | **Usage / Debug** | 토큰 사용량의 측정 범위와 추이를 보거나, 선택적 프로바이더 전송/사용량 추출 진단을 켭니다. | -| **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 거절합니다. 격리 파일은 대시보드에서 복원할 수 없습니다 — 필요하면 `.trash//`와 `manifest.json`으로 수동 복구하세요. | +| **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. **자동 정리 정책**은 opt-in이며 **기본 OFF**(`storageCleanupPolicy.enabled`)입니다. Storage 페이지에서 임계값/목표/일정/모드를 설정하거나 **지금 실행**하세요. 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 거절합니다. 격리 파일은 대시보드에서 복원할 수 없습니다 — 필요하면 `.trash//`와 `manifest.json`으로 수동 복구하세요. | | **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). | ### 섹션으로 바로 가기 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index b56ac6fce..66ceca796 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -50,6 +50,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `connectTimeoutMs?` | `number` | `200000` | Per-attempt deadline for DNS/TCP/TLS and final response headers only; it ends before response-body generation. | | `shutdownTimeoutMs?` | `number` | `5000` | Graceful drain deadline before active turns are aborted. | | `websockets?` | `boolean` | `false` | Advertise `supports_websockets` so Codex uses the Responses WebSocket path. Omit or set `false` to keep HTTP/SSE. | +| `storageCleanupPolicy?` | `StorageCleanupPolicy` | unset / `enabled: false` | **Opt-in** archived auto-cleanup (issue #42 Phase 3). Default **OFF** — never enabled implicitly. When enabled, runs on `schedule` (`startup` / `daily` / `weekly` / `manual`) once archived bytes exceed `trigger.archivedBytesOver`, selecting oldest archives toward `target` (`reduceToBytes` or `removeOldestPercent`) in `mode` (`quarantine` default, or explicit `permanent`). Persists `lastRun` / `nextRun`. Configure via Storage page or `GET`/`PUT /api/storage/cleanup-policy`; `POST /api/storage/cleanup-policy/run` for manual. | | `apiKeys?` | `OcxApiKey[]` | `[]` | Additional generated `ocx_…` credentials accepted by management and data-plane auth on non-loopback binds. Managed by the dashboard; entry fields are listed below. | | `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. `false` makes `ocx ensure` a no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 3156d5fe5..fda9b25d2 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -39,7 +39,7 @@ bun run dev:gui | **Models** | Включение и отключение нативных GPT и маршрутизируемых моделей, настройка списков разрешённых провайдеров и лимитов контекста, выбор v1/base/v2 и настройка лимита потоков v2. | | **Logs** | Автообновляемый список недавних запросов: токены, запрошенный и, когда доступен, фактически отправленный уровень рассуждений, фактическая модель, провайдер, статус, id запроса, длительность и подробности ошибок. Если адаптер отправляет параметр рассуждений, в подробностях также отображается точное wire-поле. Можно фильтровать по непрозрачному id диалога/сессии (если клиент его передаёт) и суммировать токены и оценочную стоимость по прайс-листу в пределах загруженного кольца Logs. | | **Usage / Debug** | Просмотр покрытия и трендов расхода токенов либо включение опциональной диагностики транспорта провайдеров и извлечения данных об использовании. | -| **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. Активные сессии только для чтения. Очистка отклоняется, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. Из карантина **нельзя** восстановить через панель — вручную из `.trash//` и `manifest.json`. | +| **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. **Политика автоочистки** — opt-in и **по умолчанию ВЫКЛ** (`storageCleanupPolicy.enabled`); порог/цель/расписание/режим на странице Storage или **Запустить сейчас**. Активные сессии только для чтения. Очистка отклоняется, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. Из карантина **нельзя** восстановить через панель — вручную из `.trash//` и `manifest.json`. | | **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). | ### Ссылки на разделы diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index d7c1661b6..4d6752131 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -38,7 +38,7 @@ bun run dev:gui | **Models** | 开关原生 GPT 与路由模型,配置 provider allowlist、上下文上限、v1/base/v2 以及 v2 thread 数量。 | | **Logs** | 自动刷新近期请求,显示 token、请求强度以及(可用时)实际发送强度、实际模型、provider、状态、request id、耗时和错误详情。适配器发送 reasoning 参数时,详情中还会显示准确的 wire field。可按不透明会话/对话 ID(客户端提供时)筛选,并对当前已加载的 Logs 环形缓冲合计 token 与估算标价成本。 | | **Usage / Debug** | 查看 token usage 覆盖率与趋势,或启用可选的 provider transport 和 usage 提取诊断。 | -| **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理。隔离文件**不能**从仪表盘恢复——如需恢复,请根据 `manifest.json` 将文件从 `.trash//` 手动移回原位置。 | +| **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。**自动清理策略**为可选且**默认关闭**(`storageCleanupPolicy.enabled`);可在 Storage 页配置阈值/目标/计划/模式,或点「立即运行」。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理。隔离文件**不能**从仪表盘恢复——如需恢复,请根据 `manifest.json` 将文件从 `.trash//` 手动移回原位置。 | | **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。 | ### 链接到某个部分 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c6ee7cf4e..36c805a7b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -918,6 +918,42 @@ export const de: Record = { "storage.cleanup.err.fs_failed_trash": "Dateisystem-Bereinigung fehlgeschlagen. Einige Änderungen können bereits angewendet sein — prüfen Sie {trashDir} und manifest.json auf wiederherstellbare Dateien.", "storage.cleanup.err.db_reconcile_failed": "Codex-Statusdatenbank konnte nicht aktualisiert werden.", "storage.cleanup.err.cleanup_failed": "Bereinigung fehlgeschlagen.", + + "storage.policy.title": "Automatische Bereinigungsrichtlinie", + "storage.policy.help": "Optionale Stapelbereinigung, wenn archivierte Sitzungen einen Schwellwert überschreiten. Standardmäßig aus — wird nie automatisch aktiviert.", + "storage.policy.loading": "Richtlinie wird geladen…", + "storage.policy.loadFailed": "Bereinigungsrichtlinie konnte nicht geladen werden.", + "storage.policy.saveFailed": "Bereinigungsrichtlinie konnte nicht gespeichert werden.", + "storage.policy.runFailed": "Richtlinienlauf fehlgeschlagen.", + "storage.policy.invalid": "Ungültige Richtlinienwerte.", + "storage.policy.enabled": "Automatische Bereinigung aktivieren", + "storage.policy.enabledHint": "Standard ist aus. Bei Aktivierung nur nach gewähltem Zeitplan (oder Jetzt ausführen).", + "storage.policy.threshold": "Auslösen, wenn Archivgröße überschreitet (GiB)", + "storage.policy.target": "Bereinigungsziel", + "storage.policy.targetPercent": "Ältesten Archivanteil entfernen", + "storage.policy.targetReduce": "Archivgröße reduzieren auf (GiB)", + "storage.policy.schedule": "Zeitplan", + "storage.policy.schedule.manual": "Nur manuell", + "storage.policy.schedule.startup": "Beim Proxy-Start", + "storage.policy.schedule.daily": "Täglich", + "storage.policy.schedule.weekly": "Wöchentlich", + "storage.policy.mode": "Löschmodus", + "storage.policy.mode.quarantine": "Quarantäne (Standard)", + "storage.policy.mode.permanent": "Endgültig löschen", + "storage.policy.permanentWarn": "Endgültiger Modus kann nicht rückgängig gemacht werden. Quarantäne bevorzugen, sofern unsicher.", + "storage.policy.lastRun": "Letzter Lauf", + "storage.policy.lastRunDetail": "{count} entfernt · {size} freigegeben", + "storage.policy.nextRun": "Nächster Lauf", + "storage.policy.never": "Nie", + "storage.policy.save": "Speichern", + "storage.policy.runNow": "Jetzt ausführen", + "storage.policy.running": "Läuft…", + "storage.policy.saved": "Richtlinie gespeichert.", + "storage.policy.skippedDisabled": "Richtlinie ist deaktiviert — zuerst aktivieren.", + "storage.policy.skippedUnder": "Archivgröße unter dem Schwellwert — nichts zu tun.", + "storage.policy.skippedEmpty": "Keine Archivkandidaten passend zum Ziel.", + "storage.policy.doneQuarantine": "Richtlinie hat {count} Datei(en) in Quarantäne ({size}).", + "storage.policy.donePermanent": "Richtlinie hat {count} Datei(en) endgültig gelöscht ({size}).", "modal.back": "Zurück", "modal.badge.oauth": "OAuth", "modal.customProvider": "Benutzerdefinierter Anbieter", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 245f1140a..0ff3167d7 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -671,6 +671,42 @@ export const en = { "storage.cleanup.err.db_reconcile_failed": "Could not update Codex state database.", "storage.cleanup.err.cleanup_failed": "Cleanup failed.", + "storage.policy.title": "Auto-cleanup policy", + "storage.policy.help": "Optional batch cleanup when archived sessions exceed a threshold. Off by default — never enabled automatically.", + "storage.policy.loading": "Loading policy…", + "storage.policy.loadFailed": "Could not load cleanup policy.", + "storage.policy.saveFailed": "Could not save cleanup policy.", + "storage.policy.runFailed": "Policy run failed.", + "storage.policy.invalid": "Invalid policy values.", + "storage.policy.enabled": "Enable auto-cleanup", + "storage.policy.enabledHint": "Default is off. Enabling runs only on the schedule you choose (or Run now).", + "storage.policy.threshold": "Trigger when archived size exceeds (GiB)", + "storage.policy.target": "Cleanup target", + "storage.policy.targetPercent": "Remove oldest archived percent", + "storage.policy.targetReduce": "Reduce archived size to (GiB)", + "storage.policy.schedule": "Schedule", + "storage.policy.schedule.manual": "Manual only", + "storage.policy.schedule.startup": "On proxy startup", + "storage.policy.schedule.daily": "Daily", + "storage.policy.schedule.weekly": "Weekly", + "storage.policy.mode": "Deletion mode", + "storage.policy.mode.quarantine": "Quarantine (default)", + "storage.policy.mode.permanent": "Permanent delete", + "storage.policy.permanentWarn": "Permanent mode cannot be undone. Prefer quarantine unless you are sure.", + "storage.policy.lastRun": "Last run", + "storage.policy.lastRunDetail": "Removed {count} · freed {size}", + "storage.policy.nextRun": "Next run", + "storage.policy.never": "Never", + "storage.policy.save": "Save", + "storage.policy.runNow": "Run now", + "storage.policy.running": "Running…", + "storage.policy.saved": "Policy saved.", + "storage.policy.skippedDisabled": "Policy is disabled — enable it first.", + "storage.policy.skippedUnder": "Archived size is under the threshold — nothing to do.", + "storage.policy.skippedEmpty": "No archived candidates matched the target.", + "storage.policy.doneQuarantine": "Policy quarantined {count} file(s) ({size}).", + "storage.policy.donePermanent": "Policy permanently deleted {count} file(s) ({size}).", + // add-provider modal "modal.addNamed": "Add: {label}", "modal.add": "Add provider", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6ba723664..3307e1097 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -638,6 +638,42 @@ export const ja: Record = { "storage.cleanup.err.db_reconcile_failed": "Codex の状態データベースを更新できませんでした。", "storage.cleanup.err.cleanup_failed": "クリーンアップに失敗しました。", + "storage.policy.title": "自動クリーンアップ方針", + "storage.policy.help": "アーカイブがしきい値を超えたときの任意の一括クリーンアップ。既定はオフ — 自動では有効になりません。", + "storage.policy.loading": "方針を読み込み中…", + "storage.policy.loadFailed": "クリーンアップ方針を読み込めませんでした。", + "storage.policy.saveFailed": "クリーンアップ方針を保存できませんでした。", + "storage.policy.runFailed": "方針の実行に失敗しました。", + "storage.policy.invalid": "方針の値が無効です。", + "storage.policy.enabled": "自動クリーンアップを有効化", + "storage.policy.enabledHint": "既定はオフです。有効にすると選択したスケジュール(または今すぐ実行)でのみ動きます。", + "storage.policy.threshold": "アーカイブサイズが超えたら実行(GiB)", + "storage.policy.target": "クリーンアップ目標", + "storage.policy.targetPercent": "古いアーカイブの割合を削除", + "storage.policy.targetReduce": "アーカイブを次のサイズまで縮小(GiB)", + "storage.policy.schedule": "スケジュール", + "storage.policy.schedule.manual": "手動のみ", + "storage.policy.schedule.startup": "プロキシ起動時", + "storage.policy.schedule.daily": "毎日", + "storage.policy.schedule.weekly": "毎週", + "storage.policy.mode": "削除モード", + "storage.policy.mode.quarantine": "隔離(既定)", + "storage.policy.mode.permanent": "完全削除", + "storage.policy.permanentWarn": "完全削除モードは元に戻せません。確信がなければ隔離を使ってください。", + "storage.policy.lastRun": "前回の実行", + "storage.policy.lastRunDetail": "{count} 件削除 · {size} 解放", + "storage.policy.nextRun": "次回の実行", + "storage.policy.never": "なし", + "storage.policy.save": "保存", + "storage.policy.runNow": "今すぐ実行", + "storage.policy.running": "実行中…", + "storage.policy.saved": "方針を保存しました。", + "storage.policy.skippedDisabled": "方針が無効です — 先に有効化してください。", + "storage.policy.skippedUnder": "アーカイブサイズがしきい値未満です — 作業はありません。", + "storage.policy.skippedEmpty": "目標に合うアーカイブ候補がありません。", + "storage.policy.doneQuarantine": "方針が {count} 件を隔離しました({size})。", + "storage.policy.donePermanent": "方針が {count} 件を完全削除しました({size})。", + // add-provider modal "modal.addNamed": "追加: {label}", "modal.add": "プロバイダーを追加", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3fca1a12b..92e7bd2a2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -938,6 +938,42 @@ export const ko: Record = { "storage.cleanup.err.fs_failed_trash": "파일 시스템 정리에 실패했습니다. 일부 변경이 이미 적용되었을 수 있습니다 — {trashDir}와 manifest.json에서 복구 가능한 파일을 확인하세요.", "storage.cleanup.err.db_reconcile_failed": "Codex 상태 데이터베이스를 업데이트할 수 없습니다.", "storage.cleanup.err.cleanup_failed": "정리에 실패했습니다.", + + "storage.policy.title": "자동 정리 정책", + "storage.policy.help": "보관 세션이 임계값을 넘을 때 선택적으로 일괄 정리합니다. 기본은 꺼짐 — 자동으로 켜지지 않습니다.", + "storage.policy.loading": "정책을 불러오는 중…", + "storage.policy.loadFailed": "정리 정책을 불러오지 못했습니다.", + "storage.policy.saveFailed": "정리 정책을 저장하지 못했습니다.", + "storage.policy.runFailed": "정책 실행에 실패했습니다.", + "storage.policy.invalid": "정책 값이 올바르지 않습니다.", + "storage.policy.enabled": "자동 정리 사용", + "storage.policy.enabledHint": "기본은 꺼짐입니다. 켜면 선택한 일정(또는 지금 실행)에만 동작합니다.", + "storage.policy.threshold": "보관 용량이 초과하면 실행 (GiB)", + "storage.policy.target": "정리 목표", + "storage.policy.targetPercent": "가장 오래된 보관 비율 제거", + "storage.policy.targetReduce": "보관 용량을 다음까지 줄이기 (GiB)", + "storage.policy.schedule": "일정", + "storage.policy.schedule.manual": "수동만", + "storage.policy.schedule.startup": "프록시 시작 시", + "storage.policy.schedule.daily": "매일", + "storage.policy.schedule.weekly": "매주", + "storage.policy.mode": "삭제 모드", + "storage.policy.mode.quarantine": "격리(기본)", + "storage.policy.mode.permanent": "영구 삭제", + "storage.policy.permanentWarn": "영구 모드는 되돌릴 수 없습니다. 확실하지 않으면 격리를 사용하세요.", + "storage.policy.lastRun": "마지막 실행", + "storage.policy.lastRunDetail": "{count}개 제거 · {size} 확보", + "storage.policy.nextRun": "다음 실행", + "storage.policy.never": "없음", + "storage.policy.save": "저장", + "storage.policy.runNow": "지금 실행", + "storage.policy.running": "실행 중…", + "storage.policy.saved": "정책을 저장했습니다.", + "storage.policy.skippedDisabled": "정책이 꺼져 있습니다 — 먼저 켜세요.", + "storage.policy.skippedUnder": "보관 용량이 임계값 미만입니다 — 할 일이 없습니다.", + "storage.policy.skippedEmpty": "목표에 맞는 보관 후보가 없습니다.", + "storage.policy.doneQuarantine": "정책이 파일 {count}개를 격리했습니다({size}).", + "storage.policy.donePermanent": "정책이 파일 {count}개를 영구 삭제했습니다({size}).", "modal.back": "뒤로", "modal.badge.oauth": "OAuth", "modal.customProvider": "사용자 지정 프로바이더", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index efbaf53a5..d8a517935 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -670,6 +670,42 @@ export const ru: Record = { "storage.cleanup.err.db_reconcile_failed": "Не удалось обновить базу состояния Codex.", "storage.cleanup.err.cleanup_failed": "Не удалось выполнить очистку.", + "storage.policy.title": "Политика автоочистки", + "storage.policy.help": "Необязательная пакетная очистка, когда архивные сессии превышают порог. По умолчанию выкл. — никогда не включается сама.", + "storage.policy.loading": "Загрузка политики…", + "storage.policy.loadFailed": "Не удалось загрузить политику очистки.", + "storage.policy.saveFailed": "Не удалось сохранить политику очистки.", + "storage.policy.runFailed": "Не удалось выполнить политику.", + "storage.policy.invalid": "Недопустимые значения политики.", + "storage.policy.enabled": "Включить автоочистку", + "storage.policy.enabledHint": "По умолчанию выкл. При включении работает только по выбранному расписанию (или «Запустить сейчас»).", + "storage.policy.threshold": "Запуск, если размер архива больше (ГиБ)", + "storage.policy.target": "Цель очистки", + "storage.policy.targetPercent": "Удалить процент самых старых архивов", + "storage.policy.targetReduce": "Уменьшить архив до (ГиБ)", + "storage.policy.schedule": "Расписание", + "storage.policy.schedule.manual": "Только вручную", + "storage.policy.schedule.startup": "При запуске прокси", + "storage.policy.schedule.daily": "Ежедневно", + "storage.policy.schedule.weekly": "Еженедельно", + "storage.policy.mode": "Режим удаления", + "storage.policy.mode.quarantine": "Карантин (по умолчанию)", + "storage.policy.mode.permanent": "Удалить навсегда", + "storage.policy.permanentWarn": "Постоянный режим нельзя отменить. Предпочитайте карантин, если не уверены.", + "storage.policy.lastRun": "Последний запуск", + "storage.policy.lastRunDetail": "Удалено {count} · освобождено {size}", + "storage.policy.nextRun": "Следующий запуск", + "storage.policy.never": "Никогда", + "storage.policy.save": "Сохранить", + "storage.policy.runNow": "Запустить сейчас", + "storage.policy.running": "Выполняется…", + "storage.policy.saved": "Политика сохранена.", + "storage.policy.skippedDisabled": "Политика отключена — сначала включите её.", + "storage.policy.skippedUnder": "Размер архива ниже порога — делать нечего.", + "storage.policy.skippedEmpty": "Нет архивных кандидатов под цель.", + "storage.policy.doneQuarantine": "Политика отправила в карантин {count} файл(ов) ({size}).", + "storage.policy.donePermanent": "Политика навсегда удалила {count} файл(ов) ({size}).", + // add-provider modal "modal.addNamed": "Добавить: {label}", "modal.add": "Добавить провайдера", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index fc6aa774f..c35310301 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -938,6 +938,42 @@ export const zh: Record = { "storage.cleanup.err.fs_failed_trash": "文件系统清理失败。部分更改可能已生效 — 请在 {trashDir} 和 manifest.json 中查找可恢复文件。", "storage.cleanup.err.db_reconcile_failed": "无法更新 Codex 状态数据库。", "storage.cleanup.err.cleanup_failed": "清理失败。", + + "storage.policy.title": "自动清理策略", + "storage.policy.help": "当归档会话超过阈值时可选批量清理。默认关闭——不会自动启用。", + "storage.policy.loading": "正在加载策略…", + "storage.policy.loadFailed": "无法加载清理策略。", + "storage.policy.saveFailed": "无法保存清理策略。", + "storage.policy.runFailed": "策略运行失败。", + "storage.policy.invalid": "策略值无效。", + "storage.policy.enabled": "启用自动清理", + "storage.policy.enabledHint": "默认关闭。启用后仅按所选计划(或立即运行)执行。", + "storage.policy.threshold": "归档大小超过时触发(GiB)", + "storage.policy.target": "清理目标", + "storage.policy.targetPercent": "删除最旧归档百分比", + "storage.policy.targetReduce": "将归档缩小至(GiB)", + "storage.policy.schedule": "计划", + "storage.policy.schedule.manual": "仅手动", + "storage.policy.schedule.startup": "代理启动时", + "storage.policy.schedule.daily": "每天", + "storage.policy.schedule.weekly": "每周", + "storage.policy.mode": "删除模式", + "storage.policy.mode.quarantine": "隔离(默认)", + "storage.policy.mode.permanent": "永久删除", + "storage.policy.permanentWarn": "永久模式无法撤销。不确定时请使用隔离。", + "storage.policy.lastRun": "上次运行", + "storage.policy.lastRunDetail": "已移除 {count} · 释放 {size}", + "storage.policy.nextRun": "下次运行", + "storage.policy.never": "从未", + "storage.policy.save": "保存", + "storage.policy.runNow": "立即运行", + "storage.policy.running": "运行中…", + "storage.policy.saved": "策略已保存。", + "storage.policy.skippedDisabled": "策略已禁用 — 请先启用。", + "storage.policy.skippedUnder": "归档大小低于阈值 — 无需操作。", + "storage.policy.skippedEmpty": "没有匹配目标的归档候选项。", + "storage.policy.doneQuarantine": "策略已隔离 {count} 个文件({size})。", + "storage.policy.donePermanent": "策略已永久删除 {count} 个文件({size})。", "modal.back": "返回", "modal.badge.oauth": "OAuth", "modal.customProvider": "自定义提供方", diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index d02f09a78..5d32bfd03 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -377,6 +377,352 @@ function ArchivedCleanupPanel({ ); } +const GB = 1024 ** 3; + +interface CleanupPolicy { + enabled: boolean; + trigger: { archivedBytesOver: number }; + target: { reduceToBytes?: number; removeOldestPercent?: number }; + schedule: "startup" | "daily" | "weekly" | "manual"; + mode: "quarantine" | "permanent"; + lastRun?: { at: number; freedBytes: number; removed: number }; + nextRun?: number; +} + +function AutoCleanupPolicyPanel({ + apiBase, + locale, + t, + onDone, +}: { + apiBase: string; + locale: Locale; + t: TFn; + onDone: () => void; +}) { + const [policy, setPolicy] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [running, setRunning] = useState(false); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [targetMode, setTargetMode] = useState<"percent" | "reduce">("percent"); + const [percent, setPercent] = useState(25); + const [reduceGb, setReduceGb] = useState(4); + const [thresholdGb, setThresholdGb] = useState(5); + + const loadPolicy = useCallback(async (signal?: AbortSignal) => { + setLoading(true); + setError(null); + try { + const res = await fetch(`${apiBase}/api/storage/cleanup-policy`, { signal }); + if (!res.ok) throw new Error("load_failed"); + const json = await res.json() as CleanupPolicy; + if (signal?.aborted) return; + setPolicy(json); + setThresholdGb(Math.max(0, Math.round((json.trigger.archivedBytesOver / GB) * 100) / 100)); + if (json.target.reduceToBytes !== undefined) { + setTargetMode("reduce"); + setReduceGb(Math.max(0, Math.round((json.target.reduceToBytes / GB) * 100) / 100)); + } else { + setTargetMode("percent"); + setPercent(Math.min(100, Math.max(1, Math.floor(json.target.removeOldestPercent ?? 25)))); + } + } catch { + if (signal?.aborted) return; + setPolicy(null); + setError(t("storage.policy.loadFailed")); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, [apiBase, t]); + + useEffect(() => { + const controller = new AbortController(); + void loadPolicy(controller.signal); + return () => controller.abort(); + }, [loadPolicy]); + + const buildBody = (): CleanupPolicy | null => { + if (!policy) return null; + const threshold = Number(thresholdGb); + if (!Number.isFinite(threshold) || threshold < 0) return null; + const body: CleanupPolicy = { + enabled: policy.enabled, + trigger: { archivedBytesOver: Math.floor(threshold * GB) }, + target: targetMode === "reduce" + ? { reduceToBytes: Math.floor(Math.max(0, Number(reduceGb) || 0) * GB) } + : { removeOldestPercent: Math.min(100, Math.max(1, Math.floor(Number(percent) || 1))) }, + schedule: policy.schedule, + mode: policy.mode, + }; + return body; + }; + + const savePolicy = async (patch?: Partial) => { + const base = buildBody(); + if (!base) { + setError(t("storage.policy.invalid")); + return; + } + const body = { ...base, ...patch }; + setSaving(true); + setError(null); + setStatus(null); + try { + const res = await fetch(`${apiBase}/api/storage/cleanup-policy`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json() as { ok?: boolean; policy?: CleanupPolicy; error?: string }; + if (!res.ok || !json.policy) throw new Error(json.error ?? t("storage.policy.saveFailed")); + setPolicy(json.policy); + setStatus(t("storage.policy.saved")); + } catch (e) { + setError(e instanceof Error ? e.message : t("storage.policy.saveFailed")); + } finally { + setSaving(false); + } + }; + + const runNow = async () => { + setRunning(true); + setError(null); + setStatus(null); + try { + // Persist current form values before running. + const base = buildBody(); + if (base) { + const saveRes = await fetch(`${apiBase}/api/storage/cleanup-policy`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(base), + }); + const saved = await saveRes.json() as { policy?: CleanupPolicy; error?: string }; + if (!saveRes.ok || !saved.policy) throw new Error(saved.error ?? t("storage.policy.saveFailed")); + setPolicy(saved.policy); + } + const res = await fetch(`${apiBase}/api/storage/cleanup-policy/run`, { method: "POST" }); + const json = await res.json() as { + ok?: boolean; + skipped?: string; + deferred?: boolean; + error?: string; + removed?: number; + freedBytes?: number; + mode?: string; + policy?: CleanupPolicy; + }; + if (json.policy) setPolicy(json.policy); + if (json.skipped === "disabled") { + setStatus(t("storage.policy.skippedDisabled")); + } else if (json.skipped === "under_threshold") { + setStatus(t("storage.policy.skippedUnder")); + } else if (json.skipped === "nothing_selected") { + setStatus(t("storage.policy.skippedEmpty")); + } else if (!res.ok || json.deferred || json.error === "codex_busy") { + setError(t("storage.cleanup.err.codex_busy")); + } else if (!res.ok || !json.ok) { + setError(t("storage.policy.runFailed")); + } else { + setStatus( + json.mode === "permanent" + ? t("storage.policy.donePermanent", { + count: String(json.removed ?? 0), + size: formatBytes(json.freedBytes ?? 0, locale), + }) + : t("storage.policy.doneQuarantine", { + count: String(json.removed ?? 0), + size: formatBytes(json.freedBytes ?? 0, locale), + }), + ); + onDone(); + } + } catch (e) { + setError(e instanceof Error ? e.message : t("storage.policy.runFailed")); + } finally { + setRunning(false); + } + }; + + const formatWhen = (ms: number | undefined) => + ms === undefined ? t("storage.policy.never") : new Date(ms).toLocaleString(locale); + + if (loading && !policy) { + return ( +
+

{t("storage.policy.title")}

+

{t("storage.policy.loading")}

+
+ ); + } + + if (!policy) { + return ( +
+

{t("storage.policy.title")}

+ {error &&

{error}

} +
+ ); + } + + return ( +
+

{t("storage.policy.title")}

+

{t("storage.policy.help")}

+ + +

{t("storage.policy.enabledHint")}

+ +
+ + +
+ {t("storage.policy.target")} + + {targetMode === "percent" && ( + setPercent(Number(e.target.value))} + onBlur={() => void savePolicy()} + style={{ display: "block", marginTop: 4, width: "100%" }} + /> + )} + + {targetMode === "reduce" && ( + setReduceGb(Number(e.target.value))} + onBlur={() => void savePolicy()} + style={{ display: "block", marginTop: 4, width: "100%" }} + /> + )} +
+ + + + + {policy.mode === "permanent" && ( +

{t("storage.policy.permanentWarn")}

+ )} +
+ +
+
+
{t("storage.policy.lastRun")}
+
{formatWhen(policy.lastRun?.at)}
+ {policy.lastRun && ( +
+ {t("storage.policy.lastRunDetail", { + count: String(policy.lastRun.removed), + size: formatBytes(policy.lastRun.freedBytes, locale), + })} +
+ )} +
+
+
{t("storage.policy.nextRun")}
+
{formatWhen(policy.nextRun)}
+
+
+ +
+ + +
+ {status &&

{status}

} + {error &&

{error}

} +
+ ); +} + export default function Storage({ apiBase }: { apiBase: string }) { const { t, locale } = useI18n(); const [data, setData] = useState(null); @@ -431,7 +777,15 @@ export default function Storage({ apiBase }: { apiBase: string }) { ) : failed ? ( ) : empty ? ( - + <> + + void fetchStorage()} + /> + ) : ( <>
@@ -441,6 +795,12 @@ export default function Storage({ apiBase }: { apiBase: string }) {
+ void fetchStorage()} + /> {archivedCount > 0 && ( {}); } + // Opt-in storage policy (default OFF). Never blocks listen; errors are swallowed. + queueMicrotask(() => { + maybeRunDueStorageCleanupPolicy("startup"); + }); + return server; } diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index ec0bd4308..655f0bce3 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -35,6 +35,13 @@ import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap import { resolveCodexHomeDir } from "../../codex/home"; import { scanStorage } from "../../storage/scanner"; import { executeArchivedCleanup, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode } from "../../storage/cleanup"; +import { + normalizeStorageCleanupPolicy, + parseStorageCleanupPolicyInput, + runStorageCleanupPolicy, + writeStorageCleanupPolicyToConfig, +} from "../../storage/policy"; +import type { StorageCleanupPolicy } from "../../types"; import { currentUsageLogRevision, readUsageSnapshotForManagement, @@ -326,5 +333,72 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise; + if (body.enabled === undefined) parsed.policy.enabled = previous.enabled; + const saved = writeStorageCleanupPolicyToConfig(parsed.policy); + config.storageCleanupPolicy = saved; + return jsonResponse({ ok: true, policy: saved }); + } + + if (url.pathname === "/api/storage/cleanup-policy/run" && req.method === "POST") { + try { + const result = runStorageCleanupPolicy({ reason: "manual", force: true }); + // Refresh in-memory config so subsequent GETs see lastRun/nextRun. + config.storageCleanupPolicy = result.policy; + if (result.skipped) { + return jsonResponse({ + ok: true, + skipped: result.skipped, + policy: result.policy, + }); + } + if (result.deferred === "codex_busy" || result.error === "codex_busy") { + return jsonResponse({ + ok: false, + deferred: true, + error: "codex_busy", + message: "Codex is using state.sqlite — try again after quitting Codex.", + policy: result.policy, + }, 409); + } + if (!result.ok) { + const status = + result.error === "stale_preview" || result.error === "referenced_history" + ? 409 + : result.error === "invalid_mode" || result.error === "invalid_digest" + ? 400 + : 500; + return jsonResponse({ + ok: false, + error: result.error ?? "cleanup_failed", + policy: result.policy, + ...(result.trashDir ? { trashDir: result.trashDir } : {}), + }, status); + } + return jsonResponse({ + ok: true, + mode: result.mode, + removed: result.removed ?? 0, + freedBytes: result.freedBytes ?? 0, + ...(result.trashDir ? { trashDir: result.trashDir } : {}), + policy: result.policy, + }); + } catch { + return jsonResponse({ ok: false, error: "cleanup_failed" }, 500); + } + } + return null; } diff --git a/src/storage/policy-scheduler.ts b/src/storage/policy-scheduler.ts new file mode 100644 index 000000000..1e5dd863a --- /dev/null +++ b/src/storage/policy-scheduler.ts @@ -0,0 +1,23 @@ +/** + * Long-running schedule tick for daily/weekly storage cleanup policies. + * Unref'd interval — never keeps the process alive alone (safe for tests). + */ +import { maybeRunDueStorageCleanupPolicy } from "./policy"; + +const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1h + +let timer: ReturnType | null = null; + +export function startStorageCleanupScheduler(intervalMs = DEFAULT_INTERVAL_MS): void { + if (timer) return; + timer = setInterval(() => { + maybeRunDueStorageCleanupPolicy("schedule"); + }, intervalMs); + timer.unref?.(); +} + +export function stopStorageCleanupScheduler(): void { + if (!timer) return; + clearInterval(timer); + timer = null; +} diff --git a/src/storage/policy.ts b/src/storage/policy.ts new file mode 100644 index 000000000..eb283713b --- /dev/null +++ b/src/storage/policy.ts @@ -0,0 +1,450 @@ +/** + * Phase 3 opt-in auto-cleanup policy (issue #42). + * + * Default OFF — never enabled implicitly. Selects oldest archived sessions via + * Phase 2 helpers and runs `executeArchivedCleanup` in quarantine/permanent mode. + * On `codex_busy`, defers (updates `nextRun`) without mutating archives. + * + * Privacy: logs never include host paths, digests of file contents, or secrets. + */ +import { resolveCodexHomeDir } from "../codex/home"; +import { loadConfig, saveConfigPreservingClaudeCode } from "../config"; +import type { StorageCleanupPolicy } from "../types"; +import { + executeArchivedCleanup, + listArchivedCandidates, + previewArchivedCleanup, + type CleanupMode, + type CleanupResult, + type ExecuteCleanupOptions, +} from "./cleanup"; + +export const DEFAULT_ARCHIVED_BYTES_OVER = 5 * 1024 ** 3; // 5 GiB +export const DEFAULT_REMOVE_OLDEST_PERCENT = 25; +export const BUSY_DEFER_MS = 15 * 60 * 1000; + +export type PolicySchedule = StorageCleanupPolicy["schedule"]; +export type PolicyRunReason = "startup" | "schedule" | "manual"; + +export type PolicySkipReason = + | "disabled" + | "not_due" + | "under_threshold" + | "nothing_selected"; + +export interface PolicyRunResult { + ok: boolean; + skipped?: PolicySkipReason; + deferred?: "codex_busy"; + error?: CleanupResult["error"]; + mode?: CleanupMode; + freedBytes?: number; + removed?: number; + trashDir?: string; + policy: StorageCleanupPolicy; +} + +export interface PolicyRunDeps { + now?: number; + reason: PolicyRunReason; + codexHome?: string; + /** Bypass due-window checks (tests / explicit force). Still respects enabled. */ + force?: boolean; + loadPolicy?: () => StorageCleanupPolicy; + savePolicy?: (policy: StorageCleanupPolicy) => void; + execute?: (options: ExecuteCleanupOptions) => CleanupResult; + busyTimeoutMs?: number; +} + +/** Canonical defaults — enabled is always false. */ +export function defaultStorageCleanupPolicy(): StorageCleanupPolicy { + return { + enabled: false, + trigger: { archivedBytesOver: DEFAULT_ARCHIVED_BYTES_OVER }, + target: { removeOldestPercent: DEFAULT_REMOVE_OLDEST_PERCENT }, + schedule: "manual", + mode: "quarantine", + }; +} + +function isFiniteNonNegInt(n: unknown): n is number { + return typeof n === "number" && Number.isFinite(n) && n >= 0 && Math.floor(n) === n; +} + +function isFinitePositiveInt(n: unknown): n is number { + return typeof n === "number" && Number.isFinite(n) && n > 0 && Math.floor(n) === n; +} + +/** True when target has exactly one of reduceToBytes / removeOldestPercent. */ +export function isValidPolicyTarget(target: StorageCleanupPolicy["target"]): boolean { + if (!target || typeof target !== "object") return false; + const reduce = (target as { reduceToBytes?: unknown }).reduceToBytes; + const percent = (target as { removeOldestPercent?: unknown }).removeOldestPercent; + const hasReduce = reduce !== undefined; + const hasPercent = percent !== undefined; + if (hasReduce === hasPercent) return false; // none or both + if (hasReduce) return isFiniteNonNegInt(reduce); + return typeof percent === "number" && Number.isFinite(percent) && percent > 0 && percent <= 100; +} + +/** + * Normalize a partial/unknown policy into a complete StorageCleanupPolicy. + * Never flips enabled to true unless the input explicitly sets enabled: true. + */ +export function normalizeStorageCleanupPolicy(raw: unknown): StorageCleanupPolicy { + const base = defaultStorageCleanupPolicy(); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return base; + const o = raw as Record; + + const enabled = o.enabled === true; // only explicit true enables + + let archivedBytesOver = base.trigger.archivedBytesOver; + if (o.trigger && typeof o.trigger === "object" && !Array.isArray(o.trigger)) { + const t = (o.trigger as { archivedBytesOver?: unknown }).archivedBytesOver; + if (isFiniteNonNegInt(t)) archivedBytesOver = t; + } + + let target: StorageCleanupPolicy["target"] = base.target; + if (o.target && typeof o.target === "object" && !Array.isArray(o.target)) { + const candidate = o.target as StorageCleanupPolicy["target"]; + if (isValidPolicyTarget(candidate)) { + const reduce = (candidate as { reduceToBytes?: number }).reduceToBytes; + const percent = (candidate as { removeOldestPercent?: number }).removeOldestPercent; + target = reduce !== undefined + ? { reduceToBytes: reduce } + : { removeOldestPercent: Math.min(100, Math.max(1, Math.floor(percent!))) }; + } + } + + const schedule: PolicySchedule = + o.schedule === "startup" || o.schedule === "daily" || o.schedule === "weekly" || o.schedule === "manual" + ? o.schedule + : base.schedule; + + const mode: CleanupMode = o.mode === "permanent" ? "permanent" : "quarantine"; + + let lastRun: StorageCleanupPolicy["lastRun"]; + if (o.lastRun && typeof o.lastRun === "object" && !Array.isArray(o.lastRun)) { + const lr = o.lastRun as Record; + if ( + isFinitePositiveInt(lr.at) + && isFiniteNonNegInt(lr.freedBytes) + && isFiniteNonNegInt(lr.removed) + ) { + lastRun = { at: lr.at, freedBytes: lr.freedBytes, removed: lr.removed }; + } + } + + let nextRun: number | undefined; + if (o.nextRun === undefined || o.nextRun === null) { + nextRun = undefined; + } else if (isFinitePositiveInt(o.nextRun)) { + nextRun = o.nextRun; + } + + return { + enabled, + trigger: { archivedBytesOver }, + target, + schedule, + mode, + ...(lastRun ? { lastRun } : {}), + ...(nextRun !== undefined ? { nextRun } : {}), + }; +} + +/** + * Validate a PUT body. Returns a normalized policy or an error string. + * Does not invent enabled=true. + */ +export function parseStorageCleanupPolicyInput( + raw: unknown, + previous?: StorageCleanupPolicy, +): { ok: true; policy: StorageCleanupPolicy } | { ok: false; error: string } { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { ok: false, error: "body must be a JSON object" }; + } + const o = raw as Record; + const prev = previous ?? defaultStorageCleanupPolicy(); + + if (o.enabled !== undefined && typeof o.enabled !== "boolean") { + return { ok: false, error: "enabled must be a boolean" }; + } + if (o.mode !== undefined && o.mode !== "quarantine" && o.mode !== "permanent") { + return { ok: false, error: "mode must be quarantine or permanent" }; + } + if ( + o.schedule !== undefined + && o.schedule !== "startup" + && o.schedule !== "daily" + && o.schedule !== "weekly" + && o.schedule !== "manual" + ) { + return { ok: false, error: "schedule must be startup, daily, weekly, or manual" }; + } + + const merged: Record = { + ...prev, + ...o, + trigger: o.trigger !== undefined ? o.trigger : prev.trigger, + target: o.target !== undefined ? o.target : prev.target, + // Preserve run metadata unless the client explicitly sends replacements. + lastRun: o.lastRun !== undefined ? o.lastRun : prev.lastRun, + nextRun: o.nextRun !== undefined ? o.nextRun : prev.nextRun, + }; + + if (o.trigger !== undefined) { + if (!o.trigger || typeof o.trigger !== "object" || Array.isArray(o.trigger)) { + return { ok: false, error: "trigger must be an object" }; + } + const bytes = (o.trigger as { archivedBytesOver?: unknown }).archivedBytesOver; + if (!isFiniteNonNegInt(bytes)) { + return { ok: false, error: "trigger.archivedBytesOver must be a non-negative integer" }; + } + } + + if (o.target !== undefined) { + if (!isValidPolicyTarget(o.target as StorageCleanupPolicy["target"])) { + return { + ok: false, + error: "target must set exactly one of reduceToBytes (non-negative int) or removeOldestPercent (1-100)", + }; + } + } + + // Clients must not clear lastRun/nextRun via null unless intentional — accept omit only. + const policy = normalizeStorageCleanupPolicy(merged); + // Recompute nextRun when schedule changes to a timed schedule and nextRun is stale/missing. + if ( + (policy.schedule === "daily" || policy.schedule === "weekly") + && (policy.nextRun === undefined || o.schedule !== undefined) + && o.nextRun === undefined + ) { + policy.nextRun = computeNextRun(policy.schedule, Date.now()); + } + if (policy.schedule === "manual" || policy.schedule === "startup") { + if (o.nextRun === undefined) delete policy.nextRun; + } + + return { ok: true, policy }; +} + +export function readStorageCleanupPolicyFromConfig(): StorageCleanupPolicy { + return normalizeStorageCleanupPolicy(loadConfig().storageCleanupPolicy); +} + +export function writeStorageCleanupPolicyToConfig(policy: StorageCleanupPolicy): StorageCleanupPolicy { + const normalized = normalizeStorageCleanupPolicy(policy); + const config = loadConfig(); + config.storageCleanupPolicy = normalized; + saveConfigPreservingClaudeCode(config); + return normalized; +} + +/** Wall-clock next run for daily/weekly. Startup/manual → undefined. */ +export function computeNextRun(schedule: PolicySchedule, now: number): number | undefined { + if (schedule === "daily") return now + 24 * 60 * 60 * 1000; + if (schedule === "weekly") return now + 7 * 24 * 60 * 60 * 1000; + return undefined; +} + +export function isPolicyDue( + policy: StorageCleanupPolicy, + now: number, + reason: PolicyRunReason, +): boolean { + if (!policy.enabled) return false; + if (reason === "manual") return true; + if (policy.schedule === "manual") return false; + if (policy.schedule === "startup") return reason === "startup"; + // daily / weekly: due when nextRun unset or elapsed + if (policy.nextRun === undefined) return true; + return now >= policy.nextRun; +} + +/** Oldest-first until archived total would drop to `reduceToBytes`. */ +export function selectReduceToBytes( + candidates: ReturnType, + reduceToBytes: number, +): ReturnType { + if (!Number.isFinite(reduceToBytes) || reduceToBytes < 0) return []; + const total = candidates.reduce((sum, c) => sum + c.bytes, 0); + if (total <= reduceToBytes) return []; + const need = total - reduceToBytes; + const out: typeof candidates = []; + let freed = 0; + for (const c of candidates) { + out.push(c); + freed += c.bytes; + if (freed >= need) break; + } + return out; +} + +/** + * Minimal percent in 1..100 such that `selectOldestPercent` returns at least `n` of `m`. + * Returns 0 when n<=0, 100 when n>=m. + */ +export function percentForAtLeastCount(totalCount: number, selectedCount: number): number { + if (selectedCount <= 0 || totalCount <= 0) return 0; + if (selectedCount >= totalCount) return 100; + for (let pct = 1; pct <= 100; pct++) { + const got = Math.max(1, Math.floor((totalCount * pct) / 100)); + if (got >= selectedCount) return pct; + } + return 100; +} + +export interface PolicySelection { + archivedBytes: number; + percent: number; + count: number; + bytes: number; + digest: string; +} + +/** Build a Phase-2-compatible preview for the active policy target. */ +export function selectPolicyPreview( + policy: StorageCleanupPolicy, + codexHome: string = resolveCodexHomeDir(), +): PolicySelection { + const all = listArchivedCandidates(codexHome); + const archivedBytes = all.reduce((sum, c) => sum + c.bytes, 0); + + let percent = 0; + const reduceTo = (policy.target as { reduceToBytes?: number }).reduceToBytes; + const removePct = (policy.target as { removeOldestPercent?: number }).removeOldestPercent; + + if (reduceTo !== undefined) { + const desired = selectReduceToBytes(all, reduceTo); + percent = percentForAtLeastCount(all.length, desired.length); + } else { + percent = Math.min(100, Math.max(0, Math.floor(removePct ?? 0))); + } + + // Re-select via percent so executeArchivedCleanup's digest binding matches. + const preview = previewArchivedCleanup(percent, codexHome); + return { + archivedBytes, + percent: preview.percent, + count: preview.count, + bytes: preview.bytes, + digest: preview.digest, + }; +} + +function advanceNextRun(policy: StorageCleanupPolicy, now: number): StorageCleanupPolicy { + const next = { ...policy }; + const nextRun = computeNextRun(policy.schedule, now); + if (nextRun === undefined) delete next.nextRun; + else next.nextRun = nextRun; + return next; +} + +function deferBusy(policy: StorageCleanupPolicy, now: number): StorageCleanupPolicy { + return { ...policy, nextRun: now + BUSY_DEFER_MS }; +} + +function logPolicyEvent(message: string): void { + console.log(`[storage-policy] ${message}`); +} + +/** + * Evaluate and optionally execute the storage cleanup policy. + * Disabled / under-threshold / not-due paths never call execute. + */ +export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { + const now = deps.now ?? Date.now(); + const load = deps.loadPolicy ?? readStorageCleanupPolicyFromConfig; + const save = deps.savePolicy ?? writeStorageCleanupPolicyToConfig; + const execute = deps.execute ?? executeArchivedCleanup; + + let policy = normalizeStorageCleanupPolicy(load()); + + if (!policy.enabled) { + return { ok: true, skipped: "disabled", policy }; + } + + if (!deps.force && !isPolicyDue(policy, now, deps.reason)) { + return { ok: true, skipped: "not_due", policy }; + } + + const selection = selectPolicyPreview(policy, deps.codexHome); + if (selection.archivedBytes <= policy.trigger.archivedBytesOver) { + policy = advanceNextRun(policy, now); + save(policy); + logPolicyEvent("skip under_threshold"); + return { ok: true, skipped: "under_threshold", policy }; + } + + if (selection.count === 0 || selection.percent <= 0) { + policy = advanceNextRun(policy, now); + save(policy); + logPolicyEvent("skip nothing_selected"); + return { ok: true, skipped: "nothing_selected", policy }; + } + + const result = execute({ + percent: selection.percent, + mode: policy.mode, + digest: selection.digest, + ...(deps.codexHome ? { codexHome: deps.codexHome } : {}), + ...(deps.busyTimeoutMs !== undefined ? { busyTimeoutMs: deps.busyTimeoutMs } : {}), + now, + }); + + if (!result.ok && result.error === "codex_busy") { + policy = deferBusy(policy, now); + save(policy); + logPolicyEvent("defer codex_busy"); + return { ok: false, deferred: "codex_busy", error: "codex_busy", policy }; + } + + if (!result.ok) { + // Non-busy failure: still advance schedule so we do not tight-loop. + policy = advanceNextRun(policy, now); + save(policy); + logPolicyEvent(`fail ${result.error ?? "cleanup_failed"}`); + return { + ok: false, + error: result.error, + mode: result.mode, + ...(result.trashDir ? { trashDir: result.trashDir } : {}), + policy, + }; + } + + policy = { + ...advanceNextRun(policy, now), + lastRun: { + at: now, + freedBytes: result.bytes, + removed: result.count, + }, + }; + save(policy); + logPolicyEvent( + `ok mode=${result.mode} removed=${result.count} freedBytes=${result.bytes}`, + ); + return { + ok: true, + mode: result.mode, + freedBytes: result.bytes, + removed: result.count, + ...(result.trashDir ? { trashDir: result.trashDir } : {}), + policy, + }; +} + +/** Startup / schedule tick entry — swallows unexpected errors. */ +export function maybeRunDueStorageCleanupPolicy( + reason: PolicyRunReason, + deps?: Omit, +): PolicyRunResult | null { + try { + return runStorageCleanupPolicy({ ...deps, reason }); + } catch { + logPolicyEvent("error evaluation_failed"); + return null; + } +} diff --git a/src/types.ts b/src/types.ts index 822ac4984..fb0e244d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -459,6 +459,25 @@ export interface OcxClaudeDesktopProfile { appliedAt?: string; } +/** + * Opt-in archived-session auto-cleanup policy (issue #42 Phase 3). + * Persisted under `OcxConfig.storageCleanupPolicy`. Default `enabled: false`. + */ +export interface StorageCleanupPolicy { + /** When false/unset, the engine never mutates. Default false. */ + enabled: boolean; + /** Run when archived session bytes exceed this threshold. */ + trigger: { archivedBytesOver: number }; + /** Either shrink archives toward a byte floor, or remove the oldest N%. */ + target: { reduceToBytes?: number } | { removeOldestPercent?: number }; + schedule: "startup" | "daily" | "weekly" | "manual"; + /** Default quarantine. Permanent only when explicitly set. */ + mode: "quarantine" | "permanent"; + lastRun?: { at: number; freedBytes: number; removed: number }; + /** Epoch ms when the next scheduled evaluation is due. */ + nextRun?: number; +} + /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 정의. */ export interface OcxCustomModel { /** 고유 ID (crypto.randomUUID()) */ @@ -613,6 +632,12 @@ export interface OcxConfig { shutdownTimeoutMs?: number; /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */ websockets?: boolean; + /** + * Opt-in auto-cleanup policy for archived Codex sessions (issue #42 Phase 3). + * Default OFF (`enabled` false / unset). Never enabled implicitly. + * See `src/storage/policy.ts`. + */ + storageCleanupPolicy?: StorageCleanupPolicy; /** Generated API keys for external access to the proxy's /v1/responses endpoint. */ apiKeys?: Array<{ id: string; name: string; key: string; createdAt: string }>; /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */ diff --git a/tests/api-storage-policy.test.ts b/tests/api-storage-policy.test.ts new file mode 100644 index 000000000..2b11220ce --- /dev/null +++ b/tests/api-storage-policy.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { stopStorageCleanupScheduler } from "../src/storage/policy-scheduler"; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function baseConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "forward", + }, + }, + } as OcxConfig; +} + +function seedArchived(codexHome: string): void { + mkdirSync(join(codexHome, "archived_sessions")); + writeFileSync(join(codexHome, "archived_sessions", "rollout-old.jsonl"), "o".repeat(100)); + writeFileSync(join(codexHome, "archived_sessions", "rollout-new.jsonl"), "n".repeat(200)); + utimesSync(join(codexHome, "archived_sessions", "rollout-old.jsonl"), new Date("2026-01-01"), new Date("2026-01-01")); + utimesSync(join(codexHome, "archived_sessions", "rollout-new.jsonl"), new Date("2026-06-01"), new Date("2026-06-01")); + const db = new Database(join(codexHome, "state_5.sqlite")); + db.exec(`CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER)`); + db.exec(`INSERT INTO threads VALUES + ('told','archived_sessions/rollout-old.jsonl',1), + ('tnew','archived_sessions/rollout-new.jsonl',1) + `); + db.close(); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-api-storage-policy-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-api-storage-policy-")); + process.env.OPENCODEX_HOME = testDir; + saveConfig(baseConfig()); + stopStorageCleanupScheduler(); +}); + +afterEach(() => { + stopStorageCleanupScheduler(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +describe("storage cleanup policy API", () => { + test("GET returns default-off policy without enabling", async () => { + const server = startServer(0); + try { + const res = await fetch(new URL("/api/storage/cleanup-policy", server.url)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.enabled).toBe(false); + expect(body.mode).toBe("quarantine"); + expect(body.schedule).toBe("manual"); + expect(body.trigger.archivedBytesOver).toBeGreaterThan(0); + } finally { + await server.stop(true); + stopStorageCleanupScheduler(); + } + }); + + test("PUT persists policy and never enables when enabled omitted", async () => { + const server = startServer(0); + try { + const res = await fetch(new URL("/api/storage/cleanup-policy", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + trigger: { archivedBytesOver: 1024 }, + target: { removeOldestPercent: 40 }, + schedule: "daily", + mode: "quarantine", + }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.policy.enabled).toBe(false); + expect(body.policy.trigger.archivedBytesOver).toBe(1024); + expect(body.policy.target.removeOldestPercent).toBe(40); + expect(body.policy.schedule).toBe("daily"); + + const get = await fetch(new URL("/api/storage/cleanup-policy", server.url)); + const again = await get.json(); + expect(again.enabled).toBe(false); + expect(again.trigger.archivedBytesOver).toBe(1024); + } finally { + await server.stop(true); + stopStorageCleanupScheduler(); + } + }); + + test("PUT rejects invalid target", async () => { + const server = startServer(0); + try { + const res = await fetch(new URL("/api/storage/cleanup-policy", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled: true, + target: { reduceToBytes: 1, removeOldestPercent: 10 }, + }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("target"); + } finally { + await server.stop(true); + stopStorageCleanupScheduler(); + } + }); + + test("POST run skips when disabled; runs when enabled", async () => { + seedArchived(isolatedCodexHome!.path); + const server = startServer(0); + try { + const skipped = await fetch(new URL("/api/storage/cleanup-policy/run", server.url), { + method: "POST", + }); + expect(skipped.status).toBe(200); + const skipBody = await skipped.json(); + expect(skipBody.skipped).toBe("disabled"); + + await fetch(new URL("/api/storage/cleanup-policy", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled: true, + trigger: { archivedBytesOver: 50 }, + target: { removeOldestPercent: 50 }, + schedule: "manual", + mode: "quarantine", + }), + }); + + const ran = await fetch(new URL("/api/storage/cleanup-policy/run", server.url), { + method: "POST", + }); + expect(ran.status).toBe(200); + const ranBody = await ran.json(); + expect(ranBody.ok).toBe(true); + expect(ranBody.removed).toBe(1); + expect(ranBody.freedBytes).toBe(100); + expect(ranBody.policy.lastRun.removed).toBe(1); + expect(JSON.stringify(ranBody)).not.toContain(isolatedCodexHome!.path.replaceAll("\\", "\\\\")); + } finally { + await server.stop(true); + stopStorageCleanupScheduler(); + } + }); +}); diff --git a/tests/storage-policy.test.ts b/tests/storage-policy.test.ts new file mode 100644 index 000000000..9f8efcab7 --- /dev/null +++ b/tests/storage-policy.test.ts @@ -0,0 +1,374 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { StorageCleanupPolicy } from "../src/types"; +import { + BUSY_DEFER_MS, + computeNextRun, + defaultStorageCleanupPolicy, + isPolicyDue, + normalizeStorageCleanupPolicy, + percentForAtLeastCount, + runStorageCleanupPolicy, + selectReduceToBytes, + selectPolicyPreview, +} from "../src/storage/policy"; +import { + listArchivedCandidates, + type CleanupResult, + type ExecuteCleanupOptions, +} from "../src/storage/cleanup"; + +const OLD = new Date("2026-01-01T00:00:00Z"); +const MID = new Date("2026-02-01T00:00:00Z"); +const NEW = new Date("2026-03-01T00:00:00Z"); + +let home = ""; + +afterEach(() => { + if (home) { + try { rmSync(home, { recursive: true, force: true }); } catch { /* */ } + home = ""; + } +}); + +function seedHome(sizes: Array<{ name: string; bytes: number; when: Date }>): string { + home = mkdtempSync(join(tmpdir(), "ocx-storage-policy-")); + mkdirSync(join(home, "archived_sessions")); + const db = new Database(join(home, "state_5.sqlite")); + db.exec(`CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER)`); + for (const entry of sizes) { + const path = join(home, "archived_sessions", entry.name); + writeFileSync(path, "x".repeat(entry.bytes)); + utimesSync(path, entry.when, entry.when); + db.exec( + `INSERT INTO threads VALUES ('${entry.name}','archived_sessions/${entry.name}',1)`, + ); + } + db.close(); + return home; +} + +function policy(partial: Partial & Pick): StorageCleanupPolicy { + return normalizeStorageCleanupPolicy({ + schedule: "manual", + mode: "quarantine", + ...partial, + }); +} + +describe("normalizeStorageCleanupPolicy", () => { + test("defaults enabled false and never enables implicitly", () => { + const d = defaultStorageCleanupPolicy(); + expect(d.enabled).toBe(false); + expect(normalizeStorageCleanupPolicy(undefined).enabled).toBe(false); + expect(normalizeStorageCleanupPolicy({}).enabled).toBe(false); + expect(normalizeStorageCleanupPolicy({ enabled: "yes" }).enabled).toBe(false); + expect(normalizeStorageCleanupPolicy({ enabled: true }).enabled).toBe(true); + }); + + test("permanent mode only when explicitly set", () => { + expect(normalizeStorageCleanupPolicy({ mode: "permanent" }).mode).toBe("permanent"); + expect(normalizeStorageCleanupPolicy({ mode: "nope" }).mode).toBe("quarantine"); + expect(normalizeStorageCleanupPolicy({}).mode).toBe("quarantine"); + }); +}); + +describe("selection helpers", () => { + test("selectReduceToBytes takes oldest until under target", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + { name: "rollout-mid.jsonl", bytes: 100, when: MID }, + { name: "rollout-new.jsonl", bytes: 100, when: NEW }, + ]); + const all = listArchivedCandidates(dir); + expect(all.map(c => c.relPath)).toEqual([ + "archived_sessions/rollout-old.jsonl", + "archived_sessions/rollout-mid.jsonl", + "archived_sessions/rollout-new.jsonl", + ]); + const selected = selectReduceToBytes(all, 150); + expect(selected.map(c => c.relPath)).toEqual([ + "archived_sessions/rollout-old.jsonl", + "archived_sessions/rollout-mid.jsonl", + ]); + expect(selected.reduce((s, c) => s + c.bytes, 0)).toBe(200); + }); + + test("percentForAtLeastCount maps to selectOldestPercent counts", () => { + expect(percentForAtLeastCount(4, 0)).toBe(0); + expect(percentForAtLeastCount(4, 4)).toBe(100); + // selectOldestPercent uses max(1, floor(n*pct/100)), so pct=1 already yields 1 of 4 + expect(percentForAtLeastCount(4, 1)).toBe(1); + expect(percentForAtLeastCount(4, 2)).toBe(50); + }); + + test("selectPolicyPreview honors removeOldestPercent", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 50, when: OLD }, + { name: "rollout-new.jsonl", bytes: 50, when: NEW }, + ]); + const preview = selectPolicyPreview( + policy({ + enabled: true, + trigger: { archivedBytesOver: 0 }, + target: { removeOldestPercent: 50 }, + }), + dir, + ); + expect(preview.count).toBe(1); + expect(preview.percent).toBe(50); + expect(preview.bytes).toBe(50); + }); + + test("selectPolicyPreview honors reduceToBytes via oldest files", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + { name: "rollout-mid.jsonl", bytes: 100, when: MID }, + { name: "rollout-new.jsonl", bytes: 100, when: NEW }, + ]); + const preview = selectPolicyPreview( + policy({ + enabled: true, + trigger: { archivedBytesOver: 0 }, + target: { reduceToBytes: 100 }, + }), + dir, + ); + // Need to free 200 → two oldest → percent covering ≥2 of 3 + expect(preview.count).toBeGreaterThanOrEqual(2); + expect(preview.bytes).toBeGreaterThanOrEqual(200); + }); +}); + +describe("schedule helpers", () => { + test("computeNextRun for daily/weekly; manual/startup unset", () => { + const now = 1_700_000_000_000; + expect(computeNextRun("daily", now)).toBe(now + 24 * 60 * 60 * 1000); + expect(computeNextRun("weekly", now)).toBe(now + 7 * 24 * 60 * 60 * 1000); + expect(computeNextRun("manual", now)).toBeUndefined(); + expect(computeNextRun("startup", now)).toBeUndefined(); + }); + + test("isPolicyDue respects schedule and reason", () => { + const base = policy({ + enabled: true, + trigger: { archivedBytesOver: 0 }, + target: { removeOldestPercent: 10 }, + schedule: "manual", + }); + expect(isPolicyDue(base, 1, "manual")).toBe(true); + expect(isPolicyDue(base, 1, "startup")).toBe(false); + expect(isPolicyDue({ ...base, schedule: "startup" }, 1, "startup")).toBe(true); + expect(isPolicyDue({ ...base, schedule: "startup" }, 1, "schedule")).toBe(false); + expect(isPolicyDue({ ...base, schedule: "daily", nextRun: 100 }, 50, "schedule")).toBe(false); + expect(isPolicyDue({ ...base, schedule: "daily", nextRun: 100 }, 100, "schedule")).toBe(true); + expect(isPolicyDue({ ...base, enabled: false, schedule: "daily" }, 1, "manual")).toBe(false); + }); +}); + +describe("runStorageCleanupPolicy", () => { + test("disabled never calls execute", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 1000, when: OLD }, + ]); + let calls = 0; + const stored: StorageCleanupPolicy[] = [ + policy({ + enabled: false, + trigger: { archivedBytesOver: 0 }, + target: { removeOldestPercent: 100 }, + }), + ]; + const result = runStorageCleanupPolicy({ + reason: "manual", + force: true, + codexHome: dir, + loadPolicy: () => stored[0]!, + savePolicy: p => { stored[0] = p; }, + execute: () => { + calls += 1; + return { ok: true, mode: "quarantine", percent: 100, count: 1, bytes: 1000, removedPaths: [] }; + }, + }); + expect(result.skipped).toBe("disabled"); + expect(calls).toBe(0); + expect(existsSync(join(dir, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + }); + + test("under threshold no-ops without execute", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + ]); + let calls = 0; + const stored: StorageCleanupPolicy[] = [ + policy({ + enabled: true, + trigger: { archivedBytesOver: 10_000 }, + target: { removeOldestPercent: 100 }, + schedule: "daily", + }), + ]; + const now = 5_000; + const result = runStorageCleanupPolicy({ + reason: "schedule", + force: true, + now, + codexHome: dir, + loadPolicy: () => stored[0]!, + savePolicy: p => { stored[0] = p; }, + execute: () => { + calls += 1; + return { ok: true, mode: "quarantine", percent: 100, count: 0, bytes: 0, removedPaths: [] }; + }, + }); + expect(result.skipped).toBe("under_threshold"); + expect(calls).toBe(0); + expect(stored[0]!.nextRun).toBe(computeNextRun("daily", now)); + expect(existsSync(join(dir, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + }); + + test("over threshold runs quarantine mode and records lastRun", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + { name: "rollout-new.jsonl", bytes: 100, when: NEW }, + ]); + const stored: StorageCleanupPolicy[] = [ + policy({ + enabled: true, + trigger: { archivedBytesOver: 50 }, + target: { removeOldestPercent: 50 }, + mode: "quarantine", + }), + ]; + let seen: ExecuteCleanupOptions | undefined; + const result = runStorageCleanupPolicy({ + reason: "manual", + force: true, + now: 9_000, + codexHome: dir, + loadPolicy: () => stored[0]!, + savePolicy: p => { stored[0] = p; }, + execute: (opts): CleanupResult => { + seen = opts; + return { + ok: true, + mode: opts.mode, + percent: opts.percent, + count: 1, + bytes: 100, + removedPaths: ["archived_sessions/rollout-old.jsonl"], + trashDir: ".trash/9000", + }; + }, + }); + expect(result.ok).toBe(true); + expect(seen?.mode).toBe("quarantine"); + expect(seen?.percent).toBe(50); + expect(result.freedBytes).toBe(100); + expect(result.removed).toBe(1); + expect(stored[0]!.lastRun).toEqual({ at: 9_000, freedBytes: 100, removed: 1 }); + }); + + test("permanent mode is passed through when set on policy", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + ]); + const stored: StorageCleanupPolicy[] = [ + policy({ + enabled: true, + trigger: { archivedBytesOver: 0 }, + target: { removeOldestPercent: 100 }, + mode: "permanent", + }), + ]; + let mode: string | undefined; + runStorageCleanupPolicy({ + reason: "manual", + force: true, + codexHome: dir, + loadPolicy: () => stored[0]!, + savePolicy: p => { stored[0] = p; }, + execute: (opts): CleanupResult => { + mode = opts.mode; + return { ok: true, mode: "permanent", percent: 100, count: 1, bytes: 100, removedPaths: [] }; + }, + }); + expect(mode).toBe("permanent"); + }); + + test("codex_busy defers nextRun without lastRun mutation", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + ]); + const stored: StorageCleanupPolicy[] = [ + policy({ + enabled: true, + trigger: { archivedBytesOver: 0 }, + target: { removeOldestPercent: 100 }, + schedule: "daily", + lastRun: { at: 1, freedBytes: 10, removed: 1 }, + }), + ]; + const now = 20_000; + const result = runStorageCleanupPolicy({ + reason: "manual", + force: true, + now, + codexHome: dir, + loadPolicy: () => stored[0]!, + savePolicy: p => { stored[0] = p; }, + execute: (): CleanupResult => ({ + ok: false, + mode: "quarantine", + percent: 100, + count: 0, + bytes: 0, + removedPaths: [], + error: "codex_busy", + }), + }); + expect(result.deferred).toBe("codex_busy"); + expect(stored[0]!.nextRun).toBe(now + BUSY_DEFER_MS); + expect(stored[0]!.lastRun).toEqual({ at: 1, freedBytes: 10, removed: 1 }); + expect(existsSync(join(dir, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + }); + + test("end-to-end quarantine execute against fixture home", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + { name: "rollout-new.jsonl", bytes: 200, when: NEW }, + ]); + const stored: StorageCleanupPolicy[] = [ + policy({ + enabled: true, + trigger: { archivedBytesOver: 50 }, + target: { removeOldestPercent: 50 }, + mode: "quarantine", + }), + ]; + const result = runStorageCleanupPolicy({ + reason: "manual", + force: true, + now: 42_000, + codexHome: dir, + loadPolicy: () => stored[0]!, + savePolicy: p => { stored[0] = p; }, + }); + expect(result.ok).toBe(true); + expect(result.removed).toBe(1); + expect(result.freedBytes).toBe(100); + expect(existsSync(join(dir, "archived_sessions", "rollout-old.jsonl"))).toBe(false); + expect(existsSync(join(dir, "archived_sessions", "rollout-new.jsonl"))).toBe(true); + expect(existsSync(join(dir, ".trash"))).toBe(true); + }); +}); From 1ee3be66d76031e9713175084c19a2a5d857e263 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:56:04 +0200 Subject: [PATCH 002/129] fix(storage): address phase-3 policy review and GUI lint Keep live config synced after background policy runs, execute exact reduceToBytes candidate sets, preserve nextRun on unchanged schedules, and harden the Storage policy UI against invalid drafts and raw errors. --- gui/src/pages/Storage.tsx | 79 ++++++++++++++-------- src/server/index.ts | 6 +- src/storage/cleanup.ts | 73 +++++++++++++++++++-- src/storage/policy.ts | 45 ++++++++++--- tests/storage-policy.test.ts | 123 ++++++++++++++++++++++++++++++++++- 5 files changed, 282 insertions(+), 44 deletions(-) diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 5d32bfd03..3a64a59d1 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -408,7 +408,8 @@ function AutoCleanupPolicyPanel({ const [error, setError] = useState(null); const [targetMode, setTargetMode] = useState<"percent" | "reduce">("percent"); const [percent, setPercent] = useState(25); - const [reduceGb, setReduceGb] = useState(4); + /** Draft string so blank/invalid reduce targets are rejected instead of coerced to 0. */ + const [reduceGb, setReduceGb] = useState("4"); const [thresholdGb, setThresholdGb] = useState(5); const loadPolicy = useCallback(async (signal?: AbortSignal) => { @@ -423,7 +424,7 @@ function AutoCleanupPolicyPanel({ setThresholdGb(Math.max(0, Math.round((json.trigger.archivedBytesOver / GB) * 100) / 100)); if (json.target.reduceToBytes !== undefined) { setTargetMode("reduce"); - setReduceGb(Math.max(0, Math.round((json.target.reduceToBytes / GB) * 100) / 100)); + setReduceGb(String(Math.max(0, Math.round((json.target.reduceToBytes / GB) * 100) / 100))); } else { setTargetMode("percent"); setPercent(Math.min(100, Math.max(1, Math.floor(json.target.removeOldestPercent ?? 25)))); @@ -439,24 +440,41 @@ function AutoCleanupPolicyPanel({ useEffect(() => { const controller = new AbortController(); - void loadPolicy(controller.signal); - return () => controller.abort(); + // Defer so loadPolicy's setState is not synchronous inside the effect body. + const timeout = window.setTimeout(() => { + void loadPolicy(controller.signal); + }, 0); + return () => { + window.clearTimeout(timeout); + controller.abort(); + }; }, [loadPolicy]); const buildBody = (): CleanupPolicy | null => { if (!policy) return null; const threshold = Number(thresholdGb); if (!Number.isFinite(threshold) || threshold < 0) return null; - const body: CleanupPolicy = { + + let target: CleanupPolicy["target"]; + if (targetMode === "reduce") { + const raw = reduceGb.trim(); + if (raw === "") return null; + const reduce = Number(raw); + if (!Number.isFinite(reduce) || reduce < 0) return null; + target = { reduceToBytes: Math.floor(reduce * GB) }; + } else { + const pct = Number(percent); + if (!Number.isFinite(pct) || pct < 1 || pct > 100) return null; + target = { removeOldestPercent: Math.min(100, Math.max(1, Math.floor(pct))) }; + } + + return { enabled: policy.enabled, trigger: { archivedBytesOver: Math.floor(threshold * GB) }, - target: targetMode === "reduce" - ? { reduceToBytes: Math.floor(Math.max(0, Number(reduceGb) || 0) * GB) } - : { removeOldestPercent: Math.min(100, Math.max(1, Math.floor(Number(percent) || 1))) }, + target, schedule: policy.schedule, mode: policy.mode, }; - return body; }; const savePolicy = async (patch?: Partial) => { @@ -476,11 +494,14 @@ function AutoCleanupPolicyPanel({ body: JSON.stringify(body), }); const json = await res.json() as { ok?: boolean; policy?: CleanupPolicy; error?: string }; - if (!res.ok || !json.policy) throw new Error(json.error ?? t("storage.policy.saveFailed")); + if (!res.ok || !json.policy) { + setError(t("storage.policy.saveFailed")); + return; + } setPolicy(json.policy); setStatus(t("storage.policy.saved")); - } catch (e) { - setError(e instanceof Error ? e.message : t("storage.policy.saveFailed")); + } catch { + setError(t("storage.policy.saveFailed")); } finally { setSaving(false); } @@ -491,18 +512,24 @@ function AutoCleanupPolicyPanel({ setError(null); setStatus(null); try { - // Persist current form values before running. + // Persist current form values before running — abort if the form is invalid. const base = buildBody(); - if (base) { - const saveRes = await fetch(`${apiBase}/api/storage/cleanup-policy`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(base), - }); - const saved = await saveRes.json() as { policy?: CleanupPolicy; error?: string }; - if (!saveRes.ok || !saved.policy) throw new Error(saved.error ?? t("storage.policy.saveFailed")); - setPolicy(saved.policy); + if (!base) { + setError(t("storage.policy.invalid")); + return; } + const saveRes = await fetch(`${apiBase}/api/storage/cleanup-policy`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(base), + }); + const saved = await saveRes.json() as { policy?: CleanupPolicy; error?: string }; + if (!saveRes.ok || !saved.policy) { + setError(t("storage.policy.saveFailed")); + return; + } + setPolicy(saved.policy); + const res = await fetch(`${apiBase}/api/storage/cleanup-policy/run`, { method: "POST" }); const json = await res.json() as { ok?: boolean; @@ -521,7 +548,7 @@ function AutoCleanupPolicyPanel({ setStatus(t("storage.policy.skippedUnder")); } else if (json.skipped === "nothing_selected") { setStatus(t("storage.policy.skippedEmpty")); - } else if (!res.ok || json.deferred || json.error === "codex_busy") { + } else if (json.deferred || json.error === "codex_busy") { setError(t("storage.cleanup.err.codex_busy")); } else if (!res.ok || !json.ok) { setError(t("storage.policy.runFailed")); @@ -539,8 +566,8 @@ function AutoCleanupPolicyPanel({ ); onDone(); } - } catch (e) { - setError(e instanceof Error ? e.message : t("storage.policy.runFailed")); + } catch { + setError(t("storage.policy.runFailed")); } finally { setRunning(false); } @@ -643,7 +670,7 @@ function AutoCleanupPolicyPanel({ step={0.1} value={reduceGb} disabled={saving || running} - onChange={e => setReduceGb(Number(e.target.value))} + onChange={e => setReduceGb(e.target.value)} onBlur={() => void savePolicy()} style={{ display: "block", marginTop: 4, width: "100%" }} /> diff --git a/src/server/index.ts b/src/server/index.ts index 04a62373d..f76c838ef 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -20,7 +20,7 @@ import { import { reconcileOAuthProviders } from "../oauth"; import { invalidateCodexModelsCache } from "../codex/catalog"; import { startMemoryWatchdog } from "./memory-watchdog"; -import { maybeRunDueStorageCleanupPolicy } from "../storage/policy"; +import { maybeRunDueStorageCleanupPolicy, setStorageCleanupPolicyLiveSink } from "../storage/policy"; import { startStorageCleanupScheduler } from "../storage/policy-scheduler"; import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup"; import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup"; @@ -292,6 +292,10 @@ export function startServer(port?: number) { startMemoryWatchdog(); // Issue #42 Phase 3: opt-in archived auto-cleanup (default OFF). Unref'd hourly // tick for daily/weekly; startup evaluation is fire-and-forget after listen. + // Keep live config.policy in sync when background runs advance nextRun/lastRun. + setStorageCleanupPolicyLiveSink((policy) => { + config.storageCleanupPolicy = policy; + }); startStorageCleanupScheduler(); const listenPort = port ?? config.port ?? 10100; diff --git a/src/storage/cleanup.ts b/src/storage/cleanup.ts index e3e0fcc4b..b8bb9885f 100644 --- a/src/storage/cleanup.ts +++ b/src/storage/cleanup.ts @@ -233,9 +233,8 @@ export function normalizeArchivedRolloutPath(rolloutPath: string, codexHome: str return logical; } -/** Content digest of the exact previewed candidate set (paths + size + mtime). */ -export function computePreviewDigest(candidates: ArchivedCandidate[], percent: number): string { - const lines = candidates +function candidateDigestLines(candidates: ArchivedCandidate[]): string[] { + return candidates .map(c => { const physical = [...c.physicalFiles] .sort((a, b) => a.relPath.localeCompare(b.relPath)) @@ -244,8 +243,22 @@ export function computePreviewDigest(candidates: ArchivedCandidate[], percent: n return `${c.relPath}|${c.bytes}|${Math.trunc(c.mtimeMs)}|${physical}`; }) .sort(); +} + +/** Content digest of the exact previewed candidate set (paths + size + mtime). */ +export function computePreviewDigest(candidates: ArchivedCandidate[], percent: number): string { return createHash("sha256") - .update(`${clampPercent(percent)}\n${lines.join("\n")}`) + .update(`${clampPercent(percent)}\n${candidateDigestLines(candidates).join("\n")}`) + .digest("hex"); +} + +/** + * Digest bound to an explicit candidate list (not a percent selection). + * Used when reduceToBytes needs an exact count that percent rounding cannot represent. + */ +export function computeExactPreviewDigest(candidates: ArchivedCandidate[]): string { + return createHash("sha256") + .update(`exact\n${candidateDigestLines(candidates).join("\n")}`) .digest("hex"); } @@ -335,6 +348,42 @@ export function previewArchivedCleanup( }; } +/** Preview bound to an explicit candidate set (exact digest, percent left at 0). */ +export function previewExactArchivedCleanup( + candidates: ArchivedCandidate[], + codexHome: string = resolveCodexHomeDir(), +): CleanupPreview { + const selected = [...candidates]; + return { + codexHome, + percent: 0, + count: selected.length, + bytes: selected.reduce((sum, c) => sum + c.bytes, 0), + digest: computeExactPreviewDigest(selected), + candidates: selected, + }; +} + +/** + * Resolve an exact candidate list from current archive state. + * Returns null when any requested path is missing or drifted (caller maps to stale_preview). + */ +export function resolveExactArchivedCandidates( + candidateRelPaths: string[], + codexHome: string = resolveCodexHomeDir(), +): ArchivedCandidate[] | null { + if (!Array.isArray(candidateRelPaths) || candidateRelPaths.length === 0) return []; + const all = listArchivedCandidates(codexHome); + const byRel = new Map(all.map(c => [c.relPath, c])); + const selected: ArchivedCandidate[] = []; + for (const rel of candidateRelPaths) { + const hit = byRel.get(rel); + if (!hit) return null; + selected.push(hit); + } + return selected; +} + function openDbWritable(dbPath: string, busyTimeoutMs = 100): Database { const db = new Database(dbPath); try { @@ -1271,6 +1320,11 @@ export interface ExecuteCleanupOptions { mode: CleanupMode; /** Required digest from preview; rejects when the candidate set drifted. */ digest: string; + /** + * Optional exact candidate set (logical relPaths). When set, selection bypasses + * percent rounding and the digest must match `computeExactPreviewDigest`. + */ + candidateRelPaths?: string[]; codexHome?: string; /** Test-only: shrink busy_timeout so lock tests fail fast. */ busyTimeoutMs?: number; @@ -1358,7 +1412,16 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR return fail(mode, percent, "invalid_digest"); } - const preview = previewArchivedCleanup(percent, codexHome); + let preview: CleanupPreview; + if (options.candidateRelPaths !== undefined) { + const selected = resolveExactArchivedCandidates(options.candidateRelPaths, codexHome); + if (selected === null) { + return fail(mode, percent, "stale_preview"); + } + preview = previewExactArchivedCleanup(selected, codexHome); + } else { + preview = previewArchivedCleanup(percent, codexHome); + } if (preview.digest.toLowerCase() !== options.digest.toLowerCase()) { return fail(mode, percent, "stale_preview"); } diff --git a/src/storage/policy.ts b/src/storage/policy.ts index eb283713b..424cdf8c4 100644 --- a/src/storage/policy.ts +++ b/src/storage/policy.ts @@ -14,6 +14,7 @@ import { executeArchivedCleanup, listArchivedCandidates, previewArchivedCleanup, + previewExactArchivedCleanup, type CleanupMode, type CleanupResult, type ExecuteCleanupOptions, @@ -23,6 +24,15 @@ export const DEFAULT_ARCHIVED_BYTES_OVER = 5 * 1024 ** 3; // 5 GiB export const DEFAULT_REMOVE_OLDEST_PERCENT = 25; export const BUSY_DEFER_MS = 15 * 60 * 1000; +/** Optional sink so background policy writes stay synced with the live server config. */ +let livePolicySink: ((policy: StorageCleanupPolicy) => void) | undefined; + +export function setStorageCleanupPolicyLiveSink( + sink: ((policy: StorageCleanupPolicy) => void) | null, +): void { + livePolicySink = sink ?? undefined; +} + export type PolicySchedule = StorageCleanupPolicy["schedule"]; export type PolicyRunReason = "startup" | "schedule" | "manual"; @@ -214,11 +224,12 @@ export function parseStorageCleanupPolicyInput( // Clients must not clear lastRun/nextRun via null unless intentional — accept omit only. const policy = normalizeStorageCleanupPolicy(merged); - // Recompute nextRun when schedule changes to a timed schedule and nextRun is stale/missing. + // Recompute nextRun only when schedule newly becomes timed, or no valid nextRun exists. + // Do not reset nextRun on every PUT that merely re-sends an unchanged schedule. if ( (policy.schedule === "daily" || policy.schedule === "weekly") - && (policy.nextRun === undefined || o.schedule !== undefined) && o.nextRun === undefined + && (policy.nextRun === undefined || (o.schedule !== undefined && o.schedule !== prev.schedule)) ) { policy.nextRun = computeNextRun(policy.schedule, Date.now()); } @@ -238,6 +249,7 @@ export function writeStorageCleanupPolicyToConfig(policy: StorageCleanupPolicy): const config = loadConfig(); config.storageCleanupPolicy = normalized; saveConfigPreservingClaudeCode(config); + livePolicySink?.(normalized); return normalized; } @@ -256,7 +268,13 @@ export function isPolicyDue( if (!policy.enabled) return false; if (reason === "manual") return true; if (policy.schedule === "manual") return false; - if (policy.schedule === "startup") return reason === "startup"; + if (policy.schedule === "startup") { + if (reason === "startup") return true; + // Honor deferred nextRun (e.g. codex_busy at launch) on later scheduler ticks. + return reason === "schedule" + && policy.nextRun !== undefined + && now >= policy.nextRun; + } // daily / weekly: due when nextRun unset or elapsed if (policy.nextRun === undefined) return true; return now >= policy.nextRun; @@ -301,6 +319,8 @@ export interface PolicySelection { count: number; bytes: number; digest: string; + /** Present when selection is an exact candidate list (reduceToBytes). */ + candidateRelPaths?: string[]; } /** Build a Phase-2-compatible preview for the active policy target. */ @@ -311,18 +331,24 @@ export function selectPolicyPreview( const all = listArchivedCandidates(codexHome); const archivedBytes = all.reduce((sum, c) => sum + c.bytes, 0); - let percent = 0; const reduceTo = (policy.target as { reduceToBytes?: number }).reduceToBytes; const removePct = (policy.target as { removeOldestPercent?: number }).removeOldestPercent; if (reduceTo !== undefined) { + // Exact candidate set — do not approximate via percent (would over-delete). const desired = selectReduceToBytes(all, reduceTo); - percent = percentForAtLeastCount(all.length, desired.length); - } else { - percent = Math.min(100, Math.max(0, Math.floor(removePct ?? 0))); + const preview = previewExactArchivedCleanup(desired, codexHome); + return { + archivedBytes, + percent: 0, + count: preview.count, + bytes: preview.bytes, + digest: preview.digest, + candidateRelPaths: desired.map(c => c.relPath), + }; } - // Re-select via percent so executeArchivedCleanup's digest binding matches. + const percent = Math.min(100, Math.max(0, Math.floor(removePct ?? 0))); const preview = previewArchivedCleanup(percent, codexHome); return { archivedBytes, @@ -377,7 +403,7 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { return { ok: true, skipped: "under_threshold", policy }; } - if (selection.count === 0 || selection.percent <= 0) { + if (selection.count === 0) { policy = advanceNextRun(policy, now); save(policy); logPolicyEvent("skip nothing_selected"); @@ -388,6 +414,7 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { percent: selection.percent, mode: policy.mode, digest: selection.digest, + ...(selection.candidateRelPaths ? { candidateRelPaths: selection.candidateRelPaths } : {}), ...(deps.codexHome ? { codexHome: deps.codexHome } : {}), ...(deps.busyTimeoutMs !== undefined ? { busyTimeoutMs: deps.busyTimeoutMs } : {}), now, diff --git a/tests/storage-policy.test.ts b/tests/storage-policy.test.ts index 9f8efcab7..e10908242 100644 --- a/tests/storage-policy.test.ts +++ b/tests/storage-policy.test.ts @@ -17,6 +17,7 @@ import { defaultStorageCleanupPolicy, isPolicyDue, normalizeStorageCleanupPolicy, + parseStorageCleanupPolicyInput, percentForAtLeastCount, runStorageCleanupPolicy, selectReduceToBytes, @@ -144,9 +145,33 @@ describe("selection helpers", () => { }), dir, ); - // Need to free 200 → two oldest → percent covering ≥2 of 3 - expect(preview.count).toBeGreaterThanOrEqual(2); - expect(preview.bytes).toBeGreaterThanOrEqual(200); + // Need to free 200 → exactly two oldest (not percent-rounded to more) + expect(preview.count).toBe(2); + expect(preview.bytes).toBe(200); + expect(preview.candidateRelPaths).toEqual([ + "archived_sessions/rollout-old.jsonl", + "archived_sessions/rollout-mid.jsonl", + ]); + }); + + test("selectPolicyPreview reduceToBytes keeps a single candidate when percent would over-select", () => { + const sizes = Array.from({ length: 100 }, (_, i) => ({ + name: `rollout-${String(i).padStart(3, "0")}.jsonl`, + bytes: 10, + when: new Date(OLD.getTime() + i * 60_000), + })); + const dir = seedHome(sizes); + const preview = selectPolicyPreview( + policy({ + enabled: true, + trigger: { archivedBytesOver: 0 }, + target: { reduceToBytes: 990 }, // total 1000 → free 10 → one file + }), + dir, + ); + expect(preview.count).toBe(1); + expect(preview.bytes).toBe(10); + expect(preview.candidateRelPaths).toHaveLength(1); }); }); @@ -170,10 +195,59 @@ describe("schedule helpers", () => { expect(isPolicyDue(base, 1, "startup")).toBe(false); expect(isPolicyDue({ ...base, schedule: "startup" }, 1, "startup")).toBe(true); expect(isPolicyDue({ ...base, schedule: "startup" }, 1, "schedule")).toBe(false); + expect(isPolicyDue({ ...base, schedule: "startup", nextRun: 100 }, 50, "schedule")).toBe(false); + expect(isPolicyDue({ ...base, schedule: "startup", nextRun: 100 }, 100, "schedule")).toBe(true); expect(isPolicyDue({ ...base, schedule: "daily", nextRun: 100 }, 50, "schedule")).toBe(false); expect(isPolicyDue({ ...base, schedule: "daily", nextRun: 100 }, 100, "schedule")).toBe(true); expect(isPolicyDue({ ...base, enabled: false, schedule: "daily" }, 1, "manual")).toBe(false); }); + + test("parseStorageCleanupPolicyInput preserves nextRun when schedule unchanged", () => { + const prev = policy({ + enabled: true, + trigger: { archivedBytesOver: 100 }, + target: { removeOldestPercent: 10 }, + schedule: "daily", + nextRun: 9_999, + }); + const parsed = parseStorageCleanupPolicyInput( + { + enabled: true, + trigger: { archivedBytesOver: 200 }, + target: { removeOldestPercent: 20 }, + schedule: "daily", + mode: "quarantine", + }, + prev, + ); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.policy.nextRun).toBe(9_999); + expect(parsed.policy.trigger.archivedBytesOver).toBe(200); + }); + + test("parseStorageCleanupPolicyInput recomputes nextRun when schedule changes", () => { + const prev = policy({ + enabled: true, + trigger: { archivedBytesOver: 100 }, + target: { removeOldestPercent: 10 }, + schedule: "manual", + }); + const before = Date.now(); + const parsed = parseStorageCleanupPolicyInput( + { + enabled: true, + trigger: { archivedBytesOver: 100 }, + target: { removeOldestPercent: 10 }, + schedule: "daily", + mode: "quarantine", + }, + prev, + ); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.policy.nextRun).toBeGreaterThanOrEqual(before + 24 * 60 * 60 * 1000); + }); }); describe("runStorageCleanupPolicy", () => { @@ -371,4 +445,47 @@ describe("runStorageCleanupPolicy", () => { expect(existsSync(join(dir, "archived_sessions", "rollout-new.jsonl"))).toBe(true); expect(existsSync(join(dir, ".trash"))).toBe(true); }); + + test("end-to-end reduceToBytes removes the exact candidate set", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + { name: "rollout-mid.jsonl", bytes: 100, when: MID }, + { name: "rollout-new.jsonl", bytes: 100, when: NEW }, + ]); + const stored: StorageCleanupPolicy[] = [ + policy({ + enabled: true, + trigger: { archivedBytesOver: 0 }, + target: { reduceToBytes: 100 }, + mode: "quarantine", + }), + ]; + let seen: ExecuteCleanupOptions | undefined; + const result = runStorageCleanupPolicy({ + reason: "manual", + force: true, + now: 55_000, + codexHome: dir, + loadPolicy: () => stored[0]!, + savePolicy: p => { stored[0] = p; }, + execute: (opts): CleanupResult => { + seen = opts; + return { + ok: true, + mode: opts.mode, + percent: opts.percent, + count: opts.candidateRelPaths?.length ?? 0, + bytes: 200, + removedPaths: opts.candidateRelPaths ?? [], + trashDir: ".trash/55000", + }; + }, + }); + expect(result.ok).toBe(true); + expect(seen?.candidateRelPaths).toEqual([ + "archived_sessions/rollout-old.jsonl", + "archived_sessions/rollout-mid.jsonl", + ]); + expect(result.removed).toBe(2); + }); }); From 5b3e59754dfeff0e507d387348fd68156de2a402 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:29:00 +0200 Subject: [PATCH 003/129] fix(storage): harden phase-3 policy review tip findings Reject cleared thresholds, fail closed on malformed targets, tear down scheduler on shutdown, and defer startup policy off the microtask queue. --- gui/src/pages/Storage.tsx | 11 ++++++---- src/server/index.ts | 5 +++-- src/server/lifecycle.ts | 5 +++++ src/storage/policy.ts | 40 ++++++++++++++++++++++-------------- tests/storage-policy.test.ts | 19 +++++++++++++++++ 5 files changed, 59 insertions(+), 21 deletions(-) diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 3a64a59d1..6d4e81d88 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -410,7 +410,8 @@ function AutoCleanupPolicyPanel({ const [percent, setPercent] = useState(25); /** Draft string so blank/invalid reduce targets are rejected instead of coerced to 0. */ const [reduceGb, setReduceGb] = useState("4"); - const [thresholdGb, setThresholdGb] = useState(5); + /** Draft string so a cleared threshold is rejected instead of coerced to 0. */ + const [thresholdGb, setThresholdGb] = useState("5"); const loadPolicy = useCallback(async (signal?: AbortSignal) => { setLoading(true); @@ -421,7 +422,7 @@ function AutoCleanupPolicyPanel({ const json = await res.json() as CleanupPolicy; if (signal?.aborted) return; setPolicy(json); - setThresholdGb(Math.max(0, Math.round((json.trigger.archivedBytesOver / GB) * 100) / 100)); + setThresholdGb(String(Math.max(0, Math.round((json.trigger.archivedBytesOver / GB) * 100) / 100))); if (json.target.reduceToBytes !== undefined) { setTargetMode("reduce"); setReduceGb(String(Math.max(0, Math.round((json.target.reduceToBytes / GB) * 100) / 100))); @@ -452,7 +453,9 @@ function AutoCleanupPolicyPanel({ const buildBody = (): CleanupPolicy | null => { if (!policy) return null; - const threshold = Number(thresholdGb); + const thresholdRaw = thresholdGb.trim(); + if (thresholdRaw === "") return null; + const threshold = Number(thresholdRaw); if (!Number.isFinite(threshold) || threshold < 0) return null; let target: CleanupPolicy["target"]; @@ -623,7 +626,7 @@ function AutoCleanupPolicyPanel({ step={0.1} value={thresholdGb} disabled={saving || running} - onChange={e => setThresholdGb(Number(e.target.value))} + onChange={e => setThresholdGb(e.target.value)} onBlur={() => void savePolicy()} style={{ display: "block", marginTop: 4, width: "100%" }} /> diff --git a/src/server/index.ts b/src/server/index.ts index f76c838ef..2dd253be5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -886,9 +886,10 @@ export function startServer(port?: number) { } // Opt-in storage policy (default OFF). Never blocks listen; errors are swallowed. - queueMicrotask(() => { + // Prefer macrotask over queueMicrotask so the sync archive walk does not starve I/O. + setTimeout(() => { maybeRunDueStorageCleanupPolicy("startup"); - }); + }, 0); return server; } diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index 39f97400d..3ec6256a9 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -1,4 +1,6 @@ import { flushResponseState } from "../responses/state"; +import { setStorageCleanupPolicyLiveSink } from "../storage/policy"; +import { stopStorageCleanupScheduler } from "../storage/policy-scheduler"; // --------------------------------------------------------------------------- // Active turn tracking + graceful shutdown drain @@ -68,6 +70,9 @@ export async function drainAndShutdown( // Debounced replay-state snapshot may still be pending; flush so the last completed turn's // previous_response_id chain survives the restart this shutdown is usually part of. flushResponseState(); + // Tear down opt-in storage policy timers / live-config sink so they cannot fire after stop. + stopStorageCleanupScheduler(); + setStorageCleanupPolicyLiveSink(null); s?.stop(true); draining = false; } diff --git a/src/storage/policy.ts b/src/storage/policy.ts index 424cdf8c4..48d7d468c 100644 --- a/src/storage/policy.ts +++ b/src/storage/policy.ts @@ -11,10 +11,11 @@ import { resolveCodexHomeDir } from "../codex/home"; import { loadConfig, saveConfigPreservingClaudeCode } from "../config"; import type { StorageCleanupPolicy } from "../types"; import { + computePreviewDigest, executeArchivedCleanup, listArchivedCandidates, - previewArchivedCleanup, previewExactArchivedCleanup, + selectOldestPercent, type CleanupMode, type CleanupResult, type ExecuteCleanupOptions, @@ -106,7 +107,8 @@ export function normalizeStorageCleanupPolicy(raw: unknown): StorageCleanupPolic if (!raw || typeof raw !== "object" || Array.isArray(raw)) return base; const o = raw as Record; - const enabled = o.enabled === true; // only explicit true enables + // only explicit true enables — and never when a present target is malformed + let enabled = o.enabled === true; let archivedBytesOver = base.trigger.archivedBytesOver; if (o.trigger && typeof o.trigger === "object" && !Array.isArray(o.trigger)) { @@ -115,14 +117,21 @@ export function normalizeStorageCleanupPolicy(raw: unknown): StorageCleanupPolic } let target: StorageCleanupPolicy["target"] = base.target; - if (o.target && typeof o.target === "object" && !Array.isArray(o.target)) { - const candidate = o.target as StorageCleanupPolicy["target"]; - if (isValidPolicyTarget(candidate)) { - const reduce = (candidate as { reduceToBytes?: number }).reduceToBytes; - const percent = (candidate as { removeOldestPercent?: number }).removeOldestPercent; - target = reduce !== undefined - ? { reduceToBytes: reduce } - : { removeOldestPercent: Math.min(100, Math.max(1, Math.floor(percent!))) }; + if (Object.prototype.hasOwnProperty.call(o, "target")) { + if (o.target && typeof o.target === "object" && !Array.isArray(o.target)) { + const candidate = o.target as StorageCleanupPolicy["target"]; + if (isValidPolicyTarget(candidate)) { + const reduce = (candidate as { reduceToBytes?: number }).reduceToBytes; + const percent = (candidate as { removeOldestPercent?: number }).removeOldestPercent; + target = reduce !== undefined + ? { reduceToBytes: reduce } + : { removeOldestPercent: Math.min(100, Math.max(1, Math.floor(percent!))) }; + } else { + // Fail closed: malformed persisted target must not become delete-oldest 25%. + enabled = false; + } + } else { + enabled = false; } } @@ -348,14 +357,15 @@ export function selectPolicyPreview( }; } + // Reuse the already-listed candidates — avoid a second archive directory walk. const percent = Math.min(100, Math.max(0, Math.floor(removePct ?? 0))); - const preview = previewArchivedCleanup(percent, codexHome); + const selected = selectOldestPercent(all, percent); return { archivedBytes, - percent: preview.percent, - count: preview.count, - bytes: preview.bytes, - digest: preview.digest, + percent, + count: selected.length, + bytes: selected.reduce((sum, c) => sum + c.bytes, 0), + digest: computePreviewDigest(selected, percent), }; } diff --git a/tests/storage-policy.test.ts b/tests/storage-policy.test.ts index e10908242..f774fe0be 100644 --- a/tests/storage-policy.test.ts +++ b/tests/storage-policy.test.ts @@ -82,6 +82,25 @@ describe("normalizeStorageCleanupPolicy", () => { expect(normalizeStorageCleanupPolicy({ mode: "nope" }).mode).toBe("quarantine"); expect(normalizeStorageCleanupPolicy({}).mode).toBe("quarantine"); }); + + test("malformed persisted target fails closed (does not enable delete-oldest 25%)", () => { + const both = normalizeStorageCleanupPolicy({ + enabled: true, + target: { reduceToBytes: 1, removeOldestPercent: 25 }, + }); + expect(both.enabled).toBe(false); + expect(both.target).toEqual({ removeOldestPercent: 25 }); + + const empty = normalizeStorageCleanupPolicy({ enabled: true, target: {} }); + expect(empty.enabled).toBe(false); + + const badType = normalizeStorageCleanupPolicy({ enabled: true, target: "percent" }); + expect(badType.enabled).toBe(false); + + const missingTarget = normalizeStorageCleanupPolicy({ enabled: true }); + expect(missingTarget.enabled).toBe(true); + expect(missingTarget.target).toEqual({ removeOldestPercent: 25 }); + }); }); describe("selection helpers", () => { From ee9574065a4cb592d44ab4358316ee37c727d7a4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:55:27 +0200 Subject: [PATCH 004/129] fix(storage): run cleanup policy off the proxy event loop Move archive scan/FS/SQLite cleanup into a Bun Worker behind a single-flight job controller so POST /run returns immediately and /healthz stays responsive. --- gui/src/pages/Storage.tsx | 107 +++++-- src/server/index.ts | 20 +- src/server/lifecycle.ts | 8 +- src/server/management/logs-usage-routes.ts | 64 ++--- src/storage/policy-job.ts | 295 ++++++++++++++++++++ src/storage/policy-scheduler.ts | 27 +- src/storage/policy-worker.ts | 53 ++++ tests/api-storage-policy.test.ts | 131 ++++++++- tests/storage-policy-job-responsive.test.ts | 144 ++++++++++ 9 files changed, 764 insertions(+), 85 deletions(-) create mode 100644 src/storage/policy-job.ts create mode 100644 src/storage/policy-worker.ts create mode 100644 tests/storage-policy-job-responsive.test.ts diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 6d4e81d88..104b89eb8 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -387,6 +387,32 @@ interface CleanupPolicy { mode: "quarantine" | "permanent"; lastRun?: { at: number; freedBytes: number; removed: number }; nextRun?: number; + job?: { + status: "idle" | "running"; + reason?: string; + startedAt?: number; + finishedAt?: number; + lastError?: string; + lastOutcome?: { + ok: boolean; + skipped?: string; + deferred?: string; + error?: string; + mode?: string; + freedBytes?: number; + removed?: number; + }; + }; +} + +function policyFieldsFromResponse(json: CleanupPolicy): CleanupPolicy { + const { job, ...policy } = json; + void job; + return policy; +} + +async function sleep(ms: number): Promise { + await new Promise(resolve => window.setTimeout(resolve, ms)); } function AutoCleanupPolicyPanel({ @@ -421,7 +447,7 @@ function AutoCleanupPolicyPanel({ if (!res.ok) throw new Error("load_failed"); const json = await res.json() as CleanupPolicy; if (signal?.aborted) return; - setPolicy(json); + setPolicy(policyFieldsFromResponse(json)); setThresholdGb(String(Math.max(0, Math.round((json.trigger.archivedBytesOver / GB) * 100) / 100))); if (json.target.reduceToBytes !== undefined) { setTargetMode("reduce"); @@ -501,7 +527,7 @@ function AutoCleanupPolicyPanel({ setError(t("storage.policy.saveFailed")); return; } - setPolicy(json.policy); + setPolicy(policyFieldsFromResponse(json.policy)); setStatus(t("storage.policy.saved")); } catch { setError(t("storage.policy.saveFailed")); @@ -531,40 +557,78 @@ function AutoCleanupPolicyPanel({ setError(t("storage.policy.saveFailed")); return; } - setPolicy(saved.policy); + setPolicy(policyFieldsFromResponse(saved.policy)); const res = await fetch(`${apiBase}/api/storage/cleanup-policy/run`, { method: "POST" }); - const json = await res.json() as { + const startJson = await res.json() as { ok?: boolean; - skipped?: string; - deferred?: boolean; + started?: boolean; error?: string; - removed?: number; - freedBytes?: number; - mode?: string; + job?: CleanupPolicy["job"]; policy?: CleanupPolicy; }; - if (json.policy) setPolicy(json.policy); - if (json.skipped === "disabled") { + if (startJson.policy) setPolicy(policyFieldsFromResponse(startJson.policy)); + if (!res.ok || startJson.error === "already_running") { + setError(t("storage.policy.runFailed")); + return; + } + if (!startJson.started || !startJson.job?.startedAt) { + setError(t("storage.policy.runFailed")); + return; + } + + const startedAt = startJson.job.startedAt; + const deadline = Date.now() + 120_000; + let outcome: NonNullable["lastOutcome"] | undefined; + let finalPolicy: CleanupPolicy | undefined; + + while (Date.now() < deadline) { + await sleep(250); + const pollRes = await fetch(`${apiBase}/api/storage/cleanup-policy`); + if (!pollRes.ok) continue; + const body = await pollRes.json() as CleanupPolicy; + finalPolicy = policyFieldsFromResponse(body); + setPolicy(finalPolicy); + const job = body.job; + if (!job) continue; + if (job.status === "running") continue; + if (job.startedAt === startedAt && job.lastOutcome) { + outcome = job.lastOutcome; + break; + } + // Job finished so fast that startedAt advanced; accept matching finished marker. + if (job.finishedAt && job.finishedAt >= startedAt && job.lastOutcome) { + outcome = job.lastOutcome; + break; + } + } + + if (finalPolicy) setPolicy(finalPolicy); + if (!outcome) { + setError(t("storage.policy.runFailed")); + return; + } + + if (outcome.skipped === "disabled") { setStatus(t("storage.policy.skippedDisabled")); - } else if (json.skipped === "under_threshold") { + } else if (outcome.skipped === "under_threshold") { setStatus(t("storage.policy.skippedUnder")); - } else if (json.skipped === "nothing_selected") { + } else if (outcome.skipped === "nothing_selected") { setStatus(t("storage.policy.skippedEmpty")); - } else if (json.deferred || json.error === "codex_busy") { + } else if (outcome.deferred === "codex_busy" || outcome.error === "codex_busy") { setError(t("storage.cleanup.err.codex_busy")); - } else if (!res.ok || !json.ok) { + } else if (!outcome.ok) { setError(t("storage.policy.runFailed")); } else { setStatus( - json.mode === "permanent" + outcome.mode === "permanent" ? t("storage.policy.donePermanent", { - count: String(json.removed ?? 0), - size: formatBytes(json.freedBytes ?? 0, locale), + count: String(outcome.removed ?? 0), + size: formatBytes(outcome.freedBytes ?? 0, locale), }) : t("storage.policy.doneQuarantine", { - count: String(json.removed ?? 0), - size: formatBytes(json.freedBytes ?? 0, locale), + count: String(outcome.removed ?? 0), + size: formatBytes(outcome.freedBytes ?? 0, locale), }), ); onDone(); @@ -609,7 +673,6 @@ function AutoCleanupPolicyPanel({ disabled={saving || running} onChange={e => { const enabled = e.target.checked; - setPolicy({ ...policy, enabled }); void savePolicy({ enabled }); }} /> @@ -687,7 +750,6 @@ function AutoCleanupPolicyPanel({ disabled={saving || running} onChange={e => { const schedule = e.target.value as CleanupPolicy["schedule"]; - setPolicy({ ...policy, schedule }); void savePolicy({ schedule }); }} style={{ display: "block", marginTop: 4, width: "100%" }} @@ -706,7 +768,6 @@ function AutoCleanupPolicyPanel({ disabled={saving || running} onChange={e => { const mode = e.target.value as CleanupPolicy["mode"]; - setPolicy({ ...policy, mode }); void savePolicy({ mode }); }} style={{ display: "block", marginTop: 4, width: "100%" }} diff --git a/src/server/index.ts b/src/server/index.ts index 2dd253be5..05c9b2b4f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -20,12 +20,14 @@ import { import { reconcileOAuthProviders } from "../oauth"; import { invalidateCodexModelsCache } from "../codex/catalog"; import { startMemoryWatchdog } from "./memory-watchdog"; -import { maybeRunDueStorageCleanupPolicy, setStorageCleanupPolicyLiveSink } from "../storage/policy"; -import { startStorageCleanupScheduler } from "../storage/policy-scheduler"; +import { setStorageCleanupPolicyLiveSink } from "../storage/policy"; +import { setStorageCleanupPolicyJobLiveApply } from "../storage/policy-job"; +import { scheduleStorageCleanupStartupRun, startStorageCleanupScheduler } from "../storage/policy-scheduler"; import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup"; import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; +import type { StorageCleanupPolicy } from "../types"; import { CodexAccountCooldownError, cooldownErrorMessage, @@ -292,10 +294,13 @@ export function startServer(port?: number) { startMemoryWatchdog(); // Issue #42 Phase 3: opt-in archived auto-cleanup (default OFF). Unref'd hourly // tick for daily/weekly; startup evaluation is fire-and-forget after listen. + // Heavy work runs in a Worker via the single-flight job controller. // Keep live config.policy in sync when background runs advance nextRun/lastRun. - setStorageCleanupPolicyLiveSink((policy) => { + const applyPolicy = (policy: StorageCleanupPolicy) => { config.storageCleanupPolicy = policy; - }); + }; + setStorageCleanupPolicyLiveSink(applyPolicy); + setStorageCleanupPolicyJobLiveApply(applyPolicy); startStorageCleanupScheduler(); const listenPort = port ?? config.port ?? 10100; @@ -885,11 +890,8 @@ export function startServer(port?: number) { .catch(() => {}); } - // Opt-in storage policy (default OFF). Never blocks listen; errors are swallowed. - // Prefer macrotask over queueMicrotask so the sync archive walk does not starve I/O. - setTimeout(() => { - maybeRunDueStorageCleanupPolicy("startup"); - }, 0); + // Opt-in storage policy (default OFF). Never blocks listen; cancellable on shutdown. + scheduleStorageCleanupStartupRun(); return server; } diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index 3ec6256a9..6f05c5cc5 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -1,5 +1,9 @@ import { flushResponseState } from "../responses/state"; import { setStorageCleanupPolicyLiveSink } from "../storage/policy"; +import { + abortStorageCleanupPolicyJob, + setStorageCleanupPolicyJobLiveApply, +} from "../storage/policy-job"; import { stopStorageCleanupScheduler } from "../storage/policy-scheduler"; // --------------------------------------------------------------------------- @@ -70,9 +74,11 @@ export async function drainAndShutdown( // Debounced replay-state snapshot may still be pending; flush so the last completed turn's // previous_response_id chain survives the restart this shutdown is usually part of. flushResponseState(); - // Tear down opt-in storage policy timers / live-config sink so they cannot fire after stop. + // Tear down opt-in storage policy timers / worker / live-config sink so they cannot fire after stop. stopStorageCleanupScheduler(); + abortStorageCleanupPolicyJob(); setStorageCleanupPolicyLiveSink(null); + setStorageCleanupPolicyJobLiveApply(null); s?.stop(true); draining = false; } diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 655f0bce3..6d1f83d4c 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -38,10 +38,13 @@ import { executeArchivedCleanup, pickWireCleanupTestHooks, previewArchivedCleanu import { normalizeStorageCleanupPolicy, parseStorageCleanupPolicyInput, - runStorageCleanupPolicy, writeStorageCleanupPolicyToConfig, } from "../../storage/policy"; -import type { StorageCleanupPolicy } from "../../types"; +import { + getStorageCleanupPolicyJobState, + getStorageCleanupPolicyTestStreamResponse, + requestStorageCleanupPolicyRun, +} from "../../storage/policy-job"; import { currentUsageLogRevision, readUsageSnapshotForManagement, @@ -333,9 +336,17 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise | null = null; +let activeWorker: Worker | null = null; +let testHooks: PolicyJobTestHooks | null = null; +/** Optional mirror so completed worker runs refresh the live server config. */ +let livePolicyApply: ((policy: PolicyRunResult["policy"]) => void) | undefined; + +export function setStorageCleanupPolicyJobLiveApply( + apply: ((policy: PolicyRunResult["policy"]) => void) | null, +): void { + livePolicyApply = apply ?? undefined; +} + +export function getStorageCleanupPolicyJobState(): PolicyJobState { + return { ...state, ...(state.lastOutcome ? { lastOutcome: { ...state.lastOutcome } } : {}) }; +} + +/** Test-only SSE/text stream served from the proxy while a worker is blocked. */ +export function getStorageCleanupPolicyTestStreamResponse(): Response | null { + if (!testHooks?.enableTestStream) return null; + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + async start(controller) { + for (let i = 0; i < 8; i++) { + controller.enqueue(encoder.encode(`chunk-${i}\n`)); + await Bun.sleep(50); + } + controller.close(); + }, + }), + { headers: { "content-type": "text/plain; charset=utf-8" } }, + ); +} + +export function setStorageCleanupPolicyJobTestHooks(hooks: PolicyJobTestHooks | null): void { + testHooks = hooks; +} + +export function resetStorageCleanupPolicyJobForTests(): void { + if (activeWorker) { + try { activeWorker.terminate(); } catch { /* */ } + activeWorker = null; + } + inflight = null; + testHooks = null; + state = { status: "idle" }; +} + +/** Terminate an in-flight worker during process shutdown. */ +export function abortStorageCleanupPolicyJob(): void { + if (activeWorker) { + try { activeWorker.terminate(); } catch { /* */ } + activeWorker = null; + } + if (state.status === "running") { + state = { + ...state, + status: "idle", + finishedAt: Date.now(), + lastError: "aborted", + lastOutcome: { + ok: false, + error: "evaluation_failed", + }, + }; + } + inflight = null; +} + +function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome { + return { + ok: result.ok, + ...(result.skipped ? { skipped: result.skipped } : {}), + ...(result.deferred ? { deferred: result.deferred } : {}), + ...(result.error ? { error: result.error } : {}), + ...(result.mode ? { mode: result.mode } : {}), + ...(result.freedBytes !== undefined ? { freedBytes: result.freedBytes } : {}), + ...(result.removed !== undefined ? { removed: result.removed } : {}), + ...(result.trashDir ? { trashDir: result.trashDir } : {}), + }; +} + +function applyFinished(result: PolicyRunResult): void { + livePolicyApply?.(result.policy); + state = { + status: "idle", + reason: state.reason, + startedAt: state.startedAt, + finishedAt: Date.now(), + lastOutcome: outcomeFromResult(result), + }; +} + +function applyFailed(message: string): void { + state = { + status: "idle", + reason: state.reason, + startedAt: state.startedAt, + finishedAt: Date.now(), + lastError: message, + lastOutcome: { ok: false, error: "worker_failed" }, + }; +} + +function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Promise { + return new Promise((resolve, reject) => { + const requestId = crypto.randomUUID(); + let settled = false; + const worker = new Worker(new URL("./policy-worker.ts", import.meta.url).href); + activeWorker = worker; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + try { worker.terminate(); } catch { /* */ } + if (activeWorker === worker) activeWorker = null; + reject(new Error("storage_cleanup_worker_timeout")); + }, WORKER_TIMEOUT_MS); + + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (activeWorker === worker) activeWorker = null; + try { worker.terminate(); } catch { /* */ } + fn(); + }; + + worker.onmessage = (event: MessageEvent) => { + const data = event.data; + if (!data || typeof data !== "object") return; + const msg = data as Record; + if (msg.requestId !== requestId) return; + if (msg.type === "done" && msg.result && typeof msg.result === "object") { + finish(() => resolve(msg.result as PolicyRunResult)); + return; + } + if (msg.type === "error") { + const message = typeof msg.message === "string" ? msg.message : "worker_failed"; + finish(() => reject(new Error(message))); + } + }; + + worker.onerror = (err: ErrorEvent) => { + finish(() => reject(err.error instanceof Error ? err.error : new Error(err.message || "worker_failed"))); + }; + + worker.postMessage({ + type: "run", + requestId, + reason: opts.reason, + force: opts.force === true, + ...(opts.codexHome ? { codexHome: opts.codexHome } : {}), + ...(opts.busyTimeoutMs !== undefined ? { busyTimeoutMs: opts.busyTimeoutMs } : {}), + ...(opts.blockMs !== undefined ? { blockMs: opts.blockMs } : {}), + env: { + ...(process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {}), + ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}), + }, + }); + }); +} + +async function executeJob(opts: RequestPolicyRunOptions): Promise { + try { + const blockMs = testHooks?.blockMs; + let result: PolicyRunResult; + + if (testHooks?.runInProcess) { + if (typeof blockMs === "number" && blockMs > 0) { + await Bun.sleep(blockMs); + } + // Dynamic import keeps the sync engine out of the hot path for the default Worker mode. + const { runStorageCleanupPolicy } = await import("./policy"); + result = runStorageCleanupPolicy({ + reason: opts.reason, + force: opts.force === true, + ...(opts.codexHome ? { codexHome: opts.codexHome } : {}), + ...(opts.busyTimeoutMs !== undefined ? { busyTimeoutMs: opts.busyTimeoutMs } : {}), + }); + } else { + result = await runInWorker({ + ...opts, + ...(typeof blockMs === "number" && blockMs > 0 ? { blockMs } : {}), + }); + } + + applyFinished(result); + } catch (err) { + applyFailed(err instanceof Error ? err.message : "worker_failed"); + } finally { + inflight = null; + } +} + +/** + * Start a cleanup evaluation if idle. Returns immediately; poll GET for outcome. + */ +export function requestStorageCleanupPolicyRun( + opts: RequestPolicyRunOptions, +): { accepted: true; state: PolicyJobState } | { accepted: false; error: "already_running"; state: PolicyJobState } { + if (inflight || state.status === "running") { + return { accepted: false, error: "already_running", state: getStorageCleanupPolicyJobState() }; + } + + state = { + status: "running", + reason: opts.reason, + startedAt: Date.now(), + lastError: undefined, + ...(state.lastOutcome ? { lastOutcome: state.lastOutcome } : {}), + }; + + inflight = executeJob(opts); + return { accepted: true, state: getStorageCleanupPolicyJobState() }; +} + +/** + * Fire-and-forget entry for startup / schedule ticks. + * Skips the single-flight slot when disabled or not due so manual runs stay free. + */ +export function maybeRequestStorageCleanupPolicyRun( + reason: PolicyRunReason, + opts?: Omit, +): void { + try { + const policy = readStorageCleanupPolicyFromConfig(); + if (!policy.enabled) return; + if (!isPolicyDue(policy, Date.now(), reason)) return; + requestStorageCleanupPolicyRun({ ...opts, reason }); + } catch { + // Keep scheduler/startup non-throwing. + } +} diff --git a/src/storage/policy-scheduler.ts b/src/storage/policy-scheduler.ts index 1e5dd863a..375ec4bec 100644 --- a/src/storage/policy-scheduler.ts +++ b/src/storage/policy-scheduler.ts @@ -1,23 +1,40 @@ /** * Long-running schedule tick for daily/weekly storage cleanup policies. * Unref'd interval — never keeps the process alive alone (safe for tests). + * Startup kickoff is tracked here so shutdown can cancel it. */ -import { maybeRunDueStorageCleanupPolicy } from "./policy"; +import { maybeRequestStorageCleanupPolicyRun } from "./policy-job"; const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1h let timer: ReturnType | null = null; +let startupTimer: ReturnType | null = null; export function startStorageCleanupScheduler(intervalMs = DEFAULT_INTERVAL_MS): void { if (timer) return; timer = setInterval(() => { - maybeRunDueStorageCleanupPolicy("schedule"); + maybeRequestStorageCleanupPolicyRun("schedule"); }, intervalMs); timer.unref?.(); } +/** Fire-and-forget startup evaluation; cancellable via stopStorageCleanupScheduler. */ +export function scheduleStorageCleanupStartupRun(): void { + if (startupTimer) return; + startupTimer = setTimeout(() => { + startupTimer = null; + maybeRequestStorageCleanupPolicyRun("startup"); + }, 0); + startupTimer.unref?.(); +} + export function stopStorageCleanupScheduler(): void { - if (!timer) return; - clearInterval(timer); - timer = null; + if (timer) { + clearInterval(timer); + timer = null; + } + if (startupTimer) { + clearTimeout(startupTimer); + startupTimer = null; + } } diff --git a/src/storage/policy-worker.ts b/src/storage/policy-worker.ts new file mode 100644 index 000000000..1201c0d5b --- /dev/null +++ b/src/storage/policy-worker.ts @@ -0,0 +1,53 @@ +/** + * Worker-thread entry for storage cleanup policy runs. + * Keeps archive scan / FS cleanup / SQLite reconcile off the proxy event loop. + */ +import { runStorageCleanupPolicy, type PolicyRunReason } from "./policy"; + +interface RunMessage { + type: "run"; + requestId: string; + reason: PolicyRunReason; + force?: boolean; + codexHome?: string; + busyTimeoutMs?: number; + /** Test-only: block this worker thread before evaluating the policy. */ + blockMs?: number; + /** Env snapshot — Workers may not see parent mutations on all platforms. */ + env?: { CODEX_HOME?: string; OPENCODEX_HOME?: string }; +} + +function isRunMessage(data: unknown): data is RunMessage { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + const o = data as Record; + return o.type === "run" + && typeof o.requestId === "string" + && (o.reason === "startup" || o.reason === "schedule" || o.reason === "manual"); +} + +declare const self: Worker; + +self.onmessage = (event: MessageEvent) => { + if (!isRunMessage(event.data)) return; + const { requestId, reason, force, codexHome, busyTimeoutMs, blockMs, env } = event.data; + try { + if (env?.CODEX_HOME) process.env.CODEX_HOME = env.CODEX_HOME; + if (env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = env.OPENCODEX_HOME; + if (typeof blockMs === "number" && Number.isFinite(blockMs) && blockMs > 0) { + Bun.sleepSync(Math.floor(blockMs)); + } + const result = runStorageCleanupPolicy({ + reason, + force: force === true, + ...(codexHome ? { codexHome } : {}), + ...(busyTimeoutMs !== undefined ? { busyTimeoutMs } : {}), + }); + self.postMessage({ type: "done", requestId, result }); + } catch (err) { + self.postMessage({ + type: "error", + requestId, + message: err instanceof Error ? err.message : "worker_failed", + }); + } +}; diff --git a/tests/api-storage-policy.test.ts b/tests/api-storage-policy.test.ts index 2b11220ce..418036202 100644 --- a/tests/api-storage-policy.test.ts +++ b/tests/api-storage-policy.test.ts @@ -7,6 +7,10 @@ import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { + resetStorageCleanupPolicyJobForTests, + setStorageCleanupPolicyJobTestHooks, +} from "../src/storage/policy-job"; import { stopStorageCleanupScheduler } from "../src/storage/policy-scheduler"; let testDir = ""; @@ -43,6 +47,57 @@ function seedArchived(codexHome: string): void { db.close(); } +async function waitForJobIdle( + serverUrl: URL, + startedAt: number, + timeoutMs = 15_000, +): Promise<{ + enabled: boolean; + lastRun?: { removed: number }; + job: { + status: string; + lastOutcome?: { + ok?: boolean; + skipped?: string; + removed?: number; + freedBytes?: number; + error?: string; + deferred?: string; + }; + }; +}> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const res = await fetch(new URL("/api/storage/cleanup-policy", serverUrl)); + const body = await res.json() as { + enabled: boolean; + lastRun?: { removed: number }; + job: { + status: string; + startedAt?: number; + finishedAt?: number; + lastOutcome?: { + ok?: boolean; + skipped?: string; + removed?: number; + freedBytes?: number; + error?: string; + deferred?: string; + }; + }; + }; + if ( + body.job.status === "idle" + && body.job.lastOutcome + && (body.job.startedAt === startedAt || (body.job.finishedAt ?? 0) >= startedAt) + ) { + return body; + } + await Bun.sleep(50); + } + throw new Error("policy job did not become idle in time"); +} + beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; isolatedCodexHome = installIsolatedCodexHome("ocx-api-storage-policy-codex-"); @@ -50,10 +105,13 @@ beforeEach(() => { process.env.OPENCODEX_HOME = testDir; saveConfig(baseConfig()); stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); }); afterEach(() => { stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); + setStorageCleanupPolicyJobTestHooks(null); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); @@ -73,9 +131,11 @@ describe("storage cleanup policy API", () => { expect(body.mode).toBe("quarantine"); expect(body.schedule).toBe("manual"); expect(body.trigger.archivedBytesOver).toBeGreaterThan(0); + expect(body.job.status).toBe("idle"); } finally { await server.stop(true); stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); } }); @@ -107,6 +167,7 @@ describe("storage cleanup policy API", () => { } finally { await server.stop(true); stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); } }); @@ -127,10 +188,11 @@ describe("storage cleanup policy API", () => { } finally { await server.stop(true); stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); } }); - test("POST run skips when disabled; runs when enabled", async () => { + test("POST run starts job promptly; skipped/success land on GET", async () => { seedArchived(isolatedCodexHome!.path); const server = startServer(0); try { @@ -138,8 +200,11 @@ describe("storage cleanup policy API", () => { method: "POST", }); expect(skipped.status).toBe(200); - const skipBody = await skipped.json(); - expect(skipBody.skipped).toBe("disabled"); + const skipStart = await skipped.json(); + expect(skipStart.started).toBe(true); + expect(skipStart.job.status).toBe("running"); + const skipDone = await waitForJobIdle(server.url, skipStart.job.startedAt); + expect(skipDone.job.lastOutcome?.skipped).toBe("disabled"); await fetch(new URL("/api/storage/cleanup-policy", server.url), { method: "PUT", @@ -157,15 +222,63 @@ describe("storage cleanup policy API", () => { method: "POST", }); expect(ran.status).toBe(200); - const ranBody = await ran.json(); - expect(ranBody.ok).toBe(true); - expect(ranBody.removed).toBe(1); - expect(ranBody.freedBytes).toBe(100); - expect(ranBody.policy.lastRun.removed).toBe(1); - expect(JSON.stringify(ranBody)).not.toContain(isolatedCodexHome!.path.replaceAll("\\", "\\\\")); + const ranStart = await ran.json(); + expect(ranStart.ok).toBe(true); + expect(ranStart.started).toBe(true); + expect(ranStart.job.status).toBe("running"); + // Must not block on full cleanup — response should not embed removed counts. + expect(ranStart.removed).toBeUndefined(); + + const ranDone = await waitForJobIdle(server.url, ranStart.job.startedAt); + expect(ranDone.job.lastOutcome?.ok).toBe(true); + expect(ranDone.job.lastOutcome?.removed).toBe(1); + expect(ranDone.job.lastOutcome?.freedBytes).toBe(100); + expect(ranDone.lastRun?.removed).toBe(1); + expect(JSON.stringify(ranDone)).not.toContain(isolatedCodexHome!.path.replaceAll("\\", "\\\\")); + } finally { + await server.stop(true); + stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); + } + }); + + test("POST run rejects when a job is already running", async () => { + setStorageCleanupPolicyJobTestHooks({ blockMs: 800 }); + seedArchived(isolatedCodexHome!.path); + const server = startServer(0); + try { + await fetch(new URL("/api/storage/cleanup-policy", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled: true, + trigger: { archivedBytesOver: 50 }, + target: { removeOldestPercent: 50 }, + schedule: "manual", + mode: "quarantine", + }), + }); + + const first = await fetch(new URL("/api/storage/cleanup-policy/run", server.url), { + method: "POST", + }); + expect(first.status).toBe(200); + const firstBody = await first.json(); + expect(firstBody.started).toBe(true); + + const second = await fetch(new URL("/api/storage/cleanup-policy/run", server.url), { + method: "POST", + }); + expect(second.status).toBe(409); + const secondBody = await second.json(); + expect(secondBody.error).toBe("already_running"); + expect(secondBody.started).toBe(false); + + await waitForJobIdle(server.url, firstBody.job.startedAt); } finally { await server.stop(true); stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); } }); }); diff --git a/tests/storage-policy-job-responsive.test.ts b/tests/storage-policy-job-responsive.test.ts new file mode 100644 index 000000000..55cb38dfc --- /dev/null +++ b/tests/storage-policy-job-responsive.test.ts @@ -0,0 +1,144 @@ +/** + * Regression: a blocked cleanup Worker must not stall /healthz or an active + * streaming response on the proxy event loop. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { + resetStorageCleanupPolicyJobForTests, + setStorageCleanupPolicyJobTestHooks, +} from "../src/storage/policy-job"; +import { stopStorageCleanupScheduler } from "../src/storage/policy-scheduler"; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function baseConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "forward", + }, + }, + } as OcxConfig; +} + +function seedArchived(codexHome: string): void { + mkdirSync(join(codexHome, "archived_sessions")); + writeFileSync(join(codexHome, "archived_sessions", "rollout-old.jsonl"), "o".repeat(100)); + writeFileSync(join(codexHome, "archived_sessions", "rollout-new.jsonl"), "n".repeat(200)); + utimesSync(join(codexHome, "archived_sessions", "rollout-old.jsonl"), new Date("2026-01-01"), new Date("2026-01-01")); + utimesSync(join(codexHome, "archived_sessions", "rollout-new.jsonl"), new Date("2026-06-01"), new Date("2026-06-01")); + const db = new Database(join(codexHome, "state_5.sqlite")); + db.exec(`CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER)`); + db.exec(`INSERT INTO threads VALUES + ('told','archived_sessions/rollout-old.jsonl',1), + ('tnew','archived_sessions/rollout-new.jsonl',1) + `); + db.close(); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-policy-job-responsive-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-policy-job-responsive-")); + process.env.OPENCODEX_HOME = testDir; + saveConfig(baseConfig()); + stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); +}); + +afterEach(() => { + stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); + setStorageCleanupPolicyJobTestHooks(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +describe("storage cleanup policy job responsiveness", () => { + test("blocked worker keeps /healthz and streaming response responsive", async () => { + const blockMs = 1200; + setStorageCleanupPolicyJobTestHooks({ blockMs, enableTestStream: true }); + seedArchived(isolatedCodexHome!.path); + + const server = startServer(0); + try { + await fetch(new URL("/api/storage/cleanup-policy", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled: true, + trigger: { archivedBytesOver: 50 }, + target: { removeOldestPercent: 50 }, + schedule: "manual", + mode: "quarantine", + }), + }); + + const runStarted = Date.now(); + const runRes = await fetch(new URL("/api/storage/cleanup-policy/run", server.url), { + method: "POST", + }); + const runBody = await runRes.json() as { started?: boolean; job?: { status: string; startedAt: number } }; + expect(runRes.status).toBe(200); + expect(runBody.started).toBe(true); + expect(runBody.job?.status).toBe("running"); + // POST itself must return well before the worker block finishes. + expect(Date.now() - runStarted).toBeLessThan(blockMs / 2); + + const streamPromise = (async () => { + const res = await fetch(new URL("/api/storage/cleanup-policy/test-stream", server.url)); + expect(res.ok).toBe(true); + return await res.text(); + })(); + + const healthSamples: number[] = []; + for (let i = 0; i < 6; i++) { + const t0 = Date.now(); + const health = await fetch(new URL("/healthz", server.url)); + expect(health.status).toBe(200); + healthSamples.push(Date.now() - t0); + await Bun.sleep(40); + } + + const streamText = await streamPromise; + expect(streamText.split("\n").filter(Boolean).length).toBe(8); + + // Every health probe during the blocked worker window should stay snappy. + for (const sample of healthSamples) { + expect(sample).toBeLessThan(400); + } + expect(Math.max(...healthSamples)).toBeLessThan(400); + + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const got = await fetch(new URL("/api/storage/cleanup-policy", server.url)); + const body = await got.json() as { job: { status: string; startedAt?: number } }; + if (body.job.status === "idle" && body.job.startedAt === runBody.job?.startedAt) break; + await Bun.sleep(50); + } + } finally { + await server.stop(true); + stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); + } + }); +}); From 8c3c349748c78124730cee431a706ee8bf504cc0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:11:06 +0200 Subject: [PATCH 005/129] fix(storage): avoid Windows CI timeouts in cleanup/policy tests Replace the 100-file FS fixture with synthetic over-select math plus a small exact-path check, and raise timeouts on concurrent satellite restore cases that measure multi-second on Windows runners. --- tests/storage-cleanup.test.ts | 6 ++-- tests/storage-policy.test.ts | 55 ++++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/tests/storage-cleanup.test.ts b/tests/storage-cleanup.test.ts index b28258e00..85bbc0d6e 100644 --- a/tests/storage-cleanup.test.ts +++ b/tests/storage-cleanup.test.ts @@ -820,6 +820,8 @@ describe("executeArchivedCleanup", () => { expect(existsSync(join(home, "memories_1.sqlite"))).toBe(false); }); + // Windows CI: multi-DB reconcile + restore with concurrent writers measures 2–7s there + // (vs <400ms locally) and trips bun's default 5s harness timeout. test("concurrent satellite writes after mutation are preserved on restore", () => { home = buildHome({ withSatelliteStores: true }); const result = runWithDigest(100, "permanent", home, { @@ -867,7 +869,7 @@ describe("executeArchivedCleanup", () => { "active", "tmid", "tnew", "told", ]); state.close(); - }); + }, { timeout: 30_000 }); test("concurrent consolidate enqueue watermark change is preserved on restore", () => { home = buildHome({ withSatelliteStores: true }); @@ -904,5 +906,5 @@ describe("executeArchivedCleanup", () => { "active", "tmid", "tnew", "told", ]); state.close(); - }); + }, { timeout: 30_000 }); }); diff --git a/tests/storage-policy.test.ts b/tests/storage-policy.test.ts index f774fe0be..ef2aee78a 100644 --- a/tests/storage-policy.test.ts +++ b/tests/storage-policy.test.ts @@ -25,6 +25,8 @@ import { } from "../src/storage/policy"; import { listArchivedCandidates, + selectOldestPercent, + type ArchivedCandidate, type CleanupResult, type ExecuteCleanupOptions, } from "../src/storage/cleanup"; @@ -47,18 +49,40 @@ function seedHome(sizes: Array<{ name: string; bytes: number; when: Date }>): st mkdirSync(join(home, "archived_sessions")); const db = new Database(join(home, "state_5.sqlite")); db.exec(`CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER)`); + const insert = db.prepare( + `INSERT INTO threads VALUES ($id, $path, 1)`, + ); + db.exec("BEGIN"); for (const entry of sizes) { const path = join(home, "archived_sessions", entry.name); writeFileSync(path, "x".repeat(entry.bytes)); utimesSync(path, entry.when, entry.when); - db.exec( - `INSERT INTO threads VALUES ('${entry.name}','archived_sessions/${entry.name}',1)`, - ); + insert.run({ $id: entry.name, $path: `archived_sessions/${entry.name}` }); } + db.exec("COMMIT"); db.close(); return home; } +/** Minimal ArchivedCandidate list for pure selection math (no FS). */ +function syntheticCandidates( + count: number, + bytesEach: number, +): ArchivedCandidate[] { + return Array.from({ length: count }, (_, i) => { + const relPath = `archived_sessions/rollout-${String(i).padStart(3, "0")}.jsonl`; + const mtimeMs = OLD.getTime() + i * 60_000; + return { + relPath, + absPath: relPath, + bytes: bytesEach, + mtimeMs, + physicalRelPaths: [relPath], + physicalFiles: [{ relPath, bytes: bytesEach, mtimeMs }], + }; + }); +} + function policy(partial: Partial & Pick): StorageCleanupPolicy { return normalizeStorageCleanupPolicy({ schedule: "manual", @@ -174,23 +198,32 @@ describe("selection helpers", () => { }); test("selectPolicyPreview reduceToBytes keeps a single candidate when percent would over-select", () => { - const sizes = Array.from({ length: 100 }, (_, i) => ({ - name: `rollout-${String(i).padStart(3, "0")}.jsonl`, - bytes: 10, - when: new Date(OLD.getTime() + i * 60_000), - })); - const dir = seedHome(sizes); + // Percent mapping over-selects when n>100 and only one file is needed by bytes: + // pct=1 → floor(n/100) files. Prove that without seeding hundreds of files on disk — + // Windows CI FS makes a 100-file fixture exceed bun's default 5s harness timeout. + const synthetic = syntheticCandidates(200, 10); + const desired = selectReduceToBytes(synthetic, 1990); // total 2000 → free 10 → one file + expect(desired).toHaveLength(1); + const pct = percentForAtLeastCount(synthetic.length, desired.length); + expect(selectOldestPercent(synthetic, pct).length).toBeGreaterThan(1); + + // FS integration: exact preview path (percent:0 + candidateRelPaths), small fixture. + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 10, when: OLD }, + { name: "rollout-new.jsonl", bytes: 990, when: NEW }, + ]); const preview = selectPolicyPreview( policy({ enabled: true, trigger: { archivedBytesOver: 0 }, - target: { reduceToBytes: 990 }, // total 1000 → free 10 → one file + target: { reduceToBytes: 990 }, // total 1000 → free 10 → only the 10-byte oldest }), dir, ); + expect(preview.percent).toBe(0); expect(preview.count).toBe(1); expect(preview.bytes).toBe(10); - expect(preview.candidateRelPaths).toHaveLength(1); + expect(preview.candidateRelPaths).toEqual(["archived_sessions/rollout-old.jsonl"]); }); }); From 521232dfc49e5aff72e5528ad139db5509b11771 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:18:49 +0200 Subject: [PATCH 006/129] fix(storage): merge run metadata into latest cleanup policy on completion Reload persisted policy before saving lastRun/nextRun so concurrent PUTs during a long worker run are not silently reverted. --- src/storage/policy-job.ts | 19 +++++-- src/storage/policy-worker.ts | 8 +-- src/storage/policy.ts | 70 +++++++++++++++++++------- tests/api-storage-policy.test.ts | 85 ++++++++++++++++++++++++++++++++ tests/storage-policy.test.ts | 52 +++++++++++++++++++ 5 files changed, 207 insertions(+), 27 deletions(-) diff --git a/src/storage/policy-job.ts b/src/storage/policy-job.ts index 247810eb3..4ef0c4334 100644 --- a/src/storage/policy-job.ts +++ b/src/storage/policy-job.ts @@ -44,7 +44,10 @@ export interface RequestPolicyRunOptions { } export interface PolicyJobTestHooks { - /** Block the worker thread this many ms before policy evaluation. */ + /** + * Block the worker (or in-process run) this many ms after the start-of-job + * policy load, so concurrent PUTs can race completion metadata writes. + */ blockMs?: number; /** * When true, run on the main thread via `queueMicrotask` + optional sleep. @@ -142,7 +145,15 @@ function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome { } function applyFinished(result: PolicyRunResult): void { - livePolicyApply?.(result.policy); + // Prefer the latest persisted policy over `result.policy`. The worker (or + // in-process run) already merged run metadata into disk; a concurrent PUT + // may also have landed after that write. Re-reading avoids applying a stale + // start-of-job snapshot when the run skipped without saving. + try { + livePolicyApply?.(readStorageCleanupPolicyFromConfig()); + } catch { + livePolicyApply?.(result.policy); + } state = { status: "idle", reason: state.reason, @@ -228,9 +239,6 @@ async function executeJob(opts: RequestPolicyRunOptions): Promise { let result: PolicyRunResult; if (testHooks?.runInProcess) { - if (typeof blockMs === "number" && blockMs > 0) { - await Bun.sleep(blockMs); - } // Dynamic import keeps the sync engine out of the hot path for the default Worker mode. const { runStorageCleanupPolicy } = await import("./policy"); result = runStorageCleanupPolicy({ @@ -238,6 +246,7 @@ async function executeJob(opts: RequestPolicyRunOptions): Promise { force: opts.force === true, ...(opts.codexHome ? { codexHome: opts.codexHome } : {}), ...(opts.busyTimeoutMs !== undefined ? { busyTimeoutMs: opts.busyTimeoutMs } : {}), + ...(typeof blockMs === "number" && blockMs > 0 ? { holdAfterLoadMs: blockMs } : {}), }); } else { result = await runInWorker({ diff --git a/src/storage/policy-worker.ts b/src/storage/policy-worker.ts index 1201c0d5b..601cbb6e0 100644 --- a/src/storage/policy-worker.ts +++ b/src/storage/policy-worker.ts @@ -11,7 +11,7 @@ interface RunMessage { force?: boolean; codexHome?: string; busyTimeoutMs?: number; - /** Test-only: block this worker thread before evaluating the policy. */ + /** Test-only: block after loading the start-of-job policy (see holdAfterLoadMs). */ blockMs?: number; /** Env snapshot — Workers may not see parent mutations on all platforms. */ env?: { CODEX_HOME?: string; OPENCODEX_HOME?: string }; @@ -33,14 +33,14 @@ self.onmessage = (event: MessageEvent) => { try { if (env?.CODEX_HOME) process.env.CODEX_HOME = env.CODEX_HOME; if (env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = env.OPENCODEX_HOME; - if (typeof blockMs === "number" && Number.isFinite(blockMs) && blockMs > 0) { - Bun.sleepSync(Math.floor(blockMs)); - } const result = runStorageCleanupPolicy({ reason, force: force === true, ...(codexHome ? { codexHome } : {}), ...(busyTimeoutMs !== undefined ? { busyTimeoutMs } : {}), + ...(typeof blockMs === "number" && Number.isFinite(blockMs) && blockMs > 0 + ? { holdAfterLoadMs: Math.floor(blockMs) } + : {}), }); self.postMessage({ type: "done", requestId, result }); } catch (err) { diff --git a/src/storage/policy.ts b/src/storage/policy.ts index 48d7d468c..51813fa8c 100644 --- a/src/storage/policy.ts +++ b/src/storage/policy.ts @@ -65,6 +65,11 @@ export interface PolicyRunDeps { savePolicy?: (policy: StorageCleanupPolicy) => void; execute?: (options: ExecuteCleanupOptions) => CleanupResult; busyTimeoutMs?: number; + /** + * Test-only: block after the start-of-job policy load so a concurrent PUT can + * land before completion merges run metadata. + */ + holdAfterLoadMs?: number; } /** Canonical defaults — enabled is always false. */ @@ -381,6 +386,35 @@ function deferBusy(policy: StorageCleanupPolicy, now: number): StorageCleanupPol return { ...policy, nextRun: now + BUSY_DEFER_MS }; } +export type PolicyRunMetadataPatch = { + now: number; + /** Advance nextRun from the *latest* schedule, or defer on codex_busy. */ + nextRun: "advance" | "defer_busy"; + lastRun?: StorageCleanupPolicy["lastRun"]; +}; + +/** + * Reload the latest persisted policy and write only run-owned metadata + * (`lastRun` / `nextRun`). Preserves concurrent edits to enabled, trigger, + * target, schedule, and mode that landed while a long run was in flight. + */ +export function commitPolicyRunMetadata( + load: () => StorageCleanupPolicy, + save: (policy: StorageCleanupPolicy) => void, + patch: PolicyRunMetadataPatch, +): StorageCleanupPolicy { + const latest = normalizeStorageCleanupPolicy(load()); + let next = + patch.nextRun === "defer_busy" + ? deferBusy(latest, patch.now) + : advanceNextRun(latest, patch.now); + if (patch.lastRun) { + next = { ...next, lastRun: patch.lastRun }; + } + save(next); + return next; +} + function logPolicyEvent(message: string): void { console.log(`[storage-policy] ${message}`); } @@ -395,7 +429,11 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { const save = deps.savePolicy ?? writeStorageCleanupPolicyToConfig; const execute = deps.execute ?? executeArchivedCleanup; - let policy = normalizeStorageCleanupPolicy(load()); + const policy = normalizeStorageCleanupPolicy(load()); + + if (typeof deps.holdAfterLoadMs === "number" && Number.isFinite(deps.holdAfterLoadMs) && deps.holdAfterLoadMs > 0) { + Bun.sleepSync(Math.floor(deps.holdAfterLoadMs)); + } if (!policy.enabled) { return { ok: true, skipped: "disabled", policy }; @@ -407,17 +445,15 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { const selection = selectPolicyPreview(policy, deps.codexHome); if (selection.archivedBytes <= policy.trigger.archivedBytesOver) { - policy = advanceNextRun(policy, now); - save(policy); + const saved = commitPolicyRunMetadata(load, save, { now, nextRun: "advance" }); logPolicyEvent("skip under_threshold"); - return { ok: true, skipped: "under_threshold", policy }; + return { ok: true, skipped: "under_threshold", policy: saved }; } if (selection.count === 0) { - policy = advanceNextRun(policy, now); - save(policy); + const saved = commitPolicyRunMetadata(load, save, { now, nextRun: "advance" }); logPolicyEvent("skip nothing_selected"); - return { ok: true, skipped: "nothing_selected", policy }; + return { ok: true, skipped: "nothing_selected", policy: saved }; } const result = execute({ @@ -431,35 +467,33 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { }); if (!result.ok && result.error === "codex_busy") { - policy = deferBusy(policy, now); - save(policy); + const saved = commitPolicyRunMetadata(load, save, { now, nextRun: "defer_busy" }); logPolicyEvent("defer codex_busy"); - return { ok: false, deferred: "codex_busy", error: "codex_busy", policy }; + return { ok: false, deferred: "codex_busy", error: "codex_busy", policy: saved }; } if (!result.ok) { // Non-busy failure: still advance schedule so we do not tight-loop. - policy = advanceNextRun(policy, now); - save(policy); + const saved = commitPolicyRunMetadata(load, save, { now, nextRun: "advance" }); logPolicyEvent(`fail ${result.error ?? "cleanup_failed"}`); return { ok: false, error: result.error, mode: result.mode, ...(result.trashDir ? { trashDir: result.trashDir } : {}), - policy, + policy: saved, }; } - policy = { - ...advanceNextRun(policy, now), + const saved = commitPolicyRunMetadata(load, save, { + now, + nextRun: "advance", lastRun: { at: now, freedBytes: result.bytes, removed: result.count, }, - }; - save(policy); + }); logPolicyEvent( `ok mode=${result.mode} removed=${result.count} freedBytes=${result.bytes}`, ); @@ -469,7 +503,7 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult { freedBytes: result.bytes, removed: result.count, ...(result.trashDir ? { trashDir: result.trashDir } : {}), - policy, + policy: saved, }; } diff --git a/tests/api-storage-policy.test.ts b/tests/api-storage-policy.test.ts index 418036202..8a390b630 100644 --- a/tests/api-storage-policy.test.ts +++ b/tests/api-storage-policy.test.ts @@ -281,4 +281,89 @@ describe("storage cleanup policy API", () => { resetStorageCleanupPolicyJobForTests(); } }); + + test("blocked worker completion preserves concurrent policy PUT edits", async () => { + setStorageCleanupPolicyJobTestHooks({ blockMs: 800 }); + seedArchived(isolatedCodexHome!.path); + const server = startServer(0); + try { + await fetch(new URL("/api/storage/cleanup-policy", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled: true, + trigger: { archivedBytesOver: 50 }, + target: { removeOldestPercent: 50 }, + schedule: "manual", + mode: "quarantine", + }), + }); + + const run = await fetch(new URL("/api/storage/cleanup-policy/run", server.url), { + method: "POST", + }); + expect(run.status).toBe(200); + const runStart = await run.json() as { started?: boolean; job?: { status: string; startedAt: number } }; + expect(runStart.started).toBe(true); + expect(runStart.job?.status).toBe("running"); + + // Wait until the job is visibly running, then edit policy while the worker holds. + const editDeadline = Date.now() + 2_000; + while (Date.now() < editDeadline) { + const peek = await fetch(new URL("/api/storage/cleanup-policy", server.url)); + const peekBody = await peek.json() as { job?: { status?: string } }; + if (peekBody.job?.status === "running") break; + await Bun.sleep(20); + } + + // Let the worker load the start-of-job snapshot, then edit during the hold window. + await Bun.sleep(120); + + const put = await fetch(new URL("/api/storage/cleanup-policy", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled: false, + trigger: { archivedBytesOver: 1234 }, + target: { reduceToBytes: 42 }, + schedule: "daily", + mode: "permanent", + }), + }); + expect(put.status).toBe(200); + const putBody = await put.json() as { ok?: boolean; policy?: { enabled?: boolean } }; + expect(putBody.ok).toBe(true); + expect(putBody.policy?.enabled).toBe(false); + + const done = await waitForJobIdle(server.url, runStart.job!.startedAt); + expect(done.job.lastOutcome?.ok).toBe(true); + expect(done.job.lastOutcome?.removed).toBe(1); + expect(done.enabled).toBe(false); + expect(done.lastRun?.removed).toBe(1); + + const final = await fetch(new URL("/api/storage/cleanup-policy", server.url)); + const body = await final.json() as { + enabled: boolean; + trigger: { archivedBytesOver: number }; + target: { reduceToBytes?: number; removeOldestPercent?: number }; + schedule: string; + mode: string; + lastRun?: { removed: number; freedBytes: number; at: number }; + nextRun?: number; + }; + expect(body.enabled).toBe(false); + expect(body.trigger.archivedBytesOver).toBe(1234); + expect(body.target).toEqual({ reduceToBytes: 42 }); + expect(body.schedule).toBe("daily"); + expect(body.mode).toBe("permanent"); + expect(body.lastRun?.removed).toBe(1); + expect(body.lastRun?.freedBytes).toBe(100); + expect(typeof body.lastRun?.at).toBe("number"); + expect(typeof body.nextRun).toBe("number"); + } finally { + await server.stop(true); + stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); + } + }); }); diff --git a/tests/storage-policy.test.ts b/tests/storage-policy.test.ts index ef2aee78a..e0d36222a 100644 --- a/tests/storage-policy.test.ts +++ b/tests/storage-policy.test.ts @@ -540,4 +540,56 @@ describe("runStorageCleanupPolicy", () => { ]); expect(result.removed).toBe(2); }); + + test("completion merges run metadata into concurrent policy edits", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + { name: "rollout-new.jsonl", bytes: 100, when: NEW }, + ]); + const stored: StorageCleanupPolicy[] = [ + policy({ + enabled: true, + trigger: { archivedBytesOver: 50 }, + target: { removeOldestPercent: 50 }, + schedule: "manual", + mode: "quarantine", + }), + ]; + const now = 77_000; + const result = runStorageCleanupPolicy({ + reason: "manual", + force: true, + now, + codexHome: dir, + loadPolicy: () => stored[0]!, + savePolicy: p => { stored[0] = p; }, + execute: (): CleanupResult => { + // Simulate a concurrent PUT while the job holds the start-of-run snapshot. + stored[0] = policy({ + enabled: false, + trigger: { archivedBytesOver: 999 }, + target: { reduceToBytes: 42 }, + schedule: "daily", + mode: "permanent", + }); + return { + ok: true, + mode: "quarantine", + percent: 50, + count: 1, + bytes: 100, + removedPaths: ["archived_sessions/rollout-old.jsonl"], + }; + }, + }); + expect(result.ok).toBe(true); + expect(stored[0]!.enabled).toBe(false); + expect(stored[0]!.trigger).toEqual({ archivedBytesOver: 999 }); + expect(stored[0]!.target).toEqual({ reduceToBytes: 42 }); + expect(stored[0]!.schedule).toBe("daily"); + expect(stored[0]!.mode).toBe("permanent"); + expect(stored[0]!.lastRun).toEqual({ at: now, freedBytes: 100, removed: 1 }); + expect(stored[0]!.nextRun).toBe(computeNextRun("daily", now)); + expect(result.policy).toEqual(stored[0]); + }); }); From eeeabc5c059f647d93c1eff81edbd5bf015e8578 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:25:39 +0200 Subject: [PATCH 007/129] fix(storage): settle aborted policy workers and harden Run-now UX Abort/reset now cancel the active worker promise and generation-guard late completions; GUI distinguishes already_running, aborts poll on unmount, and test-stream returns 404 when disabled. --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 3 +- gui/src/pages/Storage.tsx | 41 ++++++++++++++++++--- src/server/management/logs-usage-routes.ts | 2 + src/storage/policy-job.ts | 24 +++++++++++- tests/api-storage-policy.test.ts | 6 +-- tests/storage-policy-job-responsive.test.ts | 7 ++-- 11 files changed, 75 insertions(+), 13 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 36c805a7b..435768a4c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -925,6 +925,7 @@ export const de: Record = { "storage.policy.loadFailed": "Bereinigungsrichtlinie konnte nicht geladen werden.", "storage.policy.saveFailed": "Bereinigungsrichtlinie konnte nicht gespeichert werden.", "storage.policy.runFailed": "Richtlinienlauf fehlgeschlagen.", + "storage.policy.alreadyRunning": "Ein Bereinigungsrichtlinienlauf läuft bereits.", "storage.policy.invalid": "Ungültige Richtlinienwerte.", "storage.policy.enabled": "Automatische Bereinigung aktivieren", "storage.policy.enabledHint": "Standard ist aus. Bei Aktivierung nur nach gewähltem Zeitplan (oder Jetzt ausführen).", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0ff3167d7..345ba36ef 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -677,6 +677,7 @@ export const en = { "storage.policy.loadFailed": "Could not load cleanup policy.", "storage.policy.saveFailed": "Could not save cleanup policy.", "storage.policy.runFailed": "Policy run failed.", + "storage.policy.alreadyRunning": "A cleanup policy run is already in progress.", "storage.policy.invalid": "Invalid policy values.", "storage.policy.enabled": "Enable auto-cleanup", "storage.policy.enabledHint": "Default is off. Enabling runs only on the schedule you choose (or Run now).", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3307e1097..efc81243c 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -644,6 +644,7 @@ export const ja: Record = { "storage.policy.loadFailed": "クリーンアップ方針を読み込めませんでした。", "storage.policy.saveFailed": "クリーンアップ方針を保存できませんでした。", "storage.policy.runFailed": "方針の実行に失敗しました。", + "storage.policy.alreadyRunning": "クリーンアップ方針の実行が既に進行中です。", "storage.policy.invalid": "方針の値が無効です。", "storage.policy.enabled": "自動クリーンアップを有効化", "storage.policy.enabledHint": "既定はオフです。有効にすると選択したスケジュール(または今すぐ実行)でのみ動きます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 92e7bd2a2..d44d0ab0a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -945,6 +945,7 @@ export const ko: Record = { "storage.policy.loadFailed": "정리 정책을 불러오지 못했습니다.", "storage.policy.saveFailed": "정리 정책을 저장하지 못했습니다.", "storage.policy.runFailed": "정책 실행에 실패했습니다.", + "storage.policy.alreadyRunning": "정리 정책이 이미 실행 중입니다.", "storage.policy.invalid": "정책 값이 올바르지 않습니다.", "storage.policy.enabled": "자동 정리 사용", "storage.policy.enabledHint": "기본은 꺼짐입니다. 켜면 선택한 일정(또는 지금 실행)에만 동작합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d8a517935..978ee28f5 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -676,6 +676,7 @@ export const ru: Record = { "storage.policy.loadFailed": "Не удалось загрузить политику очистки.", "storage.policy.saveFailed": "Не удалось сохранить политику очистки.", "storage.policy.runFailed": "Не удалось выполнить политику.", + "storage.policy.alreadyRunning": "Выполнение политики очистки уже выполняется.", "storage.policy.invalid": "Недопустимые значения политики.", "storage.policy.enabled": "Включить автоочистку", "storage.policy.enabledHint": "По умолчанию выкл. При включении работает только по выбранному расписанию (или «Запустить сейчас»).", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c35310301..b6d1b37aa 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -940,11 +940,12 @@ export const zh: Record = { "storage.cleanup.err.cleanup_failed": "清理失败。", "storage.policy.title": "自动清理策略", - "storage.policy.help": "当归档会话超过阈值时可选批量清理。默认关闭——不会自动启用。", + "storage.policy.help": "当归档大小超过阈值时可选批量清理。默认关闭——不会自动启用。", "storage.policy.loading": "正在加载策略…", "storage.policy.loadFailed": "无法加载清理策略。", "storage.policy.saveFailed": "无法保存清理策略。", "storage.policy.runFailed": "策略运行失败。", + "storage.policy.alreadyRunning": "清理策略已在运行中。", "storage.policy.invalid": "策略值无效。", "storage.policy.enabled": "启用自动清理", "storage.policy.enabledHint": "默认关闭。启用后仅按所选计划(或立即运行)执行。", diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 104b89eb8..57e63a35d 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -438,6 +438,8 @@ function AutoCleanupPolicyPanel({ const [reduceGb, setReduceGb] = useState("4"); /** Draft string so a cleared threshold is rejected instead of coerced to 0. */ const [thresholdGb, setThresholdGb] = useState("5"); + /** Cancels in-flight Run-now polling when the panel unmounts. */ + const runAbortRef = useRef(null); const loadPolicy = useCallback(async (signal?: AbortSignal) => { setLoading(true); @@ -477,6 +479,13 @@ function AutoCleanupPolicyPanel({ }; }, [loadPolicy]); + useEffect(() => { + return () => { + runAbortRef.current?.abort(); + runAbortRef.current = null; + }; + }, []); + const buildBody = (): CleanupPolicy | null => { if (!policy) return null; const thresholdRaw = thresholdGb.trim(); @@ -537,6 +546,11 @@ function AutoCleanupPolicyPanel({ }; const runNow = async () => { + runAbortRef.current?.abort(); + const controller = new AbortController(); + runAbortRef.current = controller; + const { signal } = controller; + setRunning(true); setError(null); setStatus(null); @@ -551,15 +565,20 @@ function AutoCleanupPolicyPanel({ method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(base), + signal, }); const saved = await saveRes.json() as { policy?: CleanupPolicy; error?: string }; + if (signal.aborted) return; if (!saveRes.ok || !saved.policy) { setError(t("storage.policy.saveFailed")); return; } setPolicy(policyFieldsFromResponse(saved.policy)); - const res = await fetch(`${apiBase}/api/storage/cleanup-policy/run`, { method: "POST" }); + const res = await fetch(`${apiBase}/api/storage/cleanup-policy/run`, { + method: "POST", + signal, + }); const startJson = await res.json() as { ok?: boolean; started?: boolean; @@ -567,8 +586,13 @@ function AutoCleanupPolicyPanel({ job?: CleanupPolicy["job"]; policy?: CleanupPolicy; }; + if (signal.aborted) return; if (startJson.policy) setPolicy(policyFieldsFromResponse(startJson.policy)); - if (!res.ok || startJson.error === "already_running") { + if (startJson.error === "already_running" || res.status === 409) { + setError(t("storage.policy.alreadyRunning")); + return; + } + if (!res.ok) { setError(t("storage.policy.runFailed")); return; } @@ -583,10 +607,14 @@ function AutoCleanupPolicyPanel({ let finalPolicy: CleanupPolicy | undefined; while (Date.now() < deadline) { + if (signal.aborted) return; await sleep(250); - const pollRes = await fetch(`${apiBase}/api/storage/cleanup-policy`); + if (signal.aborted) return; + const pollRes = await fetch(`${apiBase}/api/storage/cleanup-policy`, { signal }); + if (signal.aborted) return; if (!pollRes.ok) continue; const body = await pollRes.json() as CleanupPolicy; + if (signal.aborted) return; finalPolicy = policyFieldsFromResponse(body); setPolicy(finalPolicy); const job = body.job; @@ -603,6 +631,7 @@ function AutoCleanupPolicyPanel({ } } + if (signal.aborted) return; if (finalPolicy) setPolicy(finalPolicy); if (!outcome) { setError(t("storage.policy.runFailed")); @@ -633,10 +662,12 @@ function AutoCleanupPolicyPanel({ ); onDone(); } - } catch { + } catch (err) { + if (signal.aborted || (err instanceof DOMException && err.name === "AbortError")) return; setError(t("storage.policy.runFailed")); } finally { - setRunning(false); + if (runAbortRef.current === controller) runAbortRef.current = null; + if (!signal.aborted) setRunning(false); } }; diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 6d1f83d4c..2d7d8ec68 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -339,6 +339,8 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise void) | undefined; +/** Settles the active `runInWorker` promise (clears watchdog) before hard terminate. */ +let cancelActiveRun: (() => void) | null = null; +/** Bumped on abort/reset so a late worker completion cannot clobber newer job state. */ +let runGeneration = 0; export function setStorageCleanupPolicyJobLiveApply( apply: ((policy: PolicyRunResult["policy"]) => void) | null, @@ -100,7 +104,15 @@ export function setStorageCleanupPolicyJobTestHooks(hooks: PolicyJobTestHooks | testHooks = hooks; } +function disownActiveRun(): void { + runGeneration += 1; + const cancel = cancelActiveRun; + cancelActiveRun = null; + cancel?.(); +} + export function resetStorageCleanupPolicyJobForTests(): void { + disownActiveRun(); if (activeWorker) { try { activeWorker.terminate(); } catch { /* */ } activeWorker = null; @@ -112,6 +124,7 @@ export function resetStorageCleanupPolicyJobForTests(): void { /** Terminate an in-flight worker during process shutdown. */ export function abortStorageCleanupPolicyJob(): void { + disownActiveRun(); if (activeWorker) { try { activeWorker.terminate(); } catch { /* */ } activeWorker = null; @@ -184,6 +197,7 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom const timer = setTimeout(() => { if (settled) return; settled = true; + cancelActiveRun = null; try { worker.terminate(); } catch { /* */ } if (activeWorker === worker) activeWorker = null; reject(new Error("storage_cleanup_worker_timeout")); @@ -192,12 +206,17 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom const finish = (fn: () => void) => { if (settled) return; settled = true; + cancelActiveRun = null; clearTimeout(timer); if (activeWorker === worker) activeWorker = null; try { worker.terminate(); } catch { /* */ } fn(); }; + cancelActiveRun = () => { + finish(() => reject(new Error("aborted"))); + }; + worker.onmessage = (event: MessageEvent) => { const data = event.data; if (!data || typeof data !== "object") return; @@ -234,6 +253,7 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom } async function executeJob(opts: RequestPolicyRunOptions): Promise { + const generation = ++runGeneration; try { const blockMs = testHooks?.blockMs; let result: PolicyRunResult; @@ -255,11 +275,13 @@ async function executeJob(opts: RequestPolicyRunOptions): Promise { }); } + if (generation !== runGeneration) return; applyFinished(result); } catch (err) { + if (generation !== runGeneration) return; applyFailed(err instanceof Error ? err.message : "worker_failed"); } finally { - inflight = null; + if (generation === runGeneration) inflight = null; } } diff --git a/tests/api-storage-policy.test.ts b/tests/api-storage-policy.test.ts index 8a390b630..499b078ff 100644 --- a/tests/api-storage-policy.test.ts +++ b/tests/api-storage-policy.test.ts @@ -240,7 +240,7 @@ describe("storage cleanup policy API", () => { stopStorageCleanupScheduler(); resetStorageCleanupPolicyJobForTests(); } - }); + }, { timeout: 30_000 }); test("POST run rejects when a job is already running", async () => { setStorageCleanupPolicyJobTestHooks({ blockMs: 800 }); @@ -280,7 +280,7 @@ describe("storage cleanup policy API", () => { stopStorageCleanupScheduler(); resetStorageCleanupPolicyJobForTests(); } - }); + }, { timeout: 30_000 }); test("blocked worker completion preserves concurrent policy PUT edits", async () => { setStorageCleanupPolicyJobTestHooks({ blockMs: 800 }); @@ -365,5 +365,5 @@ describe("storage cleanup policy API", () => { stopStorageCleanupScheduler(); resetStorageCleanupPolicyJobForTests(); } - }); + }, { timeout: 30_000 }); }); diff --git a/tests/storage-policy-job-responsive.test.ts b/tests/storage-policy-job-responsive.test.ts index 55cb38dfc..9128d55f2 100644 --- a/tests/storage-policy-job-responsive.test.ts +++ b/tests/storage-policy-job-responsive.test.ts @@ -123,10 +123,11 @@ describe("storage cleanup policy job responsiveness", () => { expect(streamText.split("\n").filter(Boolean).length).toBe(8); // Every health probe during the blocked worker window should stay snappy. + // Relative to blockMs: a main-thread block would push samples toward blockMs itself. + const maxHealthMs = Math.floor(blockMs / 3); // 400ms at blockMs=1200 for (const sample of healthSamples) { - expect(sample).toBeLessThan(400); + expect(sample).toBeLessThan(maxHealthMs); } - expect(Math.max(...healthSamples)).toBeLessThan(400); const deadline = Date.now() + 10_000; while (Date.now() < deadline) { @@ -140,5 +141,5 @@ describe("storage cleanup policy job responsiveness", () => { stopStorageCleanupScheduler(); resetStorageCleanupPolicyJobForTests(); } - }); + }, { timeout: 30_000 }); }); From 131573e36a78b9532785491e1b0bff20e9315e73 Mon Sep 17 00:00:00 2001 From: wonsh42 Date: Tue, 28 Jul 2026 03:59:10 +0900 Subject: [PATCH 008/129] fix(oauth): stop OAuth login from deleting a stored provider API key (#491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(oauth): stop OAuth login from deleting a stored provider API key `upsertOAuthProvider` replaced the provider entry with the bare preset on every OAuth login. For providers that can be billed either way (`allowKeyAuthOverride`: xai, github-copilot) this deleted `apiKey` and `apiKeyPool` from config.json and silently flipped an explicit `authMode: "key"` billing choice back to the subscription — the key was gone and had to be pasted again. Carry the key fields over and keep an explicitly chosen `authMode: "key"`, so logging in never changes which credential bills the request. OAuth-only providers and fresh logins still get the untouched preset. * fix(oauth): restore OAuth mode after final key removal * fix(oauth): promote safe pool key and keep key mode when authMode is omitted Address review follow-ups so OAuth login preserves key billing when a usable key remains, including pool promotion and shared API-key sanitization. * fix(oauth): sync active key into pool and gate key mode on resolved env Keep list/switch/remove aligned with routing after OAuth upsert, and only restore authMode key when resolveEnvValue yields a usable secret. * fix(oauth): push merged provider config to running proxy after CLI login Avoid POST /api/providers with the bare OAuth preset, which dropped preserved apiKey/authMode and wrote that stripped state back to disk. * fix(oauth): persist key intent without CLI env and route env keys at runtime Stop upsertOAuthProvider from gating authMode on resolveEnvValue in the login CLI; router falls back to OAuth when the proxy cannot resolve an env-backed active key without rewriting stored mode. --------- Co-authored-by: wonsh42 Co-authored-by: wonsh42 <300028278+wonsh42@users.noreply.github.com> Co-authored-by: Wibias <37517432+Wibias@users.noreply.github.com> --- src/oauth/index.ts | 78 ++++- src/oauth/login-cli.ts | 19 +- src/providers/api-keys.ts | 19 +- src/router.ts | 13 +- tests/oauth-login-cli-live-update.test.ts | 96 ++++++ tests/oauth-upsert-preserves-api-key.test.ts | 331 +++++++++++++++++++ tests/router.test.ts | 27 ++ 7 files changed, 568 insertions(+), 15 deletions(-) create mode 100644 tests/oauth-login-cli-live-update.test.ts create mode 100644 tests/oauth-upsert-preserves-api-key.test.ts diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 4d15ef4b7..a610a8cbd 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -13,7 +13,8 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity" import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; -import { effectiveGoogleMode } from "../providers/registry"; +import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; +import { effectiveGoogleMode, getProviderRegistryEntry } from "../providers/registry"; import { resolveProviderTransport } from "../providers/xai-transport"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; import { logOAuthEvent } from "./log"; @@ -566,12 +567,83 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean { return changed; } -/** Add/refresh an OAuth provider's config entry on a config object (does not persist). */ +/** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */ +function preservableApiKeyPool(value: unknown): NonNullable | undefined { + if (!Array.isArray(value)) return undefined; + const pool: NonNullable = []; + const ids = new Set(); + const keys = new Set(); + for (const entry of value as unknown[]) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const candidate = entry as Record; + const id = typeof candidate.id === "string" ? candidate.id.trim() : ""; + const key = sanitizeApiKeyValue(candidate.key); + if (!id || !key || ids.has(id) || keys.has(key)) continue; + const label = typeof candidate.label === "string" ? candidate.label : undefined; + const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt) + ? candidate.addedAt + : undefined; + ids.add(id); + keys.add(key); + pool.push({ + id, + key, + ...(label !== undefined ? { label } : {}), + ...(addedAt !== undefined ? { addedAt } : {}), + }); + } + // `apiKey` remains the routing source of truth. Keep valid alternate slots even when a + // hand-edited config left the pool out of sync, rather than deleting usable credentials. + return pool.length > 0 ? pool : undefined; +} + +/** + * Add/refresh an OAuth provider's config entry on a config object (does not persist). + * + * Providers whose registry entry sets `allowKeyAuthOverride` (xai, github-copilot) can be + * billed through a stored API key instead of the OAuth login (router.ts honors + * `authMode: "key"` for them). A blind preset overwrite here deletes `apiKey`/`apiKeyPool` + * on every OAuth login, silently destroying the stored key and forcing a re-paste — and it + * flips billing back to the subscription without the user asking. Carry the key fields over + * and keep key billing while usable key material remains and the user was not explicitly on + * oauth. If the final key was removed and only the old key mode remains, let the OAuth + * preset restore `authMode: "oauth"` so the newly saved OAuth credential can be used. + * + * After preservation, `apiKey` always has exactly one matching pool entry (inserting via the + * same content-derived id as the API-key manager when the active key was missing from the + * pool). Key mode reflects stored user intent (explicit `"key"` or omitted mode with safe + * key material) — never whether the login CLI process can resolve an env reference. Env-backed + * availability is decided at proxy routing time in `router.ts`. + */ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (provider === "chatgpt") return; const def = OAUTH_PROVIDERS[provider]; if (!def) return; - config.providers[provider] = { ...def.providerConfig }; + const existing = config.providers[provider]; + const next: OcxProviderConfig = { ...def.providerConfig }; + if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { + // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes. + let storedApiKey = sanitizeApiKeyValue(existing.apiKey); + const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool); + // Unsafe/blank active key with a usable pool: promote the first safe pool entry so + // key billing keeps working instead of falling back to oauth while pool keys remain. + if (storedApiKey === undefined && storedApiKeyPool && storedApiKeyPool.length > 0) { + storedApiKey = storedApiKeyPool[0]!.key; + } + if (storedApiKey !== undefined) { + const pool = storedApiKeyPool ? [...storedApiKeyPool] : []; + // Keep routing and listProviderApiKeys in sync: never leave a hidden active key that + // is absent from the pool (listing would fall back to pool[0] as "active"). + if (!pool.some(entry => entry.key === storedApiKey)) { + pool.push({ id: apiKeyPoolEntryId(storedApiKey), key: storedApiKey }); + } + next.apiKey = storedApiKey; + next.apiKeyPool = pool; + const previousModeAllowsKey = existing.authMode === "key" || existing.authMode === undefined; + if (previousModeAllowsKey) next.authMode = "key"; + } + } + config.providers[provider] = next; } /** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */ diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 86a37e0fa..1c7dfae2e 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -2,7 +2,7 @@ import * as readline from "node:readline"; import { openUrl } from "../lib/open-url"; import { loadConfig, saveConfig } from "../config"; import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; -import { isPublicOAuthProvider, listOAuthProviders, OAUTH_PROVIDERS, runLogin } from "./index"; +import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index"; import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers"; import type { OcxProviderConfig } from "../types"; @@ -14,7 +14,7 @@ export function runningProxyUpdateHeaders(): Headers { } /** Push the new provider into a running proxy's live config so it routes without a restart. */ -async function notifyRunningProxy(name: string, provider: unknown): Promise { +export async function notifyRunningProxy(name: string, provider: unknown): Promise { // Identity-checked runtime-port lookup: reaches a fallback-port proxy and avoids // posting credentials-adjacent config to whatever else answers on config.port. const live = await findLiveProxy(); @@ -30,6 +30,19 @@ async function notifyRunningProxy(name: string, provider: unknown): Promise { + const provider = loadConfig().providers[name]; + if (!provider) return; + await notifyRunningProxy(name, provider); +} + export async function handleLogin(provider?: string): Promise { const name = (provider ?? "").trim().toLowerCase(); if (isPublicOAuthProvider(name)) return handleOAuthLogin(name); @@ -58,7 +71,7 @@ async function handleOAuthLogin(name: string): Promise { } finally { rl.close(); } - await notifyRunningProxy(name, OAUTH_PROVIDERS[name].providerConfig); + await notifyRunningProxyAfterOAuthLogin(name); console.log(`\n✅ Logged in to ${name}. Try: ocx sync`); } diff --git a/src/providers/api-keys.ts b/src/providers/api-keys.ts index 84e5f371a..88a0d7223 100644 --- a/src/providers/api-keys.ts +++ b/src/providers/api-keys.ts @@ -30,7 +30,7 @@ export function maskApiKey(value: string): string { } /** Content-derived id: re-adding the same key upserts instead of duplicating. */ -function keyId(key: string): string { +export function apiKeyPoolEntryId(key: string): string { return createHash("sha256").update(key).digest("hex").slice(0, 8); } @@ -39,11 +39,18 @@ export function isKeyAuthProvider(provider: OcxProviderConfig): boolean { return provider.authMode !== "oauth" && provider.authMode !== "forward"; } +/** Trim and reject blank / CRLF-bearing secrets. Shared by pool writes and OAuth upsert. */ +export function sanitizeApiKeyValue(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed && !/[\r\n]/.test(trimmed) ? trimmed : undefined; +} + /** Seed the pool from a legacy bare `apiKey`, and keep `apiKey` mirrored to the active entry. */ function ensurePool(provider: OcxProviderConfig): NonNullable { if (!provider.apiKeyPool) provider.apiKeyPool = []; if (provider.apiKeyPool.length === 0 && provider.apiKey) { - provider.apiKeyPool.push({ id: keyId(provider.apiKey), key: provider.apiKey }); + provider.apiKeyPool.push({ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }); } return provider.apiKeyPool; } @@ -75,11 +82,11 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId export function addProviderApiKey(config: OcxConfig, name: string, key: string, label?: string): { id: string } | { error: string } { const provider = config.providers[name]; if (!provider || !isKeyAuthProvider(provider)) return { error: "provider does not use API-key auth" }; - const trimmed = key.trim(); - if (!trimmed) return { error: "key is required" }; - if (/[\r\n]/.test(trimmed)) return { error: "key must not include line breaks" }; + if (typeof key !== "string" || !key.trim()) return { error: "key is required" }; + const trimmed = sanitizeApiKeyValue(key); + if (!trimmed) return { error: "key must not include line breaks" }; const pool = ensurePool(provider); - const id = keyId(trimmed); + const id = apiKeyPoolEntryId(trimmed); const existing = pool.find(e => e.id === id); if (existing) { if (label?.trim()) existing.label = label.trim(); diff --git a/src/router.ts b/src/router.ts index cc5774261..6dc331f4d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -185,15 +185,22 @@ function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effec ); } +function usableResolvedApiKey(apiKey: string | undefined): string | undefined { + const resolved = resolveEnvValue(apiKey); + return typeof resolved === "string" && resolved.trim().length > 0 ? resolved : undefined; +} + function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig { const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); if (!registryEntry) { assertProviderDestinationAllowed(providerName, provider); - return { ...provider, apiKey: resolveEnvValue(provider.apiKey) }; + return { ...provider, apiKey: usableResolvedApiKey(provider.apiKey) }; } + const resolvedApiKey = usableResolvedApiKey(provider.apiKey); const explicitKeyOverride = registryEntry.authKind === "oauth" && registryEntry.allowKeyAuthOverride === true - && provider.authMode === "key"; + && provider.authMode === "key" + && resolvedApiKey !== undefined; const canonicalAuthMode = explicitKeyOverride ? "key" : registryEntry.authKind === "forward" || registryEntry.authKind === "oauth" @@ -239,7 +246,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig) adapter: registryEntry.adapter, baseUrl, authMode: canonicalAuthMode, - apiKey: resolveEnvValue(provider.apiKey), + apiKey: resolvedApiKey, // Backfill the Google wire mode + Vertex project/location from the registry when the user // config omits them, so a minimal `google-vertex`/`google-antigravity` entry still routes // through the correct branch (CCA/Vertex) instead of falling back to AI Studio. diff --git a/tests/oauth-login-cli-live-update.test.ts b/tests/oauth-login-cli-live-update.test.ts new file mode 100644 index 000000000..fca3cdde7 --- /dev/null +++ b/tests/oauth-login-cli-live-update.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../src/config"; +import { upsertOAuthProvider } from "../src/oauth"; +import { notifyRunningProxyAfterOAuthLogin } from "../src/oauth/login-cli"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +/** + * Regression: CLI OAuth login used to POST the bare OAuth preset into a running proxy. + * That wiped the preserved apiKey / apiKeyPool / authMode:"key" that runLogin had just + * written, then saved the stripped entry back to disk. + */ +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function keyModeXaiConfig(port = 0): OcxConfig { + return { + port, + hostname: "127.0.0.1", + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "live-update-sentinel-key", + apiKeyPool: [{ id: "livekey01", key: "live-update-sentinel-key" }], + }, + }, + } as OcxConfig; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-oauth-live-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-oauth-live-")); + process.env.OPENCODEX_HOME = testDir; + saveConfig(keyModeXaiConfig()); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +describe("CLI OAuth live-update credential preservation", () => { + test("notify after OAuth login keeps key billing on live and disk configs", async () => { + const server = startServer(0); + try { + const port = server.port!; + const boot = loadConfig(); + boot.port = port; + saveConfig(boot); + + // Mirror runLogin()'s disk write: upsert preserves key material, then save. + const afterLogin = loadConfig(); + upsertOAuthProvider(afterLogin, "xai"); + saveConfig(afterLogin); + expect(afterLogin.providers.xai!.authMode).toBe("key"); + expect(afterLogin.providers.xai!.apiKey).toBe("live-update-sentinel-key"); + + await notifyRunningProxyAfterOAuthLogin("xai"); + + const listed = await fetch(new URL("/api/providers", server.url)).then(r => r.json()) as Array<{ + name: string; + authMode?: string; + hasApiKey?: boolean; + }>; + const live = listed.find(entry => entry.name === "xai"); + expect(live?.authMode).toBe("key"); + expect(live?.hasApiKey).toBe(true); + + const pool = await fetch(new URL("/api/providers/keys?name=xai", server.url)).then(r => r.json()) as { + activeId: string | null; + keys: Array<{ id: string; active: boolean }>; + }; + expect(pool.activeId).toBeTruthy(); + expect(pool.keys.some(entry => entry.active)).toBe(true); + + const disk = JSON.parse(readFileSync(join(testDir, "config.json"), "utf-8")) as OcxConfig; + expect(disk.providers.xai!.authMode).toBe("key"); + expect(disk.providers.xai!.apiKey).toBe("live-update-sentinel-key"); + expect(disk.providers.xai!.apiKeyPool?.some(entry => entry.key === "live-update-sentinel-key")).toBe(true); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/oauth-upsert-preserves-api-key.test.ts b/tests/oauth-upsert-preserves-api-key.test.ts new file mode 100644 index 000000000..6ef07220b --- /dev/null +++ b/tests/oauth-upsert-preserves-api-key.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { upsertOAuthProvider } from "../src/oauth"; +import { + apiKeyPoolEntryId, + listProviderApiKeys, + removeProviderApiKey, + setActiveProviderApiKey, +} from "../src/providers/api-keys"; +import { routeModel } from "../src/router"; +import type { OcxConfig } from "../src/types"; + +/** + * Regression: `upsertOAuthProvider` used to overwrite the provider entry with the bare preset + * on every OAuth login, deleting a stored `apiKey`/`apiKeyPool` and silently flipping an + * explicit `authMode: "key"` billing choice back to the subscription. Providers whose registry + * entry sets `allowKeyAuthOverride` (xai, github-copilot) are the ones that can hold both. + */ +function configWithKey(provider: string, adapter: string, baseUrl: string): OcxConfig { + return { + port: 10100, + defaultProvider: provider, + providers: { + [provider]: { + adapter, + baseUrl, + authMode: "key", + apiKey: "stored-key-sentinel", + apiKeyPool: [{ id: "aaaaaaaa", key: "stored-key-sentinel" }], + }, + }, + } as unknown as OcxConfig; +} + +describe("upsertOAuthProvider credential preservation", () => { + test("keeps a stored API key and the explicit key billing mode for xai", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + upsertOAuthProvider(config, "xai"); + const provider = config.providers.xai!; + expect(provider.apiKey).toBe("stored-key-sentinel"); + expect(provider.apiKeyPool).toEqual([{ id: "aaaaaaaa", key: "stored-key-sentinel" }]); + expect(provider.authMode).toBe("key"); + }); + + test("keeps a stored API key for github-copilot", () => { + const config = configWithKey("github-copilot", "openai-chat", "https://api.githubcopilot.com"); + upsertOAuthProvider(config, "github-copilot"); + const provider = config.providers["github-copilot"]!; + expect(provider.apiKey).toBe("stored-key-sentinel"); + expect(provider.authMode).toBe("key"); + }); + + test("carries the key over without changing oauth billing when the user did not pick key mode", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + config.providers.xai!.authMode = "oauth"; + upsertOAuthProvider(config, "xai"); + const provider = config.providers.xai!; + expect(provider.apiKey).toBe("stored-key-sentinel"); + expect(provider.authMode).toBe("oauth"); + }); + + test("sets key mode when authMode was omitted but a stored key remains", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + delete config.providers.xai!.authMode; + upsertOAuthProvider(config, "xai"); + const provider = config.providers.xai!; + expect(provider.apiKey).toBe("stored-key-sentinel"); + expect(provider.authMode).toBe("key"); + expect(routeModel(config, "xai/grok-4.5").provider.authMode).toBe("key"); + }); + + test("persists key mode for env-backed keys without consulting the login CLI environment", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + config.providers.xai!.apiKey = "${OCX_TEST_XAI_API_KEY}"; + config.providers.xai!.apiKeyPool = [{ id: "env-key", key: "${OCX_TEST_XAI_API_KEY}" }]; + const previous = process.env.OCX_TEST_XAI_API_KEY; + delete process.env.OCX_TEST_XAI_API_KEY; + try { + upsertOAuthProvider(config, "xai"); + const provider = config.providers.xai!; + expect(provider.apiKey).toBe("${OCX_TEST_XAI_API_KEY}"); + expect(provider.apiKeyPool).toEqual([{ id: "env-key", key: "${OCX_TEST_XAI_API_KEY}" }]); + expect(provider.authMode).toBe("key"); + } finally { + if (previous === undefined) delete process.env.OCX_TEST_XAI_API_KEY; + else process.env.OCX_TEST_XAI_API_KEY = previous; + } + }); + + test("falls back to OAuth at routing time when the proxy cannot resolve the active key", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + config.providers.xai!.apiKey = "${OCX_TEST_XAI_API_KEY_MISSING}"; + config.providers.xai!.apiKeyPool = [{ id: "env-key", key: "${OCX_TEST_XAI_API_KEY_MISSING}" }]; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.authMode).toBe("key"); + + const previous = process.env.OCX_TEST_XAI_API_KEY_MISSING; + delete process.env.OCX_TEST_XAI_API_KEY_MISSING; + try { + const routed = routeModel(config, "xai/grok-4.5").provider; + expect(routed.authMode).toBe("oauth"); + expect(routed.apiKey).toBeUndefined(); + expect(config.providers.xai!.authMode).toBe("key"); + } finally { + if (previous === undefined) delete process.env.OCX_TEST_XAI_API_KEY_MISSING; + else process.env.OCX_TEST_XAI_API_KEY_MISSING = previous; + } + }); + + test("uses key billing at routing time when the proxy resolves the env-backed active key", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + config.providers.xai!.apiKey = "${OCX_TEST_XAI_API_KEY}"; + config.providers.xai!.apiKeyPool = [{ id: "env-key", key: "${OCX_TEST_XAI_API_KEY}" }]; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.authMode).toBe("key"); + + const previous = process.env.OCX_TEST_XAI_API_KEY; + process.env.OCX_TEST_XAI_API_KEY = "resolved-xai-secret"; + try { + const routed = routeModel(config, "xai/grok-4.5").provider; + expect(routed.authMode).toBe("key"); + expect(routed.apiKey).toBe("resolved-xai-secret"); + expect(config.providers.xai!.authMode).toBe("key"); + } finally { + if (previous === undefined) delete process.env.OCX_TEST_XAI_API_KEY; + else process.env.OCX_TEST_XAI_API_KEY = previous; + } + }); + + test("CLI and proxy env visibility can diverge without rewriting stored authMode", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + config.providers.xai!.apiKey = "${OCX_TEST_XAI_SPLIT_ENV}"; + config.providers.xai!.apiKeyPool = [{ id: "env-key", key: "${OCX_TEST_XAI_SPLIT_ENV}" }]; + const previous = process.env.OCX_TEST_XAI_SPLIT_ENV; + + // Login CLI sees the secret; upsert still records key intent, not CLI resolution. + process.env.OCX_TEST_XAI_SPLIT_ENV = "cli-only-secret"; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.authMode).toBe("key"); + + // Running proxy lacks the env var: route on OAuth without touching stored mode. + delete process.env.OCX_TEST_XAI_SPLIT_ENV; + const oauthFallback = routeModel(config, "xai/grok-4.5").provider; + expect(oauthFallback.authMode).toBe("oauth"); + expect(oauthFallback.apiKey).toBeUndefined(); + expect(config.providers.xai!.authMode).toBe("key"); + + // Proxy later gains the env var: key billing resumes without a config rewrite. + process.env.OCX_TEST_XAI_SPLIT_ENV = "proxy-secret"; + const keyRoute = routeModel(config, "xai/grok-4.5").provider; + expect(keyRoute.authMode).toBe("key"); + expect(keyRoute.apiKey).toBe("proxy-secret"); + expect(config.providers.xai!.authMode).toBe("key"); + + if (previous === undefined) delete process.env.OCX_TEST_XAI_SPLIT_ENV; + else process.env.OCX_TEST_XAI_SPLIT_ENV = previous; + }); + + test("inserts a missing active key into the pool so listing matches routing", () => { + const config = { + port: 10100, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "routing-only-key", + apiKeyPool: [{ id: "pool-visible", key: "pool-visible-key" }], + }, + }, + } as OcxConfig; + const previousHome = process.env.OPENCODEX_HOME; + const testHome = mkdtempSync(join(tmpdir(), "ocx-oauth-upsert-pool-")); + process.env.OPENCODEX_HOME = testHome; + try { + upsertOAuthProvider(config, "xai"); + const provider = config.providers.xai!; + const activeId = apiKeyPoolEntryId("routing-only-key"); + expect(provider.authMode).toBe("key"); + expect(provider.apiKey).toBe("routing-only-key"); + expect(provider.apiKeyPool).toEqual([ + { id: "pool-visible", key: "pool-visible-key" }, + { id: activeId, key: "routing-only-key" }, + ]); + + const listed = listProviderApiKeys(config, "xai"); + expect(listed.activeId).toBe(activeId); + expect(listed.keys.find(entry => entry.id === activeId)?.active).toBe(true); + expect(listed.keys.find(entry => entry.id === "pool-visible")?.active).toBe(false); + + expect(setActiveProviderApiKey(config, "xai", "pool-visible")).toBe(true); + expect(config.providers.xai!.apiKey).toBe("pool-visible-key"); + expect(listProviderApiKeys(config, "xai").activeId).toBe("pool-visible"); + + expect(setActiveProviderApiKey(config, "xai", activeId)).toBe(true); + expect(config.providers.xai!.apiKey).toBe("routing-only-key"); + expect(removeProviderApiKey(config, "xai", activeId)).toBe(true); + expect(config.providers.xai!.apiKey).toBe("pool-visible-key"); + expect(config.providers.xai!.apiKeyPool).toEqual([{ id: "pool-visible", key: "pool-visible-key" }]); + expect(listProviderApiKeys(config, "xai").activeId).toBe("pool-visible"); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(testHome, { recursive: true, force: true }); + } + }); + + test("drops malformed stored key fields instead of breaking OAuth routing", () => { + const config = { + port: 10100, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: 12345, + apiKeyPool: [{ id: "bad-key", key: 67890 }], + }, + }, + } as unknown as OcxConfig; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.authMode).toBe("oauth"); + expect(config.providers.xai!.apiKey).toBeUndefined(); + expect(config.providers.xai!.apiKeyPool).toBeUndefined(); + expect(routeModel(config, "xai/grok-4.5").provider.authMode).toBe("oauth"); + }); + + test("rejects an unsafe active key and promotes the first safe pool entry", () => { + const config = { + port: 10100, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "unsafe\r\nheader", + apiKeyPool: [ + { id: "safe", key: " safe-alternate " }, + { id: "unsafe", key: "bad\r\nheader" }, + ], + }, + }, + } as OcxConfig; + upsertOAuthProvider(config, "xai"); + const provider = config.providers.xai!; + expect(provider.authMode).toBe("key"); + expect(provider.apiKey).toBe("safe-alternate"); + expect(provider.apiKeyPool).toEqual([{ id: "safe", key: "safe-alternate" }]); + expect(routeModel(config, "xai/grok-4.5").provider.authMode).toBe("key"); + }); + + test("filters malformed and duplicate pool data without deleting valid keys", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + config.providers.xai!.apiKeyPool = [ + { id: "primary", key: "stored-key-sentinel" }, + { id: "alternate", key: " alternate-key " }, + { id: "alternate", key: "duplicate-id" }, + { id: "duplicate-key", key: "alternate-key" }, + { id: "bad-added-at", key: "bad-metadata", addedAt: Number.NaN }, + ]; + upsertOAuthProvider(config, "xai"); + const provider = config.providers.xai!; + expect(provider.authMode).toBe("key"); + expect(provider.apiKey).toBe("stored-key-sentinel"); + expect(provider.apiKeyPool).toEqual([ + { id: "primary", key: "stored-key-sentinel" }, + { id: "alternate", key: "alternate-key" }, + { id: "bad-added-at", key: "bad-metadata" }, + ]); + }); + + test("returns to oauth after the last stored API key is removed", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + const previousHome = process.env.OPENCODEX_HOME; + const testHome = mkdtempSync(join(tmpdir(), "ocx-oauth-upsert-")); + process.env.OPENCODEX_HOME = testHome; + try { + expect(removeProviderApiKey(config, "xai", "aaaaaaaa")).toBe(true); + expect(config.providers.xai!.authMode).toBe("key"); + expect(config.providers.xai!.apiKey).toBeUndefined(); + expect(config.providers.xai!.apiKeyPool).toBeUndefined(); + + upsertOAuthProvider(config, "xai"); + const provider = config.providers.xai!; + expect(provider.authMode).toBe("oauth"); + expect(provider.apiKey).toBeUndefined(); + expect(provider.apiKeyPool).toBeUndefined(); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(testHome, { recursive: true, force: true }); + } + }); + + test("still applies the plain preset for oauth-only providers", () => { + const config = { + port: 10100, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "key", + apiKey: "stale-key", + apiKeyPool: [{ id: "stale", key: "stale-key" }], + note: "stale-note", + }, + }, + } as unknown as OcxConfig; + upsertOAuthProvider(config, "anthropic"); + const provider = config.providers.anthropic!; + expect(provider.authMode).toBe("oauth"); + expect(provider.apiKey).toBeUndefined(); + expect(provider.apiKeyPool).toBeUndefined(); + expect(provider.note).toBeUndefined(); + }); + + test("a fresh login on an unconfigured provider gets the untouched preset", () => { + const config = { port: 10100, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + upsertOAuthProvider(config, "xai"); + const provider = config.providers.xai!; + expect(provider.authMode).toBe("oauth"); + expect(provider.apiKey).toBeUndefined(); + expect(provider.apiKeyPool).toBeUndefined(); + }); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts index 34af7ebb6..82cf6cb5b 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -39,6 +39,33 @@ describe("routeModel registry effort defaults", () => { expect(routeModel(cursorKeyAttempt, "cursor/auto").provider.authMode).toBe("oauth"); }); + test("falls back to OAuth routing for allowKeyAuthOverride providers when the active key is unresolved", () => { + const envName = "OCX_TEST_XAI_ROUTER_ENV"; + const config: OcxConfig = { + port: 10100, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: `\${${envName}}`, + }, + }, + }; + const previous = process.env[envName]; + delete process.env[envName]; + try { + const routed = routeModel(config, "xai/grok-4.5").provider; + expect(routed.authMode).toBe("oauth"); + expect(routed.apiKey).toBeUndefined(); + expect(config.providers.xai!.authMode).toBe("key"); + } finally { + if (previous === undefined) delete process.env[envName]; + else process.env[envName] = previous; + } + }); + test("routes bare OpenAI/Codex model ids to OpenAI before adopted Cursor model lists", () => { const config: OcxConfig = { port: 10100, From 461de3961568b3863afa296d3fe5f5bab610071c Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 28 Jul 2026 04:08:58 +0900 Subject: [PATCH 009/129] chore: stop tracking local agent session state (#567) The .codexclaw/ goalplans and ledgers are per-machine agent state. They were committed with 'git add -f' despite the ignore rule, and once tracked the rule stopped applying, so they rode along into main and preview. Untrack them (files stay on disk), drop two .DS_Store files that got in the same way, and add tests/repo-hygiene.test.ts so a forced add fails CI instead of landing silently. Also drop a registry.ts comment pointing at a .codexclaw evidence file that was never committed and cannot be resolved by any reader. --- .../goalplan.json | 356 ----------------- .../ledger.jsonl | 21 - .../goalplan.json | 365 ------------------ .../ledger.jsonl | 24 -- .gitignore | 6 + devlog/.DS_Store | Bin 6148 -> 0 bytes devlog/_plan/.DS_Store | Bin 6148 -> 0 bytes src/providers/registry.ts | 1 - tests/repo-hygiene.test.ts | 57 +++ 9 files changed, 63 insertions(+), 767 deletions(-) delete mode 100644 .codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json delete mode 100644 .codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl delete mode 100644 .codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json delete mode 100644 .codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl delete mode 100644 devlog/.DS_Store delete mode 100644 devlog/_plan/.DS_Store create mode 100644 tests/repo-hygiene.test.ts diff --git a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json b/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json deleted file mode 100644 index 937dba65c..000000000 --- a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/goalplan.json +++ /dev/null @@ -1,356 +0,0 @@ -{ - "objective": "opencodex 저장소 거버넌스 및 기여 정책 4건을 PABCD 다중 work-phase 루프로 처리한다. WP1 문서화 우선 사이클, WP2 dev2-go 기반 PR 및 포팅/리베이스 PR 허용 정책, WP3 대상 PR 두 개로 분할, WP4 Wibias 메인테이너 추가. 검증은 실제 파일과 명령 출력으로만. 서브에이전트는 gpt-5.6-terra medium 일반 티어 활용.", - "slug": "opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr", - "createdAt": "2026-07-27T00:41:29.352Z", - "updatedAt": "2026-07-27T04:58:54.547Z", - "activeWorkPhaseId": "wp8", - "workPhases": [ - { - "id": "wp1", - "title": "docs-only 로드맵 사이클: devlog/_plan/260727_governance_intake/ 000/010/020/030 작성", - "status": "done", - "tasks": [ - { - "id": "wp1-t1", - "title": "000 현황 조사: MAINTAINERS.md/CODEOWNERS/AGENTS.md/CONTRIBUTING 및 브랜치 실측", - "status": "done" - }, - { - "id": "wp1-t2", - "title": "010 dev2-go 기반 PR + 포팅/리베이스 PR 허용 정책 diff-level 안", - "status": "done" - }, - { - "id": "wp1-t3", - "title": "020 대상 PR 분할 안 (어떤 PR을 어떤 경계로 두 개로)", - "status": "done" - }, - { - "id": "wp1-t4", - "title": "030 Wibias 메인테이너 추가 diff-level 안", - "status": "done" - } - ], - "criteriaIds": [ - "c1" - ] - }, - { - "id": "wp2", - "title": "브랜치 정책 문서화 (문서 전용): dev2-go 통합선 + 포팅/리베이스 PR 환영", - "status": "done", - "tasks": [ - { - "id": "wp2-t1", - "title": "AGENTS.md Branch policy + Review guidelines", - "status": "done" - }, - { - "id": "wp2-t2", - "title": "CONTRIBUTING.md Branches 절", - "status": "done" - }, - { - "id": "wp2-t3", - "title": "MAINTAINERS.md 리뷰/머지 정책", - "status": "done" - } - ], - "criteriaIds": [ - "c2" - ] - }, - { - "id": "wp4", - "title": "Wibias 메인테이너 추가 (MAINTAINERS.md + CODEOWNERS)", - "status": "done", - "tasks": [ - { - "id": "wp4-t1", - "title": "MAINTAINERS.md Current maintainers 표에 @Wibias 추가", - "status": "done" - }, - { - "id": "wp4-t2", - "title": ".github/CODEOWNERS 기본 리뷰어 및 보안 경로 갱신", - "status": "done" - }, - { - "id": "wp4-t3", - "title": "Maintainer changes 절차 이행 기록", - "status": "done" - } - ], - "criteriaIds": [ - "c4" - ] - }, - { - "id": "wp3", - "title": "대상 PR 두 개로 분할 (WP4 이후 — 분할 대상 #518은 Wibias 소유이므로 메인테이너 지위가 선행)", - "status": "done", - "tasks": [ - { - "id": "wp3-t1", - "title": "020 문서가 지정한 분할 대상 및 경계 확정", - "status": "done" - }, - { - "id": "wp3-t2", - "title": "분할 브랜치 생성 및 커밋 분리", - "status": "done" - }, - { - "id": "wp3-t3", - "title": "typecheck + 관련 테스트 통과 확인", - "status": "done" - } - ], - "criteriaIds": [ - "c3" - ] - }, - { - "id": "wp5", - "title": "PR 타깃 게이트 재설계 (enforce-pr-target.yml) — 사용자 승인 완료, allow-list 방식 채택", - "status": "done", - "tasks": [ - { - "id": "wp5-t1", - "title": "040 문서에 승인 결과와 채택안(c: allow-list) 확정 기록", - "status": "done" - }, - { - "id": "wp5-t2", - "title": "enforce-pr-target.yml: EXPECTED_BASE -> ALLOWED_BASES allow-list", - "status": "done" - }, - { - "id": "wp5-t3", - "title": "tests/ci-workflows.test.ts: dev2-go 통과 / 그 외 차단 / 복원 시나리오", - "status": "done" - }, - { - "id": "wp5-t4", - "title": "AGENTS.md/CONTRIBUTING.md/MAINTAINERS.md 서술을 실제 동작에 동기화", - "status": "done" - }, - { - "id": "wp5-t5", - "title": "독립 감사 + 변이 회귀 재실행 (신규 allow-list 우회 변이 포함)", - "status": "pending" - }, - { - "id": "wp5-t6", - "title": "CI 트리거 갭 해소: ci.yml/service-lifecycle.yml pull_request에 dev2-go 추가 + 드리프트 테스트", - "status": "done" - } - ], - "criteriaIds": [ - "c5" - ] - }, - { - "id": "wp6", - "title": "enforce-pr-target.yml 특성화 테스트 — 현재 동작을 고정 (승인 불필요)", - "status": "done", - "tasks": [ - { - "id": "wp6-t1", - "title": "현재 워크플로 계약을 tests/ci-workflows.test.ts에 고정", - "status": "done" - }, - { - "id": "wp6-t2", - "title": "050에서 관측한 상태 마커 정합성 결함을 테스트로 표현", - "status": "done" - }, - { - "id": "wp6-t3", - "title": "bun test tests/ci-workflows.test.ts 통과 확인", - "status": "done" - } - ], - "criteriaIds": [ - "c6" - ] - }, - { - "id": "wp7", - "title": "특성화 테스트 하드닝 — 감사 2/3라운드가 찾은 우회 봉쇄 (wp6 조기 종료 후속)", - "status": "done", - "tasks": [ - { - "id": "wp7-t1", - "title": "감사 2라운드가 찾은 6가지 우회 봉쇄 (잡 인벤토리/잡 권한/스텝 수/블록 주석/복원 분기/pull_number 바인딩)", - "status": "done" - }, - { - "id": "wp7-t2", - "title": "알려진 변이 10종 재주입 검증, 워크플로 원복 확인", - "status": "done" - }, - { - "id": "wp7-t3", - "title": "감사 3라운드 (독립 서브에이전트, 새 각도)", - "status": "done" - }, - { - "id": "wp7-t4", - "title": "3라운드 지적 반영 후 커밋 및 dev 푸시", - "status": "done" - }, - { - "id": "wp7-t5", - "title": "감사 3라운드 FAIL(14건) → 부정 목록을 허용 목록으로 반전", - "status": "done" - }, - { - "id": "wp7-t6", - "title": "감사 4라운드 FAIL(12건, 전부 스크립트 내부) → 실행 하네스 도입", - "status": "done" - }, - { - "id": "wp7-t7", - "title": "감사 5라운드 FAIL(6건, 하네스 충실도) → 하네스를 실제 러너에 맞춤 + 시나리오 3개", - "status": "done" - }, - { - "id": "wp7-t8", - "title": "감사 6~10라운드 대응 (하네스 충실도 + 계약 어설션)", - "status": "done" - }, - { - "id": "wp7-t9", - "title": "감사 11~14라운드 대응 (트리거 모양, 도달 가능 상태, 버전 스큐, 스키마 truthiness 계약)", - "status": "done" - } - ], - "criteriaIds": [ - "c7" - ] - }, - { - "id": "wp8", - "title": "게이트를 main으로 승격 (fast-forward) — pull_request_target은 기본 브랜치 워크플로를 실행하므로 이것 없이는 발효되지 않음", - "status": "deferred", - "tasks": [ - { - "id": "wp8-t1", - "title": "dev HEAD b4a9fe5c 호스팅 CI success 확인 (Cross-platform CI + Service lifecycle)", - "status": "deferred" - }, - { - "id": "wp8-t2", - "title": "main..dev 98커밋 범위에 릴리스 파이프라인/보안 리뷰 대상 변경이 있는지 확인", - "status": "deferred" - }, - { - "id": "wp8-t3", - "title": "git push origin b4a9fe5c:main (fast-forward, 로컬 체크아웃은 dev 유지)", - "status": "deferred" - }, - { - "id": "wp8-t4", - "title": "승격 후 원격 main의 enforce-pr-target.yml에 ALLOWED_BASES 존재 확인 + c5 met 기록", - "status": "deferred" - } - ], - "criteriaIds": [], - "note": "사용자가 세션 범위를 dev로 좁힘(\"일단 dev에서만 처리해\"). 사전 감사도 DO NOT PROMOTE 판정: 승격 범위가 게이트 한정이 아니라 main..dev 98커밋/9172줄이고, main 푸시는 deploy-docs(실제 Pages 배포)를 트리거한다. 메인테이너의 명시적 수용이 선행 조건. 계획서: devlog/_plan/260727_governance_intake/070_main_promotion.md", - "blocker": "EXTERNAL: main 승격은 (a) 사용자가 이번 세션 범위에서 제외했고(\"일단 dev에서만 처리해\"), (b) 사전 감사가 DO NOT PROMOTE 판정을 냈다 — 승격 범위가 게이트 한정이 아니라 main..dev 98커밋/9172줄이며 main 푸시는 deploy-docs(실제 GitHub Pages 배포)를 트리거하므로 메인테이너의 명시적 수용이 선행 조건이다. 에이전트 권한으로 해소할 수 없다." - }, - { - "id": "wp9", - "title": "ocx account 인증 코드를 셸 인자에서 stdin으로 (WP8 사전 감사 High 지적)", - "status": "done", - "tasks": [ - { - "id": "wp9-t1", - "title": "stdin 읽기 헬퍼 + 주입 가능한 deps (runtime-api.ts)", - "status": "done" - }, - { - "id": "wp9-t2", - "title": "account code 위치 인자 / login --code를 선택으로, 기본 stdin, '-'는 명시적 stdin", - "status": "done" - }, - { - "id": "wp9-t3", - "title": "인자 사용 시 stderr 경고 (값은 절대 에코 금지)", - "status": "done" - }, - { - "id": "wp9-t4", - "title": "tests/cli-account.test.ts 회귀 + 독립 감사", - "status": "done" - } - ], - "criteriaIds": [] - } - ], - "criteria": [ - { - "id": "c1", - "scenario": "WP1 docs-only 사이클이 끝나면 devlog/_plan/260727_governance_intake/ 아래 000/010/020/030 문서가 존재하고 각각 변경 파일 경로와 정확한 문구를 담는다. 프로덕션 코드 변경은 0건이다.", - "expectedEvidence": "ls devlog/_plan/260727_governance_intake/ 출력 + git show --stat 으로 src/ 변경 0건 확인", - "capturedEvidence": "ls devlog/_plan/260727_governance_intake/ → 000_survey.md, 010_branch_policy.md, 011_audit_round1.md, 012_audit_round2.md, 013_audit_round3_and_scope_split.md, 020_pr_split.md, 030_maintainer_wibias.md, 040_pr_target_gate.md (8개). git diff --name-only edf3b2c6..HEAD | rg -v '^devlog/' | wc -l → 0 (프로덕션 코드 변경 0건). 커밋 f43ead8c..e0d600f3 (9개). 독립 감사 6회: 1~5차 FAIL, 6차 NEAR-PASS.", - "status": "met" - }, - { - "id": "c2", - "scenario": "AGENTS.md/CONTRIBUTING.md/MAINTAINERS.md 세 파일 모두에서 dev2-go가 정식 통합선으로 문서화되고, 포팅/리베이스 PR이 환영 대상으로 명시되며, 현재 자동화가 dev2-go PR에 [WRONG BRANCH]를 붙인다는 사실이 숨겨지지 않고 적힌다. .github/ 변경은 0건이다.", - "expectedEvidence": "rg -n 'dev2-go' 세 파일 + rg -n -i 'porting|rebase' + rg -n 'WRONG BRANCH' + git diff --name-only에 .github/ 없음", - "capturedEvidence": "rg -c dev2-go AGENTS.md/CONTRIBUTING.md/MAINTAINERS.md → 2/1/2. rg -c -i \"porting|rebase\" AGENTS.md CONTRIBUTING.md → 1/1. rg -c \"WRONG BRANCH\" → 2/1/1 (세 파일 모두 현 자동화 동작 명시). git diff --name-only | rg \"^\\.github/\" → 0건 (워크플로 미변경). docs-site 5개 로케일(en/ko/ja/ru/zh-cn) 각 dev2-go 1건. 커밋 a37cc55b, e4062e32. 독립 감사 2라운드 VERDICT: PASS.", - "status": "met" - }, - { - "id": "c3", - "scenario": "지정된 PR이 두 개의 독립적인 변경으로 분리되고, 각각 bun run typecheck 통과 및 관련 테스트 통과가 증명된다.", - "expectedEvidence": "git log --oneline 분리 브랜치 + bun run typecheck exit 0 + bun test 관련 파일 결과", - "capturedEvidence": "SRC=d93b46932b58b963ea5dfa27dee5c63f3a7b0f2a 고정. B(codex/catalog-written-signal, PR #526): typecheck exit 0, 22 pass 0 fail, 6파일. A(codex/app-server-restart, PR #527): typecheck exit 0, 22 pass 1 skip 0 fail, 15파일. 합집합 검증 diff pr518-manifest split-union → IDENTICAL (21파일). SRC 불변 재확인. 계획 경계 가정 3회 실측 정정: config-routes.ts, gui i18n syncStaleHint, codex-models-cache-invalidate.test.ts 전부 A 소속.", - "status": "met" - }, - { - "id": "c4", - "scenario": "MAINTAINERS.md의 Current maintainers 표에 @Wibias 행이 있고 .github/CODEOWNERS의 기본 리뷰어에 @Wibias가 포함되며, Maintainer changes 절차 이행이 기록된다.", - "expectedEvidence": "rg -n 'Wibias' MAINTAINERS.md .github/CODEOWNERS 출력", - "capturedEvidence": "rg -c Wibias MAINTAINERS.md → 2 (표 행 + change log). rg -c Wibias .github/CODEOWNERS → 1 (기본 리뷰어 * 규칙만). 보안 경로 미포함 검증 → 0건. MAINTAINERS.md 표 3행. 감사 확인: CODEOWNERS 마지막 매칭 우선 규칙으로 @Wibias는 /src/oauth/, /.github/, /scripts/release.ts, /MAINTAINERS.md, /SECURITY.md, /package.json, /bun.lock의 코드오너가 아님(GitHub API errors:[]). 커밋 01e831d0, 71e43d9c, 491373f3.", - "status": "met" - }, - { - "id": "c5", - "scenario": "enforce-pr-target.yml이 dev2-go PR을 정당하게 통과시키고, 그 외 타깃은 차단·복원 경로가 회귀 테스트로 덮이며, PR을 받는 브랜치는 CI 트리거도 함께 받는다. actor 검증 + head SHA 바인딩은 040 문서의 근거 셋에 따라 채택하지 않았다 (강제력 없는 자동 판정 대신 리뷰가 잡는다). main 승격으로 실제 발효된다.", - "expectedEvidence": "워크플로 diff + 신규 테스트 통과 출력 + main 승격 확인", - "capturedEvidence": "커밋: d761e880 (ALLOWED_BASES allow-list, 워크플로 5지점) / 10b1d2aa (문서 8종, 5개 로케일 포함) / 5229717b (ci.yml+service-lifecycle.yml PR 트리거에 dev2-go) / 76c25710 (하네스 node24 충실도 + 트리거 키집합 고정 + live base 코멘트 어설션) / 2b03e908 (ci.yml paths 양쪽 트리거 정확 집합 고정) / 99679376 (040 문서에 15~17라운드 기록). 검증: bun x tsc --noEmit exit 0; bun run test 4945 pass / 0 fail / 24352 expect(); bun test tests/ci-workflows.test.ts 52 pass / 0 fail / 548 expect(). 변이 회귀: mut16 12/12, mut17 5/5, mut18 5/5 (봉쇄 전 5종 전부 SURVIVED), mut20 8/8 (봉쇄 전 4종 SURVIVED) 전부 CAUGHT. 독립 감사 3라운드: 15=FAIL(5건) -> 76c25710, 16=FAIL(1건) -> 2b03e908, 17=PASS (12/12 CAUGHT, 무해 생존 2건 근거 기록). 미충족 잔여: main 승격 (승인 완료, 다음 work-phase). [세션 종료 시점] dev 쪽 조건은 전부 충족(워크플로 diff + ci-workflows 테스트 통과 + 변이 회귀 전건 CAUGHT). 남은 조건인 'main 승격으로 발효'만 미충족이며, 이는 사용자가 이번 세션 범위에서 제외했다. origin/main은 origin/dev의 조상이라 fast-forward 가능한 상태로 남아 있다.", - "status": "deferred", - "blocker": "EXTERNAL: 'main 승격으로 발효' 조건만 미충족. 승격 권한/수용은 메인테이너 결정이며 사용자가 범위에서 제외했다." - }, - { - "id": "c6", - "scenario": "tests/ci-workflows.test.ts에 enforce-pr-target.yml 테스트가 존재하고, 현재 동작(pull_request_target 트리거 집합, EXPECTED_BASE, 제목 prefix, draft 전환, checkout/PR코드실행 없음, 최소 권한)을 고정한다. WP5에서 게이트를 바꿀 때 이 테스트가 회귀 그물이 된다.", - "expectedEvidence": "bun test tests/ci-workflows.test.ts 통과 출력 + rg enforce-pr-target tests/ci-workflows.test.ts", - "capturedEvidence": "tests/ci-workflows.test.ts에 PR target enforcement 테스트 3개(rg -c → 3). bun test → 14 pass 0 fail 282 expect(). bun run typecheck exit 0. 4라운드 적대적 변이 감사: 1차 FAIL(문자열 매칭 구멍 3), 2차 FAIL(YAML 문법 우회 5), 3차 FAIL(함수 shadow), 4차 PASS. 최종적으로 Bun.YAML 파싱 + quote-aware 주석 제거 + 함수 선언 개수/mutation 결속. 통과하는 변이는 전부 고의적 무력화만 남음.", - "status": "met" - }, - { - "id": "c7", - "scenario": "enforce-pr-target.yml 특성화 테스트가 알려진 모든 우회 변이를 잡는다", - "expectedEvidence": "변이 주입 스크립트 출력에서 알려진 변이 전부 CAUGHT, survived=none. 기준선 bun test 통과, bun run typecheck 오류 0. 독립 감사(gpt-5.6-terra) verdict PASS 또는 NEAR-PASS.", - "capturedEvidence": "변이 회귀 12개 스크립트 131종 전수: 알려진 무해 6종(context-eventname, core-tostring, promise-identity, err-tostringtag, response-headers, comment-after-write)과 들여쓰기 no-op 2종 외 전부 CAUGHT, git status --short .github/ clean. 기준선 bun test tests/ci-workflows.test.ts -> 44 pass / 0 fail / 485 expect(). bun run test -> 4937 pass / 0 fail / 24289 expect() exit 0. bun x tsc --noEmit exit 0. 독립 감사(gpt-5.6-terra, agent 019fa18f) 14라운드 verdict NEAR-PASS, 잔여 지적 1건은 dbd558e1로 봉쇄 후 CAUGHT 확인.", - "status": "met" - }, - { - "id": "c9", - "scenario": "ocx account login/code가 인증 코드를 셸 히스토리와 프로세스 목록에 남기지 않는 경로를 기본으로 제공하고, 인자 경로를 쓰면 값을 노출하지 않는 경고가 나온다.", - "expectedEvidence": "테스트 통과 출력 + 인자 경고 어설션 + 값 미노출 어설션", - "capturedEvidence": "구현: d4dfa24e(stdin 기본 경로), e7f5bef5(공백 문법 redact + 중복 --code 거부), d3dc9d79(미지 플래그를 코드로 먹지 않음), f10c5060(비밀 옵션 뒤 토큰은 모양 무관 은닉, -- 구분자 건너뜀, account code 잔여 위치 인자 은닉, 종료된 stdin 즉시 실패), 1de43286(CR/LF 줄바꿈 고정). 검증: bun run test 4965 pass / 0 fail / 24429 expect(); bun x tsc --noEmit exit 0; bun test tests/cli-account.test.ts 62 pass / 0 fail / 276 expect(). 변이: mut23 10/10 CAUGHT, mut21 9/9 CAUGHT, mut22 4/4 CAUGHT. 독립 감사 2라운드: R2 FAIL(High 2 + Medium 1 + Low 1, 전부 프로브로 실측 재현) → 수정 → R3 VERDICT: PASS. 잔여: `--code --nope`는 --nope를 값으로 간주해 은닉(사용 오류 메시지는 --code를 여전히 지목) — 진단성보다 노출 차단 우선.", - "status": "met" - } - ], - "host": { - "armed": false, - "armedAt": null, - "source": "none" - } -} \ No newline at end of file diff --git a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl b/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl deleted file mode 100644 index 4cabb8387..000000000 --- a/.codexclaw/goalplans/opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr/ledger.jsonl +++ /dev/null @@ -1,21 +0,0 @@ -{"ts":"2026-07-27T00:41:29.353Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"created","detail":"init objective=\"opencodex 저장소 거버넌스 및 기여 정책 4건을 PABCD 다중 work-phase 루프로 처리한다. WP1 문서화 우선 사이클, WP2 dev2-go 기반 PR 및 포팅/리베이스 PR 허용 정책, WP3 대상 PR 두 개로 분할, WP4 Wibias 메인테이너 추가. 검증은 실제 파일과 명령 출력으로만. 서브에이전트는 gpt-5.6-terra medium 일반 티어 활용.\" criteria=0"} -{"ts":"2026-07-27T01:18:20.834Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp1"} -{"ts":"2026-07-27T01:18:20.834Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp2"} -{"ts":"2026-07-27T01:26:02.696Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp2"} -{"ts":"2026-07-27T01:26:02.696Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp4"} -{"ts":"2026-07-27T01:32:20.967Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp4"} -{"ts":"2026-07-27T01:32:20.967Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp3"} -{"ts":"2026-07-27T01:45:55.340Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp3"} -{"ts":"2026-07-27T01:45:55.340Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts":"2026-07-27T02:09:28.671Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp6"} -{"ts":"2026-07-27T02:09:28.671Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts": "2026-07-27T02:12:28.095252Z", "slug": "opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr", "event": "workphase_started", "detail": "started wp7 (P-phase amendment: wp6 closed before the round-2 audit returned FAIL; hardening is the next unit, LOOP-UNIT-CHAIN-01)"} -{"ts":"2026-07-27T02:14:19.004Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp7"} -{"ts":"2026-07-27T02:14:19.004Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts":"2026-07-27T03:11:25Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"re-activated wp7: rounds 7-10 hardening was the unit actually worked; wp5 remains blocked on user approval and was parked back to pending"} -{"ts":"2026-07-27T03:39:57.830Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp7"} -{"ts":"2026-07-27T03:39:57.830Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp5"} -{"ts":"2026-07-27T03:40:13Z","event":"work-phase-complete","workPhaseId":"wp7","criterion":"c7","status":"met","evidence":"rounds 11-14 audited; 131 mutation variants CAUGHT except 6 proven-harmless; suite 4937 pass/0 fail; typecheck 0; commits 5f7afc21, dbd558e1"} -{"ts":"2026-07-27T04:17:39.364Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp5"} -{"ts":"2026-07-27T04:58:54.547Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_done","detail":"closed wp9"} -{"ts":"2026-07-27T04:58:54.547Z","slug":"opencodex-4-pabcd-work-phase-wp1-wp2-dev2-go-pr","event":"workphase_started","detail":"started wp8"} diff --git a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json b/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json deleted file mode 100644 index bf89c77b1..000000000 --- a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/goalplan.json +++ /dev/null @@ -1,365 +0,0 @@ -{ - "objective": "OpenCodex live unfinished issues and PRs triage against current GitHub state. Scope: repository lidge-jun/opencodex, dev target only, worktree . First work-phase is docs-only manifest/devlog: fetch live open PRs and open issues via gh, record current number/title/state/base/head/mergeable/checks/reviews/labels, classify every item into merge now, takeover-fix, comment/request-changes, needs-author-rebase, needs-human/security, later/enhancement, upstream-tracking, or close, and produce a priority table. Later work-phases process exactly one PR or one issue per full PABCD cycle. Allowed: directly fix bugfix/simple safe items on dev, create PRs, wait for CI, squash merge, and close linked issues when live evidence proves safety. Out of scope: main/preview/release branches, automatic merge of security/auth/permission/data-migration/privilege-boundary work, and unapproved GUI/UX decisions except the approved OpenRouter Free separate-provider direction. Terminal outcomes: DONE when all safe live items are processed and manifest evidence is current; NOOP when an item needs no action after live check; NEEDS_HUMAN or UNSAFE for risk-bound/security/UX-decision items; BLOCKED for external author/rebase/CI or upstream dependency; BUDGET_EXHAUSTED only if explicit runtime bounds are hit. Verification: gh live snapshots, code/diff review for candidate PRs, CI/check URLs where actions occur, comments/merge/close URLs for external state changes, and devlog evidence committed locally before completion.", - "slug": "opencodex-live-unfinished-issues-and-prs-triage", - "createdAt": "2026-07-27T10:35:02.724Z", - "updatedAt": "2026-07-27T12:25:15.899Z", - "activeWorkPhaseId": "WP8", - "workPhases": [ - { - "id": "WP0", - "title": "Live GitHub triage manifest and priority table", - "status": "done", - "tasks": [ - { - "id": "WP0-T1", - "title": "Refresh dev branch/worktree state from origin/dev without touching main/preview/release branches", - "status": "done" - }, - { - "id": "WP0-T2", - "title": "Query all current open PRs and issues via gh with state/base/head/mergeable/checks/reviews/labels", - "status": "done" - }, - { - "id": "WP0-T3", - "title": "Write numbered docs-first devlog manifest and priority table", - "status": "done" - }, - { - "id": "WP0-T4", - "title": "Append follow-up one-item work-phases for safe merge/fix/close candidates discovered by the manifest", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP0-LIVE-MANIFEST" - ] - }, - { - "id": "WP1", - "title": "PR #526 catalog write signal rebase and coverage decision", - "status": "done", - "tasks": [ - { - "id": "WP1-T1", - "title": "Re-check PR #526 live head, checks, diff, and independent review", - "status": "done" - }, - { - "id": "WP1-T2", - "title": "Take over rebase/tests or leave a documented blocker for PR #526 only", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP1-PR526" - ] - }, - { - "id": "WP9", - "title": "Dev baseline checkout blocker from devlog gitlinks", - "status": "done", - "tasks": [ - { - "id": "WP9-T1", - "title": "Confirm current dev gitlink/.gitmodules mismatch and whether devlog chase checkouts are required tracked inputs", - "status": "done" - }, - { - "id": "WP9-T2", - "title": "Remove or repair only the accidental devlog gitlinks on a dev-target branch", - "status": "done" - }, - { - "id": "WP9-T3", - "title": "Verify checkout/submodule commands and open/merge the dev baseline CI fix before rerunning PR #526", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP9-DEVLOG-GITLINK" - ] - }, - { - "id": "WP10", - "title": "Dev baseline Desktop 3P target-platform path fix", - "status": "done", - "tasks": [ - { - "id": "WP10-T1", - "title": "Confirm hosted Windows failure and local Desktop 3P path resolver cause", - "status": "done" - }, - { - "id": "WP10-T2", - "title": "Patch target-platform-specific path joining and regression tests on a dev-target branch", - "status": "done" - }, - { - "id": "WP10-T3", - "title": "Verify locally, push PR, wait for hosted checks, and squash merge if green", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP10-DESKTOP3P-PATH" - ] - }, - { - "id": "WP2", - "title": "PR #528 image bridge credential-origin request changes", - "status": "pending", - "tasks": [ - { - "id": "WP2-T1", - "title": "Re-check PR #528 live head, checks, diff, and independent review", - "status": "pending" - }, - { - "id": "WP2-T2", - "title": "Post request-changes comment for credential-origin binding and stale checks", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP2-PR528" - ] - }, - { - "id": "WP11", - "title": "PR #526 final rerun and merge after dev baseline fixes", - "status": "done", - "tasks": [ - { - "id": "WP11-T1", - "title": "Rebase PR #526 branch onto current origin/dev after WP9/WP10", - "status": "done" - }, - { - "id": "WP11-T2", - "title": "Run local targeted verification and push the rebased PR head", - "status": "done" - }, - { - "id": "WP11-T3", - "title": "Wait for hosted checks and squash merge PR #526 if the latest head is clean", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP11-PR526-FINAL" - ] - }, - { - "id": "WP3", - "title": "Issue #543 Claude queued_command support reply", - "status": "pending", - "tasks": [ - { - "id": "WP3-T1", - "title": "Verify existing debug capture switch and issue context", - "status": "pending" - }, - { - "id": "WP3-T2", - "title": "Post one maintainer comment requesting marker frames", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP3-ISSUE543" - ] - }, - { - "id": "WP4", - "title": "Issue #547 Claude Desktop custom-model visibility reply", - "status": "pending", - "tasks": [ - { - "id": "WP4-T1", - "title": "Verify issue #547 context and likely evidence gaps", - "status": "pending" - }, - { - "id": "WP4-T2", - "title": "Post one maintainer comment requesting generated config/log/profile evidence", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP4-ISSUE547" - ] - }, - { - "id": "WP5", - "title": "Issue #545 64-token classifier investigation", - "status": "pending", - "tasks": [ - { - "id": "WP5-T1", - "title": "Trace max_tokens/max_output_tokens path for Desktop 3P classifier requests", - "status": "pending" - }, - { - "id": "WP5-T2", - "title": "Fix if local bug is proven; otherwise comment with exact evidence needed", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP5-ISSUE545" - ] - }, - { - "id": "WP6", - "title": "PR #527 wrong-base handling", - "status": "done", - "tasks": [ - { - "id": "WP6-T1", - "title": "Re-check #527 after #526 decision", - "status": "pending" - }, - { - "id": "WP6-T2", - "title": "Retarget/request rebase or leave blocker; do not merge in same phase as #526", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP6-PR527" - ] - }, - { - "id": "WP7", - "title": "Issue #418 V2 custom delegation investigation", - "status": "done", - "tasks": [ - { - "id": "WP7-T1", - "title": "Read custom-parent/custom-child delegation code path and issue evidence", - "status": "done" - }, - { - "id": "WP7-T2", - "title": "Fix or comment with code pointers and required reproduction evidence", - "status": "done" - } - ], - "criteriaIds": [ - "C-WP7-ISSUE418" - ] - }, - { - "id": "WP8", - "title": "Issue #509 JS heap watchdog investigation", - "status": "in_progress", - "tasks": [ - { - "id": "WP8-T1", - "title": "Trace RSS/heap watchdog logic and issue evidence", - "status": "pending" - }, - { - "id": "WP8-T2", - "title": "Fix or comment with code pointers and blocker", - "status": "pending" - } - ], - "criteriaIds": [ - "C-WP8-ISSUE509" - ] - } - ], - "criteria": [ - { - "id": "C-WP0-LIVE-MANIFEST", - "scenario": "Live GitHub state has been refreshed and every open PR/open issue is classified into the requested triage buckets.", - "expectedEvidence": "devlog/_plan/260727_live_unfinished_triage/ numbered manifest files with gh snapshot timestamp, PR/issue lists, classification rationale, and priority table.", - "capturedEvidence": "WP0 closed by cxc D at 2026-07-27T10:46:14Z. Commit 58b247ac records devlog/_plan/260727_live_unfinished_triage. C check: open PR count 14 with numbers 355,424,429,447,461,491,493,495,498,512,526,527,528,533; open issue count 23 with numbers 42,92,95,177,178,201,241,294,386,401,414,415,417,418,425,462,476,509,521,540,543,545,547; manifest has unsafe merge-now entries 0.", - "status": "met" - }, - { - "id": "C-WP1-PR526", - "scenario": "PR #526 is either taken over with rebase/direct coverage or left open with a fresh blocker comment.", - "expectedEvidence": "live PR head/checks, independent review verdict, test evidence or comment URL.", - "capturedEvidence": "WP1 closed by cxc D at 2026-07-27T11:01:22Z. Same-repo branch codex/catalog-written-signal was rebased onto origin/dev@7fcaa9119253d010393cb457427a2868cd935718 and pushed at 43d0efff4569711ed192e09d4d87b62fc803153c. Local pre-push passed typecheck, lint:gui, bun test 5051 pass/0 fail/24881 assertions, privacy scan, and React Doctor. Hosted Issue quality tests/test failed before tests during checkout with fatal missing .gitmodules mapping for devlog/_chase/_cca; origin/dev has the same gitlink mismatch. Merge held pending WP9 baseline checkout repair.", - "status": "met" - }, - { - "id": "C-WP9-DEVLOG-GITLINK", - "scenario": "The dev baseline checkout blocker caused by tracked devlog gitlinks without .gitmodules mappings is fixed or proven to require human direction.", - "expectedEvidence": "git tree/submodule evidence, commit/PR URL, local checkout/submodule verification, and hosted CI rerun evidence if pushed.", - "capturedEvidence": "WP9 closed by cxc D at 2026-07-27T11:11:58Z. Branch codex/devlog-gitlink-ci-fix removed exactly three accidental 160000 gitlinks without .gitmodules mappings: devlog/_chase/_cca, devlog/_chase/_litellm, and devlog/_fin/opencode-cursor. Local verification: git ls-files -s found no 160000 entries; git submodule status --recursive exit 0; git diff --check origin/dev exit 0. PR #550 https://github.com/lidge-jun/opencodex/pull/550 passed hosted checks and was squash-merged into origin/dev at ff831858388179d3f76f4dd7c119d84470214fa6.", - "status": "met" - }, - { - "id": "C-WP10-DESKTOP3P-PATH", - "scenario": "The dev baseline Desktop 3P path resolver returns target-platform separators across hosted OSes and unblocks PR #526 CI.", - "expectedEvidence": "code pointers, targeted Bun test output, TypeScript output, diff-check output, PR URL, hosted check evidence, and merge SHA if green.", - "capturedEvidence": "WP10 branch codex/desktop3p-path-windows-ci commit f6d2881dd422830eece502e0ba8de493205fe9d1 patched src/claude/desktop-3p-paths.ts to use target-platform posix/win32 joins and updated tests/desktop-3p.test.ts plus tests/claude-desktop-config-path.test.ts. Local verification: bun test tests/desktop-3p.test.ts tests/claude-desktop-config-path.test.ts = 30 pass/0 fail; bun x tsc --noEmit exit 0; git diff --check origin/dev exit 0. Prepush full gate: 5047 pass/0 fail, privacy scan passed, GUI doctor skipped. Independent C review Aquinas PASS. PR #552 https://github.com/lidge-jun/opencodex/pull/552 passed hosted CodeRabbit, enforce-target, label, react-doctor, ubuntu-latest, macos-latest, windows-latest, and all npm-global matrix jobs; squash-merged at 2026-07-27T11:43:58Z as origin/dev 7c74e0a22ec96dd5849d3d7253758f0ab15d9737. Remote topic branch deleted.", - "status": "met" - }, - { - "id": "C-WP2-PR528", - "scenario": "PR #528 receives a request-changes comment for credential-origin binding and stale checks.", - "expectedEvidence": "live PR head/checks, independent review verdict, and comment/review URL.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP11-PR526-FINAL", - "scenario": "PR #526 is rebased after dev baseline blockers, verified on the latest head, and merged if clean.", - "expectedEvidence": "rebase head SHA, local test/typecheck/diff-check output, pushed head SHA, hosted check list, merge commit URL/SHA or blocker evidence.", - "capturedEvidence": "WP11 closed by cxc D at 2026-07-27T12:05:44Z merge evidence. Branch codex/catalog-written-signal was rebased on origin/dev@7c74e0a22ec96dd5849d3d7253758f0ab15d9737 and pushed at ce716cc117ab23e4420c8c9fe860959968f66cdc. Local targeted verification passed: bun test tests/codex-refresh.test.ts tests/codex-sync-api.test.ts tests/injection-model-api.test.ts = 24 pass/0 fail/112 assertions; bun x tsc --noEmit exit 0; git diff --check origin/dev exit 0. Pre-push full gate passed: bun run test = 5051 pass/0 fail/24881 assertions, privacy scan passed, GUI doctor skipped. Hosted checks on PR #526 head ce716cc117ab23e4420c8c9fe860959968f66cdc all succeeded: CodeRabbit, label, react-doctor, ubuntu-latest, macos-latest, windows-latest, and npm-global ubuntu/macos/windows. Pre-merge gate passed: remote dev stayed 7c74e0a22ec96dd5849d3d7253758f0ab15d9737, PR head matched ce716cc117ab23e4420c8c9fe860959968f66cdc, mergeable MERGEABLE/CLEAN and REST mergeable_state clean. PR #526 https://github.com/lidge-jun/opencodex/pull/526 was squash-merged into dev at 2026-07-27T12:05:44Z as 9dd3c42dae2e7feda3581c6d477cf5a0d6e646bf. Remote codex/catalog-written-signal branch was preserved at ce716cc117ab23e4420c8c9fe860959968f66cdc.", - "status": "met" - }, - { - "id": "C-WP3-ISSUE543", - "scenario": "Issue #543 receives a concrete support reply naming the existing debug capture switch and requested evidence.", - "expectedEvidence": "comment URL plus code/docs pointers for `ocx debug claude` or `OCX_CLAUDE_DEBUG=1`.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP4-ISSUE547", - "scenario": "Issue #547 receives a concrete evidence request for Claude Desktop custom-model visibility.", - "expectedEvidence": "comment URL plus requested config/log/profile evidence.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP5-ISSUE545", - "scenario": "Issue #545 is narrowed to a proven local fix or exact remaining evidence need.", - "expectedEvidence": "code pointers, test/command output if fixed, PR/comment URL.", - "capturedEvidence": "", - "status": "open" - }, - { - "id": "C-WP6-PR527", - "scenario": "PR #527 wrong-base state is resolved or documented after PR #526 decision.", - "expectedEvidence": "live base/check state and retarget/request-rebase/comment URL.", - "capturedEvidence": "WP6 closed by cxc D at 2026-07-27T12:16:35Z. Live PR #527 remained OPEN on base codex/catalog-written-signal@ce716cc117ab23e4420c8c9fe860959968f66cdc with head codex/app-server-restart@a64aa585630f664a83c25253497a62810133e832, mergeable CONFLICTING and mergeStateStatus DIRTY. PR #526 had merged to dev as 9dd3c42dae2e7feda3581c6d477cf5a0d6e646bf. Topology showed #527 still carried duplicate old #526 commit 1ba588eff663a5be846a8723b90a452dca8cd04c; merge-tree against current dev conflicted in tests/codex-refresh.test.ts and tests/injection-model-api.test.ts. Independent A review returned GO-WITH-FIXES and the blocker was folded into the comment plan. Posted maintainer comment https://github.com/lidge-jun/opencodex/pull/527#issuecomment-5091163284 requesting a clean rebuild from current dev, dropping 1ba588e, preserving the Grok diagnostic, and retargeting after rebuild. No retarget, push, merge, or branch deletion was performed.", - "status": "met" - }, - { - "id": "C-WP7-ISSUE418", - "scenario": "Issue #418 has a proven fix or a fresh investigation comment with code pointers.", - "expectedEvidence": "test/command output if fixed, otherwise comment URL plus code pointers.", - "capturedEvidence": "WP7 closed as NOOP/comment-request-changes at 2026-07-27. Live issue #418 remains OPEN with bug label. Existing comments already satisfy this phase's intended maintainer action: owner request https://github.com/lidge-jun/opencodex/issues/418#issuecomment-5069836945 asks for raw provider tool-call, Responses event, and child lifecycle trace; reporter acknowledgement https://github.com/lidge-jun/opencodex/issues/418#issuecomment-5070272410 says same-run failing spawn_agent trace is still unavailable until usage limit clears; collaborator cross-link https://github.com/lidge-jun/opencodex/issues/418#issuecomment-5085535548 keeps #418 separate from #92 pending the three-boundary capture. Code review found no proven local argument-drop path: parser copies tool parameters at src/responses/parser.ts:134-139; OpenAI-compatible adapter forwards parameters at src/adapters/openai-chat.ts:432-445; provider function.arguments fragments are accumulated at src/adapters/openai-chat.ts:749-768; bridge forwards deltas at src/bridge.ts:610-617 and materializes {} only when accumulated bytes are empty at src/bridge.ts:366-375. No GitHub comment, close, merge, or code change was performed.", - "status": "met" - }, - { - "id": "C-WP8-ISSUE509", - "scenario": "Issue #509 heap watchdog gap has a proven fix or a fresh investigation comment with code pointers.", - "expectedEvidence": "test/command output if fixed, otherwise comment URL plus code pointers.", - "capturedEvidence": "", - "status": "open" - } - ], - "host": { - "armed": true, - "armedAt": "2026-07-27T10:35:02.725Z", - "source": "freeze" - } -} diff --git a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl b/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl deleted file mode 100644 index 07d4aeae8..000000000 --- a/.codexclaw/goalplans/opencodex-live-unfinished-issues-and-prs-triage/ledger.jsonl +++ /dev/null @@ -1,24 +0,0 @@ -{"ts":"2026-07-27T10:35:02.725Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"created","detail":"init objective=\"OpenCodex live unfinished issues and PRs triage against current GitHub state. Scope: repository lidge-jun/opencodex, dev target only, worktree . First work-phase is docs-only manifest/devlog: fetch live open PRs and open issues via gh, record current number/title/state/base/head/mergeable/checks/reviews/labels, classify every item into merge now, takeover-fix, comment/request-changes, needs-author-rebase, needs-human/security, later/enhancement, upstream-tracking, or close, and produce a priority table. Later work-phases process exactly one PR or one issue per full PABCD cycle. Allowed: directly fix bugfix/simple safe items on dev, create PRs, wait for CI, squash merge, and close linked issues when live evidence proves safety. Out of scope: main/preview/release branches, automatic merge of security/auth/permission/data-migration/privilege-boundary work, and unapproved GUI/UX decisions except the approved OpenRouter Free separate-provider direction. Terminal outcomes: DONE when all safe live items are processed and manifest evidence is current; NOOP when an item needs no action after live check; NEEDS_HUMAN or UNSAFE for risk-bound/security/UX-decision items; BLOCKED for external author/rebase/CI or upstream dependency; BUDGET_EXHAUSTED only if explicit runtime bounds are hit. Verification: gh live snapshots, code/diff review for candidate PRs, CI/check URLs where actions occur, comments/merge/close URLs for external state changes, and devlog evidence committed locally before completion.\" criteria=0"} -{"ts":"2026-07-27T10:46:14.884Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP0"} -{"ts":"2026-07-27T10:46:14.884Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP1"} -{"ts":"2026-07-27T11:01:22.413Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP1"} -{"ts":"2026-07-27T11:01:22.413Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T11:11:58.894Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP9"} -{"ts":"2026-07-27T11:11:58.894Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T11:46:27.214Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP10"} -{"ts":"2026-07-27T11:46:27.214Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T11:47:00.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP11"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"task_done","detail":"WP11-T1 rebased codex/catalog-written-signal onto origin/dev@7c74e0a22ec96dd5849d3d7253758f0ab15d9737"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"task_done","detail":"WP11-T2 verified locally and pushed ce716cc117ab23e4420c8c9fe860959968f66cdc"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"task_done","detail":"WP11-T3 hosted checks passed and PR #526 squash-merged as 9dd3c42dae2e7feda3581c6d477cf5a0d6e646bf"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"criterion_met","detail":"C-WP11-PR526-FINAL evidence in devlog/_plan/260727_wp11-pr526-final-rerun-merge/011_phase1_evidence.md"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP11"} -{"ts":"2026-07-27T12:05:44.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP2"} -{"ts":"2026-07-27T12:08:50.093Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP11"} -{"ts":"2026-07-27T12:08:50.093Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP3"} -{"ts":"2026-07-27T12:09:30.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"steering","detail":"corrected next active work-phase from WP3 to WP6 because PR #527 explicitly depends on the PR #526 decision; WP2 and WP3 remain pending"} -{"ts":"2026-07-27T12:09:30.000Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP6"} -{"ts":"2026-07-27T12:16:35.216Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP6"} -{"ts":"2026-07-27T12:16:35.216Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP7"} -{"ts":"2026-07-27T12:25:15.900Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_done","detail":"closed WP7"} -{"ts":"2026-07-27T12:25:15.900Z","slug":"opencodex-live-unfinished-issues-and-prs-triage","event":"workphase_started","detail":"started WP8"} diff --git a/.gitignore b/.gitignore index 8ee0b5d9c..e35cc4ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,14 @@ devlog/ .opencode/ # Local agent/session artifacts +# These are per-machine agent state (goalplans, ledgers, evidence scratch). +# They are never part of the product and must not be committed, not even with +# `git add -f` — see tests/repo-hygiene.test.ts, which fails if any path here +# becomes tracked again. .codexclaw/ +**/.codexclaw/ .omo/ +**/.omo/ # Test-generated artifacts tests/.tmp-*/ diff --git a/devlog/.DS_Store b/devlog/.DS_Store deleted file mode 100644 index 9ac38fcbe91ac0275aa8957e474efc69401ffed6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK&5qMB5FU36n`(vVftB`XiECH%XW13nOH_7WfFL*kDha6|BG^^Z&_h)zXLuo= zgy-3BY$O6vc6@DvZ$BC z;dj+J=-#<|7#&7O(VOIhT_wwO+0@gtdc{xAtu3>yon|kJ`D)gG@YI@RT9|p|5{i6| zkk>B@liStQHYTrKZsI$lUeufQPuACl_{-xk1QVXKA2d57lW_dI)+YRHB?cJ=jxZ}?VN(b zfG{8o>=y&!gp7{%>$T)kifUQS&bf9r10I&@L*GJKIIi`0n*zgK#fasr_yp<%e$56j U4%m8x2O>WLjs|Ijfq%-t4e(K28~^|S diff --git a/devlog/_plan/.DS_Store b/devlog/_plan/.DS_Store deleted file mode 100644 index 3a1ad0021f9ebeed0b59a514a836fce018b0ef71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK!D`z;5S?|LMkW+|NTEFh1ic2TxQRo^MYzciL?J!2MU^U(iYVG($=1ObbZ>qr zKhp2%o85IvaB?cNrp&?Gq7R1eXjp!&)@$aCrM8j5C;A$ z22}SnIvwGaTy0%>Ij*%n^edEw<64UkDKJbaMl6@&*H9zy`)mM1hpk0;Ao3$%Xplx2 I_^S;30vo_u@c;k- diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c55b544fb..5902c5b29 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -978,7 +978,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ authKind: "key", dashboardUrl: "https://ollama.com/settings/keys", // Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15. - // Evidence: .codexclaw/evidence/260710_wp9_ollama_cloud_model_ids.md. models: ["glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"], defaultModel: "glm-5.2", noVisionModels: [ diff --git a/tests/repo-hygiene.test.ts b/tests/repo-hygiene.test.ts new file mode 100644 index 000000000..07fcae065 --- /dev/null +++ b/tests/repo-hygiene.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Local agent/session state must never reach a commit. + * + * `.gitignore` alone does not enforce this: `git add -f` overrides it silently, + * and once a path is tracked the ignore rule stops applying to it entirely. The + * `.codexclaw/` goalplans and ledgers were committed exactly that way and rode + * along into `main` and `preview` before anyone noticed. + * + * This test closes that gap by asserting against the real index instead of the + * ignore file, so a forced add fails CI on the commit that introduces it. + */ +const FORBIDDEN_TRACKED_DIRS = [".codexclaw", ".omo", ".claude", "node_modules", ".tmp"]; + +const FORBIDDEN_TRACKED_FILENAMES = [".DS_Store", "Thumbs.db"]; + +function trackedFiles(): string[] { + const result = Bun.spawnSync(["git", "ls-files"], { cwd: repoRoot }); + if (result.exitCode !== 0) { + throw new Error(`git ls-files failed: ${new TextDecoder().decode(result.stderr)}`); + } + return new TextDecoder() + .decode(result.stdout) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +describe("repository hygiene", () => { + test("no local agent or session state is tracked", () => { + const offenders = trackedFiles().filter((path) => + path.split("/").some((segment) => FORBIDDEN_TRACKED_DIRS.includes(segment)), + ); + + expect(offenders).toEqual([]); + }); + + test("no OS metadata files are tracked", () => { + const offenders = trackedFiles().filter((path) => + FORBIDDEN_TRACKED_FILENAMES.includes(path.split("/").pop() ?? ""), + ); + + expect(offenders).toEqual([]); + }); + + test("gitignore still declares the agent-state directories", async () => { + const ignore = await Bun.file(new URL("../.gitignore", import.meta.url)).text(); + + for (const dir of FORBIDDEN_TRACKED_DIRS) { + expect(ignore).toContain(`${dir}/`); + } + }); +}); From f195e90bcbd0fca8e5302ddc147f321d26f5b15d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:18:30 +0200 Subject: [PATCH 010/129] feat(cli): add ocx opencode launcher (#568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add `ocx opencode` launcher opencode reads providers from a JSON config rather than env slots, so the `ocx claude` env-injection pattern does not transfer. This adds a launcher that generates a provider block from the proxy's visible catalog and points OPENCODE_CONFIG at it. The user's own opencode.json is never written to. Their effective config is read (explicit OPENCODE_CONFIG first, then the XDG global path), merged forward into a generated copy under the opencodex config dir, and only the `opencodex` provider key is overwritten. Carrying the base config forward keeps the command correct whether opencode merges the OPENCODE_CONFIG layer or replaces it, and leaves plain `opencode` completely unchanged. Credential handling: the generated file is written to disk and outlives the child, so it carries opencode's documented `{env:VAR}` reference instead of the admission key, and the real value is passed only through the child environment. The key resolves OPENCODEX_API_AUTH_TOKEN before config.apiKeys, matching fetchClaudeContextWindows — a non-loopback bind requires the env token and may have no apiKeys at all, where a placeholder would 401 every request. Model limits: limit.context is emitted only from an authoritative context window, including native slugs via nativeOpenAiContextWindow. opencode's schema rejects a limit block carrying context without output and CatalogModel has no authoritative output field, so a documented budget rides along, clamped to the context window so a small-context model is never emitted with output > context. Robustness: opencode.json is parsed as JSONC (strict JSON first, tolerant only on failure) because opencode documents that syntax and a commented config would otherwise be rejected as malformed; the generated file is written atomically so a concurrent launch cannot read a torn file; the detached proxy-start child gets an error listener so a failed spawn reports through the health poll instead of throwing; and a project-level opencode.json defining provider.opencodex is detected and warned about, since opencode loads that layer last and it outranks the generated block. Verified against a live proxy: 76 models registered, 47 carrying authoritative limits with no output > context violations, `opencode models opencodex` lists all of them, and an end-to-end run through opencodex/kiro/claude-haiku-4.5 returns a completion. * fix(cli): harden ocx opencode launcher review follow-ups Merge inherited OPENCODE_CONFIG_CONTENT instead of replacing it, probe the live proxy hostname for baseURL, resolve admission from env/service token/ config keys, add x-opencodex-api-key on non-loopback binds, omit native slugs in Codex Direct mode, and widen global override detection. Stabilize the Windows storage-policy concurrency test timing. * fix(cli): pass service token file to ocx opencode auto-start When ocx opencode spawns a detached ocx start and OPENCODEX_API_AUTH_TOKEN is absent, propagate OCX_API_TOKEN_FILE (or the default hardened path) so handleStart can load the service token before a non-loopback bind. * fix(cli): address CodeRabbit opencode review follow-ups Use a deterministic loopback default config in provider-block helpers instead of loadConfig(), send proxy admission via apiKey on loopback and only x-opencodex-api-key on non-loopback binds, and split the docs/help examples accordingly. * fix(cli): build ocx opencode catalog from proxy /api/models Fetch the live model list from the running proxy instead of calling fetchAllModels in the CLI process, so env-backed provider keys resolve in the proxy environment and namespaced selectors carry display metadata. * fix(cli): bound ocx opencode /api/models fetch deadline Abort stalled proxy catalog requests after 8s so ocx opencode fails fast instead of hanging before OpenCode launches. --------- Co-authored-by: mihneaptu <223163739+mihneaptu@users.noreply.github.com> --- docs-site/astro.config.mjs | 1 + docs-site/src/content/docs/guides/opencode.md | 106 +++ src/cli/help.ts | 15 + src/cli/index.ts | 4 + src/cli/opencode.ts | 701 ++++++++++++++++++ tests/api-storage-policy.test.ts | 4 +- tests/opencode-cli.test.ts | 570 ++++++++++++++ 7 files changed, 1399 insertions(+), 2 deletions(-) create mode 100644 docs-site/src/content/docs/guides/opencode.md create mode 100644 src/cli/opencode.ts create mode 100644 tests/opencode-cli.test.ts diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 01b6c508f..f86b7ea19 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -88,6 +88,7 @@ export default defineConfig({ { label: "Model Ordering", translations: { ko: "모델 정렬에 관하여", "zh-CN": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順" }, slug: "guides/model-ordering" }, { label: "Claude Code", translations: { ko: "Claude Code", "zh-CN": "Claude Code", ru: "Claude Code", ja: "Claude Code" }, slug: "guides/claude-code" }, { label: "Grok Build", translations: { ko: "Grok Build", "zh-CN": "Grok Build", ru: "Grok Build", ja: "Grok Build" }, slug: "guides/grok-build" }, + { label: "opencode", translations: { ko: "opencode", "zh-CN": "opencode", ru: "opencode", ja: "opencode" }, slug: "guides/opencode" }, { label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン" }, slug: "guides/sidecars" }, { label: "Web Dashboard", translations: { ko: "웹 대시보드", "zh-CN": "网页控制台", ru: "Веб-дашборд", ja: "ウェブダッシュボード" }, slug: "guides/web-dashboard" }, { label: "Sub-agent Surface", translations: { ko: "서브에이전트 서피스", "zh-CN": "子代理界面", ru: "Интерфейс подагентов", ja: "サブエージェントサーフェス" }, slug: "guides/sub-agent-surface" }, diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md new file mode 100644 index 000000000..c54be6956 --- /dev/null +++ b/docs-site/src/content/docs/guides/opencode.md @@ -0,0 +1,106 @@ +--- +title: opencode +description: Use any routed model from opencode — opencodex injects a runtime provider block and leaves your own opencode config untouched. +--- + +opencode reads its providers from merged JSON config layers rather than environment +variables, so there is no `ANTHROPIC_BASE_URL`-style slot to inject. `ocx opencode` +bridges that gap: it ensures the proxy is running, builds a provider block from the +visible catalog, and injects it through OpenCode's inline runtime layer +(`OPENCODE_CONFIG_CONTENT`). + +## Quickstart + +```bash +ocx opencode +``` + +This ensures the proxy is running and launches opencode with only the generated +`provider.opencodex` block injected for that process. Extra arguments pass through: +`ocx opencode run "hello"`. + +Routed models appear in the picker under the `opencodex` provider: + +```text +opencodex/kiro/glm-5 +opencodex/gpt-5.6-sol # native slugs stay unprefixed +``` + +## Your own config is never modified + +The launcher does not copy or rewrite `~/.config/opencode/opencode.json`, +project `opencode.json` / `opencode.jsonc`, or any other on-disk config layer. It may +read global or project config to detect a `provider.opencodex` override, while your +existing providers, agents, keybinds, MCP entries, and relative `{file:…}` references +keep resolving from their original files. + +For this launch only, opencodex adds the generated `provider.opencodex` block through +OpenCode's inline runtime layer. That layer merges after global/custom/project config +and overrides only conflicting keys for the child process. + +| Layer | Behavior with `ocx opencode` | +| --- | --- | +| Global / custom / project config | Left on disk exactly as you wrote it | +| Inline runtime (`OPENCODE_CONFIG_CONTENT`) | Receives only the generated `provider.opencodex` block | +| Relative `{file:…}` paths | Still resolve against the config file that originally defined them | + +If a global or project config also defines `provider.opencodex`, the launcher prints an +informational note: the runtime layer from `ocx opencode` overrides it for that launch. + +## The admission key is not written to disk + +When the proxy requires an API key, the inline runtime config carries opencode's +`{env:…}` reference rather than the secret. Loopback binds use that reference as +`apiKey`; non-loopback binds send it only through `x-opencodex-api-key` so proxy +admission stays separate from any upstream `Authorization` header. + +Loopback example: + +```json +"options": { + "baseURL": "http://127.0.0.1:10100/v1", + "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}" +} +``` + +Non-loopback example: + +```json +"options": { + "baseURL": "http://192.168.1.10:10100/v1", + "headers": { + "x-opencodex-api-key": "{env:OPENCODEX_OPENCODE_API_KEY}" + } +} +``` + +The real value is passed only through the child process environment. +`OPENCODEX_API_AUTH_TOKEN` takes precedence, then the hardened service token file, then +a configured API key — which is what a non-loopback bind requires. + +## Reverting + +Nothing to undo — no generated config file is written under `~/.opencodex`. Run plain +`opencode` and it reads your own config exactly as before. + +## Model limits + +`limit.context` is written only when the catalog reports an authoritative context window; when it +does not, the whole `limit` block is omitted and opencode keeps its own defaults. + +opencode's schema rejects a `limit` block carrying `context` without `output`, and the catalog has +no authoritative per-model output field, so an `output` budget of `32000` is emitted alongside it, +clamped down to the context window so a small-context model is never given `output > context`. +That figure exists to satisfy the schema — it is not a claim about any specific model's true +maximum. + +The `opencodex` provider block is regenerated on every launch, so per-model tweaks made inside it +will not survive. Keep custom entries under a provider key of your own instead. + +## Requirements + +opencode must be installed and on `PATH`: + +```bash +npm install -g opencode-ai +``` diff --git a/src/cli/help.ts b/src/cli/help.ts index 7933147c3..95f2d67d6 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -184,6 +184,20 @@ const helpEntries: Record = { "Claude Code settings: ocx claude config ...", ], }, + opencode: { + usage: "ocx opencode [opencode args...]", + summary: "Launch opencode wired to the proxy (runtime provider config).", + details: [ + "Ensures the proxy is running, then execs `opencode` with the generated `provider.opencodex`", + "block injected through OpenCode's inline runtime layer (`OPENCODE_CONFIG_CONTENT`). Any", + "existing inline config in the environment is preserved and only `provider.opencodex` is", + "overwritten for this launch.", + "Global/project opencode.json may be read to warn about an existing provider.opencodex", + "override; on-disk files are never modified.", + "Routed models appear in the model picker as opencodex//.", + "Stop using `ocx opencode` and plain `opencode` behaves exactly as before.", + ], + }, restart: { usage: "ocx restart", summary: "Stop the proxy and restart it (background). Equivalent to stop + ensure.", @@ -256,6 +270,7 @@ Usage: ocx config Validated configuration show/get/set/import/export ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile + ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config) ocx help [command] Show help ocx --version | -v Print version diff --git a/src/cli/index.ts b/src/cli/index.ts index 9c27a7701..196e52be8 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1005,6 +1005,10 @@ switch (command) { break; } process.exit(await cmdClaude(args.slice(1))); + } + case "opencode": { + const { cmdOpencode } = await import("./opencode"); + process.exit(await cmdOpencode(args.slice(1))); } case "help": case "--help": diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts new file mode 100644 index 000000000..966985087 --- /dev/null +++ b/src/cli/opencode.ts @@ -0,0 +1,701 @@ +/** + * `ocx opencode [opencode args...]` — launch opencode wired to the local proxy. + * + * Mirrors `ocx claude` (src/cli/claude.ts): ensure the proxy is running, then exec the + * client with stdio inherited. The wiring channel differs — opencode reads providers + * from merged JSON config layers rather than env slots. + * + * The launcher never copies or rewrites the user's opencode config files. It may read + * global/project config to detect an existing `provider.opencodex` override, then injects + * only the generated provider block through OpenCode's inline runtime layer + * (`OPENCODE_CONFIG_CONTENT`), which outranks project/global/custom config and avoids + * duplicating API keys, MCP credentials, or breaking relative `{file:…}` paths. + * + * The admission key is never serialized into that inline config. The provider block + * carries opencode's documented `{env:VAR}` reference and the real value is passed + * only through the child process environment. + */ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { loadConfig } from "../config"; +import { visibleNativeSlugs } from "../codex/catalog"; +import { shouldInjectApiAuthHeader } from "../codex/inject"; +import { commandInvocation } from "../lib/win-exec"; +import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets"; +import { providerCodexAccountMode } from "../providers/registry"; +import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; +import type { OcxConfig } from "../types"; + +export interface OpencodeLaunchEnv { + [key: string]: string | undefined; +} + +/** One proxy-routed model destined for the generated provider block. */ +export interface OpencodeRoutedModel { + provider: string; + id: string; + /** Authoritative context window (CatalogModel.contextWindow); optional. */ + contextWindow?: number; + /** Authoritative display label (CatalogModel.displayName); optional. */ + displayName?: string; +} + +/** Row shape from authenticated GET /api/models on the running proxy. */ +export interface OpencodeProxyModelRow { + provider?: string; + id?: string; + namespaced?: string; + native?: boolean; + disabled?: boolean; + displayName?: string; + contextWindow?: number; +} + +/** Visible catalog entry keyed by the proxy's canonical namespaced selector. */ +export interface OpencodeCatalogModel { + namespaced: string; + native?: boolean; + provider?: string; + id?: string; + contextWindow?: number; + displayName?: string; +} + +export interface OpencodeModelEntry { + name: string; + limit?: { context: number; output: number }; +} + +export interface OpencodeProviderBlock { + npm: string; + name: string; + options: { + baseURL: string; + apiKey?: string; + headers?: Record; + }; + models: Record; +} + +export interface OpencodeGeneratedConfig { + $schema: string; + provider: Record; +} + +/** Provider key owned by this launcher; the only key it ever injects at runtime. */ +export const OPENCODE_PROVIDER_ID = "opencodex"; + +const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json"; + +const PROJECT_CONFIG_FILENAMES = ["opencode.json", "opencode.jsonc"] as const; + +/** + * OpenCode's inline runtime config layer. It merges after project/global/custom config + * and carries only the generated provider block for this launch. + */ +export const OPENCODE_CONFIG_CONTENT_ENV = "OPENCODE_CONFIG_CONTENT"; + +/** + * The proxy speaks the OpenAI-compatible shape at /v1, which opencode reaches through + * the AI SDK's openai-compatible package (the same wiring users hand-write today). + */ +const OPENCODE_PROVIDER_NPM = "@ai-sdk/openai-compatible"; + +/** + * Env var carrying the proxy admission key to the child. The inline config only ever + * holds the `{env:...}` reference, so the secret never lands on disk (AGENTS.md treats + * token serialization as a release blocker). opencode substitutes it at load time. + */ +export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY"; + +/** + * opencode's config schema rejects a `limit` block that carries `context` without + * `output`, but CatalogModel has no authoritative per-model output field. Dropping + * `limit` entirely would also throw away the authoritative context window we DO have, + * so the block is emitted with this budget standing in for the missing half. + * + * The value matches REASONING_MAX_TOKENS_CEILING in src/adapters/anthropic.ts — the + * project's existing "safe ceiling across current models" figure. It is a ceiling for + * schema validity, NOT a claim about any specific model's true maximum, and it is + * clamped to the context window so a small-context model can never be emitted with + * output > context. + */ +export const SCHEMA_REQUIRED_OUTPUT_BUDGET = 32_000; + +/** Deterministic loopback default for exported provider-block helpers in tests. */ +export const OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Strip `//` and block comments outside string literals. Escape-aware so a quote inside + * an escaped sequence cannot flip string state and expose config text to the stripper. + */ +function stripJsonComments(text: string): string { + let out = ""; + let inString = false; + let inLine = false; + let inBlock = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + const next = text[i + 1]; + if (inLine) { + if (ch === "\n") { + inLine = false; + out += ch; + } + continue; + } + if (inBlock) { + // Newlines are preserved so JSON.parse error positions stay meaningful. + if (ch === "\n") out += ch; + else if (ch === "*" && next === "/") { inBlock = false; i++; } + continue; + } + if (inString) { + out += ch; + if (ch === "\\") { + const escaped = text[i + 1]; + if (escaped !== undefined) { out += escaped; i++; } + continue; + } + if (ch === "\"") inString = false; + continue; + } + if (ch === "\"") { inString = true; out += ch; continue; } + if (ch === "/" && next === "/") { inLine = true; i++; continue; } + if (ch === "/" && next === "*") { inBlock = true; i++; continue; } + out += ch; + } + return out; +} + +/** Drop commas that sit directly before `}` or `]`, ignoring string contents. */ +function stripTrailingCommas(text: string): string { + let out = ""; + let inString = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (inString) { + out += ch; + if (ch === "\\") { + const escaped = text[i + 1]; + if (escaped !== undefined) { out += escaped; i++; } + continue; + } + if (ch === "\"") inString = false; + continue; + } + if (ch === "\"") { inString = true; out += ch; continue; } + if (ch === ",") { + let j = i + 1; + while (j < text.length && /\s/.test(text[j]!)) j++; + if (text[j] === "}" || text[j] === "]") continue; + } + out += ch; + } + return out; +} + +/** + * opencode documents opencode.json as JSONC, so a valid user config may carry comments + * or trailing commas. Strict JSON.parse runs first and untouched — the tolerant path is + * only attempted when that throws, keeping well-formed configs away from the stripper. + */ +export function parseJsonc(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return JSON.parse(stripTrailingCommas(stripJsonComments(text))); + } +} + +/** + * Resolve the user's global opencode config path. opencode uses the XDG layout on every + * platform (including Windows, where it is %USERPROFILE%\.config\opencode). + */ +export function opencodeGlobalConfigPath( + env: OpencodeLaunchEnv = process.env, + home: string = homedir(), +): string { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0 ? env.XDG_CONFIG_HOME : join(home, ".config"); + return join(xdg, "opencode", "opencode.json"); +} + +/** Model key as the proxy routes it: `provider/id` for routed models, bare slug for native OpenAI entries. */ +export function opencodeModelKey(provider: string, id: string): string { + return provider === "native" ? id : `${provider}/${id}`; +} + +/** Compose the OpenAI-compatible proxy base URL from a live probe result. */ +export function opencodeProxyBaseUrl(port: number, hostname?: string): string { + return `http://${probeHostname(hostname)}:${port}/v1`; +} + +/** Env reference shared by apiKey and the dedicated proxy admission header. */ +export const OPENCODE_API_KEY_ENV_REF = `{env:${OPENCODE_API_KEY_ENV}}`; + +/** + * Native OpenAI slugs advertised to opencode. Omitted in Codex Direct mode because native + * chat-completions require the caller's real ChatGPT OAuth bearer, not proxy admission. + */ +export function opencodeLaunchNativeSlugs(config: OcxConfig): string[] { + if (providerCodexAccountMode("openai", config.providers?.openai) === "direct") return []; + return [...visibleNativeSlugs(config)]; +} + +function opencodeProviderOptions(baseURL: string, config: OcxConfig): OpencodeProviderBlock["options"] { + const options: OpencodeProviderBlock["options"] = { baseURL }; + // Non-loopback binds accept proxy admission only via x-opencodex-api-key so Authorization + // stays free for Codex Direct upstream credentials when applicable. + if (shouldInjectApiAuthHeader(config)) { + options.headers = { "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF }; + return options; + } + options.apiKey = OPENCODE_API_KEY_ENV_REF; + return options; +} + +function opencodeModelEntryLabel(model: OpencodeCatalogModel): string { + const providerLabel = model.native ? "native" : (model.provider ?? "routed"); + const id = model.id ?? model.namespaced; + if (model.displayName && model.displayName.length > 0) { + return `${model.displayName} (${providerLabel})`; + } + return `${id} (${providerLabel})`; +} + +/** + * Build the `opencodex` provider block from proxy catalog rows keyed by each row's + * canonical `namespaced` selector. + * + * `limit.context` is emitted ONLY from an authoritative context window — never guessed. + * When none is available the whole `limit` block is dropped and opencode keeps its own + * defaults; when one is present, `limit.output` rides along (opencode's schema requires + * the pair) clamped to the context window. + */ +export function buildOpencodeProviderBlockFromCatalog( + port: number, + catalogModels: readonly OpencodeCatalogModel[], + hostname?: string, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeProviderBlock { + const models: Record = {}; + for (const model of catalogModels) { + const key = model.namespaced; + if (models[key]) continue; // first entry wins; native rows lead /api/models + const entry: OpencodeModelEntry = { name: opencodeModelEntryLabel(model) }; + const { contextWindow } = model; + if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { + const context = Math.floor(contextWindow); + entry.limit = { context, output: Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context) }; + } + models[key] = entry; + } + return { + npm: OPENCODE_PROVIDER_NPM, + name: "OpenCodex", + options: opencodeProviderOptions(opencodeProxyBaseUrl(port, hostname), config), + models, + }; +} + +/** Back-compat helper for unit tests that assemble slugs/routed rows directly. */ +export function buildOpencodeProviderBlock( + port: number, + nativeSlugs: readonly string[], + routedModels: readonly OpencodeRoutedModel[], + nativeContextWindow: (slug: string) => number | undefined = () => undefined, + hostname?: string, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeProviderBlock { + const catalog: OpencodeCatalogModel[] = [ + ...nativeSlugs.map(id => ({ + namespaced: id, + native: true, + provider: "openai", + id, + contextWindow: nativeContextWindow(id), + })), + ...routedModels.map(model => ({ + namespaced: opencodeModelKey(model.provider, model.id), + native: false, + provider: model.provider, + id: model.id, + contextWindow: model.contextWindow, + displayName: model.displayName, + })), + ]; + return buildOpencodeProviderBlockFromCatalog(port, catalog, hostname, config); +} + +/** Default deadline for authenticated GET /api/models during `ocx opencode` launch. */ +export const OPENCODE_PROXY_MODELS_TIMEOUT_MS = 8_000; + +/** Fetch the live model catalog from a running proxy's management API. */ +export async function fetchOpencodeProxyModels( + live: LiveProxy, + apiKey: string, + deps: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}, +): Promise { + const baseUrl = `http://${probeHostname(live.hostname)}:${live.port}`; + const fetchImpl = deps.fetchImpl ?? fetch; + const headers = new Headers({ Accept: "application/json" }); + const token = apiKey.trim(); + if (token) headers.set("X-OpenCodex-API-Key", token); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), deps.timeoutMs ?? OPENCODE_PROXY_MODELS_TIMEOUT_MS); + const abortIfTimedOut = (): Promise => new Promise((_, reject) => { + if (controller.signal.aborted) { + reject(new DOMException("The operation was aborted.", "AbortError")); + return; + } + controller.signal.addEventListener( + "abort", + () => reject(new DOMException("The operation was aborted.", "AbortError")), + { once: true }, + ); + }); + + let response: Response; + let text: string; + try { + response = await Promise.race([ + fetchImpl(`${baseUrl}/api/models`, { + headers, + signal: controller.signal, + }), + abortIfTimedOut(), + ]); + text = await Promise.race([response.text(), abortIfTimedOut()]); + } catch (error) { + const timedOut = error instanceof Error && error.name === "AbortError"; + throw new Error( + timedOut + ? "Management API timed out while fetching /api/models." + : `Management API is unreachable: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + clearTimeout(timeout); + } + let body: unknown = null; + if (text) { + try { body = JSON.parse(text); } + catch { body = text; } + } + if (!response.ok) { + const message = body && typeof body === "object" && typeof (body as Record).error === "string" + ? (body as Record).error + : `Management request failed (${response.status})`; + throw new Error(message); + } + if (!Array.isArray(body)) { + throw new Error("Management API returned an unexpected /api/models payload."); + } + return body as OpencodeProxyModelRow[]; +} + +/** + * Visible OpenCode catalog entries from proxy /api/models rows. Disabled rows are omitted; + * native rows are omitted in Codex Direct mode. + */ +export function opencodeCatalogFromProxyRows( + rows: readonly OpencodeProxyModelRow[], + config: OcxConfig, +): OpencodeCatalogModel[] { + const omitNative = providerCodexAccountMode("openai", config.providers?.openai) === "direct"; + const seen = new Set(); + const catalog: OpencodeCatalogModel[] = []; + for (const row of rows) { + const namespaced = row.namespaced?.trim(); + if (!namespaced || row.disabled === true) continue; + if (omitNative && row.native === true) continue; + if (seen.has(namespaced)) continue; + seen.add(namespaced); + catalog.push({ + namespaced, + native: row.native === true, + provider: row.provider, + id: row.id, + contextWindow: row.contextWindow, + displayName: row.displayName, + }); + } + return catalog; +} + +export type OpencodeRuntimeConfigError = { error: string }; + +/** True when mergeOpencodeRuntimeConfig rejected inherited inline config. */ +export function isOpencodeRuntimeConfigError( + value: OpencodeGeneratedConfig | OpencodeRuntimeConfigError, +): value is OpencodeRuntimeConfigError { + return "error" in value; +} + +/** + * Merge inherited `OPENCODE_CONFIG_CONTENT` and override only `provider.opencodex`. + * When no inline layer is present, emit the minimal runtime object for this launcher. + */ +export function mergeOpencodeRuntimeConfig( + inheritedContent: string | undefined, + providerBlock: OpencodeProviderBlock, +): OpencodeGeneratedConfig | OpencodeRuntimeConfigError { + if (!inheritedContent?.trim()) { + return { + $schema: OPENCODE_CONFIG_SCHEMA, + provider: { [OPENCODE_PROVIDER_ID]: providerBlock }, + }; + } + let parsed: unknown; + try { + parsed = JSON.parse(inheritedContent); + } catch { + return { error: "OPENCODE_CONFIG_CONTENT is not valid JSON." }; + } + if (!isRecord(parsed)) { + return { error: "OPENCODE_CONFIG_CONTENT must be a JSON object." }; + } + const existingProvider = parsed.provider; + if (existingProvider !== undefined && !isRecord(existingProvider)) { + return { error: "OPENCODE_CONFIG_CONTENT provider must be a JSON object when present." }; + } + return { + ...parsed, + $schema: typeof parsed.$schema === "string" ? parsed.$schema : OPENCODE_CONFIG_SCHEMA, + provider: { + ...(isRecord(existingProvider) ? existingProvider : {}), + [OPENCODE_PROVIDER_ID]: providerBlock, + }, + } as OpencodeGeneratedConfig; +} + +/** Inline runtime config carrying only the provider block this launcher owns. */ +export function buildOpencodeConfig( + port: number, + nativeSlugs: readonly string[], + routedModels: readonly OpencodeRoutedModel[], + nativeContextWindow: (slug: string) => number | undefined = () => undefined, + hostname?: string, + config: OcxConfig = OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, +): OpencodeGeneratedConfig { + const merged = mergeOpencodeRuntimeConfig( + undefined, + buildOpencodeProviderBlock(port, nativeSlugs, routedModels, nativeContextWindow, hostname, config), + ); + if (isOpencodeRuntimeConfigError(merged)) { + throw new Error(merged.error); + } + return merged; +} + +/** Serialize the inline runtime config OpenCode merges on launch. */ +export function serializeOpencodeRuntimeConfig(config: OpencodeGeneratedConfig): string { + return JSON.stringify(config); +} + +function findGitRoot(start: string): string | null { + let dir = start; + while (true) { + if (existsSync(join(dir, ".git"))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function configFileDefinesProvider(path: string): boolean { + if (!existsSync(path)) return false; + try { + const parsed = parseJsonc(readFileSync(path, "utf8")); + return isRecord(parsed) && isRecord(parsed.provider) && OPENCODE_PROVIDER_ID in parsed.provider; + } catch { + return false; + } +} + +/** + * Detect global or project-level opencode.json/jsonc that defines our provider key. + * Informational only: the inline runtime layer from `OPENCODE_CONFIG_CONTENT` outranks both. + */ +export function opencodeProviderOverridePath( + cwd: string, + env: OpencodeLaunchEnv = process.env, + home: string = homedir(), +): string | null { + const globalPath = opencodeGlobalConfigPath(env, home); + if (configFileDefinesProvider(globalPath)) return globalPath; + + const gitRoot = findGitRoot(cwd); + let dir = cwd; + while (true) { + for (const name of PROJECT_CONFIG_FILENAMES) { + const candidate = join(dir, name); + if (configFileDefinesProvider(candidate)) return candidate; + } + if (gitRoot && dir === gitRoot) break; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +/** @deprecated Use {@link opencodeProviderOverridePath}. */ +export function projectConfigOverridesProvider(cwd: string): string | null { + return opencodeProviderOverridePath(cwd); +} + +function serviceTokenLookupEnv(env: OpencodeLaunchEnv): OpencodeLaunchEnv { + if (env.OCX_API_TOKEN_FILE?.trim()) return env; + return { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() }; +} + +/** + * Child env for a detached `ocx start` from `ocx opencode`. When the admission token is + * not already in the environment, pass through an existing `OCX_API_TOKEN_FILE` or the + * default hardened service token path so `handleStart` can load it before bind. + */ +export function opencodeProxyStartEnv(base: OpencodeLaunchEnv = process.env): OpencodeLaunchEnv { + const withTokenFile = base.OPENCODEX_API_AUTH_TOKEN?.trim() + ? base + : serviceTokenLookupEnv(base); + return { ...withTokenFile, OCX_SERVICE: "1" }; +} + +/** + * Env assembly (unit-tested). Inherited inline config is merged and only + * `provider.opencodex` is replaced; disk config layers stay untouched. The admission + * key travels in the child env rather than in the inline config payload. + */ +export function buildOpencodeEnv( + providerBlock: OpencodeProviderBlock, + apiKey: string, + base: OpencodeLaunchEnv, +): OpencodeLaunchEnv | OpencodeRuntimeConfigError { + const runtimeConfig = mergeOpencodeRuntimeConfig(base[OPENCODE_CONFIG_CONTENT_ENV], providerBlock); + if (isOpencodeRuntimeConfigError(runtimeConfig)) return runtimeConfig; + return { + ...base, + [OPENCODE_CONFIG_CONTENT_ENV]: serializeOpencodeRuntimeConfig(runtimeConfig), + [OPENCODE_API_KEY_ENV]: apiKey, + }; +} + +/** + * Admission key for the proxy: env token, hardened service token file, configured API + * key, then the open-loopback placeholder. Never serialized into runtime config. + */ +export function opencodeApiKey(config: OcxConfig, env: OpencodeLaunchEnv = process.env): string { + const envToken = env.OPENCODEX_API_AUTH_TOKEN?.trim(); + if (envToken) return envToken; + const serviceToken = loadServiceTokenFromFile(serviceTokenLookupEnv(env)); + if (serviceToken) return serviceToken; + return config.apiKeys?.[0]?.key || "ocx"; +} + +async function ensureProxyForOpencode(config: OcxConfig): Promise { + const live = await findLiveProxy(); + if (live) return live; + const cfgPort = config.port; + const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; + const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(pinPort)], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: opencodeProxyStartEnv(process.env) as NodeJS.ProcessEnv, + }); + // Without a listener an 'error' (bad argv[1], EMFILE, AV denial) throws synchronously + // and kills this process; the health poll below already reports the failure properly. + child.on("error", () => { /* handled by the deadline loop returning null */ }); + child.unref(); + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const started = await findLiveProxy(); + if (started) return started; + await new Promise(resolve => setTimeout(resolve, 250)); + } + return null; +} + +const OPENCODE_INSTALL_HINT = "❌ `opencode` CLI not found. Install it first: npm install -g opencode-ai"; + +/** + * cmd.exe reports command-not-found as exit 9009 (the win32 launcher routes `.cmd` + * shims through cmd.exe, so ENOENT never fires there). Signal exits are not hints. + * Same contract as claudeNotFoundHint (devlog 260715_cross_platform_audit/020). + */ +export function opencodeNotFoundHint( + code: number | null, + signal: NodeJS.Signals | null, + platform: NodeJS.Platform = process.platform, +): string | null { + return platform === "win32" && code === 9009 && !signal ? OPENCODE_INSTALL_HINT : null; +} + +export async function cmdOpencode(args: string[]): Promise { + const config = loadConfig(); + const live = await ensureProxyForOpencode(config); + if (!live) { + console.error("❌ Proxy did not become healthy after starting."); + return 1; + } + + const apiKey = opencodeApiKey(config); + let proxyModels: OpencodeProxyModelRow[]; + try { + proxyModels = await fetchOpencodeProxyModels(live, apiKey); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`❌ Could not fetch the model catalog from the proxy: ${reason}`); + return 1; + } + const catalog = opencodeCatalogFromProxyRows(proxyModels, config); + const providerBlock = buildOpencodeProviderBlockFromCatalog( + live.port, + catalog, + live.hostname, + config, + ); + const baseUrl = providerBlock.options.baseURL; + const modelCount = catalog.length; + console.error(`✅ opencode wired to ${baseUrl} — ${modelCount} model(s) under provider \`${OPENCODE_PROVIDER_ID}\`.`); + console.error(" Your existing opencode config files are left untouched; only the runtime provider block is injected."); + const providerOverride = opencodeProviderOverridePath(process.cwd()); + if (providerOverride) { + console.error(`ℹ ${providerOverride} also defines provider.${OPENCODE_PROVIDER_ID}; the runtime layer from ocx opencode overrides it for this launch.`); + } + + const builtEnv = buildOpencodeEnv(providerBlock, apiKey, process.env); + if ("error" in builtEnv) { + console.error(`❌ ${builtEnv.error}`); + return 1; + } + const env = builtEnv; + return await new Promise(resolve => { + const inv = commandInvocation("opencode", args); + const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); + child.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") { + console.error(OPENCODE_INSTALL_HINT); + } else { + console.error(`❌ Failed to launch opencode: ${err.message}`); + } + resolve(1); + }); + child.on("exit", (code, signal) => { + const hint = opencodeNotFoundHint(code, signal); + if (hint) console.error(hint); + resolve(signal ? 1 : code ?? 0); + }); + }); +} diff --git a/tests/api-storage-policy.test.ts b/tests/api-storage-policy.test.ts index 499b078ff..ef8724766 100644 --- a/tests/api-storage-policy.test.ts +++ b/tests/api-storage-policy.test.ts @@ -283,7 +283,7 @@ describe("storage cleanup policy API", () => { }, { timeout: 30_000 }); test("blocked worker completion preserves concurrent policy PUT edits", async () => { - setStorageCleanupPolicyJobTestHooks({ blockMs: 800 }); + setStorageCleanupPolicyJobTestHooks({ blockMs: 1_200 }); seedArchived(isolatedCodexHome!.path); const server = startServer(0); try { @@ -317,7 +317,7 @@ describe("storage cleanup policy API", () => { } // Let the worker load the start-of-job snapshot, then edit during the hold window. - await Bun.sleep(120); + await Bun.sleep(450); const put = await fetch(new URL("/api/storage/cleanup-policy", server.url), { method: "PUT", diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts new file mode 100644 index 000000000..00dbad5a1 --- /dev/null +++ b/tests/opencode-cli.test.ts @@ -0,0 +1,570 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearModelCache } from "../src/codex/model-cache"; +import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../src/lib/service-secrets"; +import { + OPENCODE_API_KEY_ENV, + OPENCODE_API_KEY_ENV_REF, + OPENCODE_CONFIG_CONTENT_ENV, + OPENCODE_PROVIDER_ID, + SCHEMA_REQUIRED_OUTPUT_BUDGET, + buildOpencodeConfig, + buildOpencodeEnv, + buildOpencodeProviderBlock, + buildOpencodeProviderBlockFromCatalog, + fetchOpencodeProxyModels, + isOpencodeRuntimeConfigError, + mergeOpencodeRuntimeConfig, + opencodeApiKey, + opencodeCatalogFromProxyRows, + opencodeGlobalConfigPath, + opencodeLaunchNativeSlugs, + opencodeModelKey, + opencodeNotFoundHint, + opencodeProviderOverridePath, + opencodeProxyBaseUrl, + opencodeProxyStartEnv, + parseJsonc, + projectConfigOverridesProvider, + serializeOpencodeRuntimeConfig, +} from "../src/cli/opencode"; +import type { OcxConfig } from "../src/types"; + +function cfg(extra?: Partial): OcxConfig { + return { + port: 10100, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://x/v1" } }, + ...extra, + } as OcxConfig; +} + +describe("ocx opencode provider block", () => { + test("points at the live proxy port over the OpenAI-compatible surface", () => { + const block = buildOpencodeProviderBlock(10123, [], []); + expect(block.options.baseURL).toBe("http://127.0.0.1:10123/v1"); + expect(block.npm).toBe("@ai-sdk/openai-compatible"); + }); + + test("uses probeHostname for IPv6 and specific-interface binds", () => { + expect(buildOpencodeProviderBlock(10100, [], [], () => undefined, "::1").options.baseURL) + .toBe("http://[::1]:10100/v1"); + expect(buildOpencodeProviderBlock(10100, [], [], () => undefined, "192.168.4.10").options.baseURL) + .toBe("http://192.168.4.10:10100/v1"); + expect(opencodeProxyBaseUrl(8080, "fe80::1")).toBe("http://[fe80::1]:8080/v1"); + }); + + test("apiKey is an env reference, never a literal secret", () => { + const block = buildOpencodeProviderBlock(10100, [], []); + expect(block.options.apiKey).toBe(OPENCODE_API_KEY_ENV_REF); + expect(JSON.stringify(block)).not.toContain("sk-"); + }); + + test("non-loopback binds add x-opencodex-api-key via env reference", () => { + const block = buildOpencodeProviderBlock( + 10100, + [], + [], + () => undefined, + "0.0.0.0", + cfg({ hostname: "0.0.0.0" }), + ); + expect(block.options.headers).toEqual({ "x-opencodex-api-key": OPENCODE_API_KEY_ENV_REF }); + expect(block.options.apiKey).toBeUndefined(); + expect(JSON.stringify(block.options)).not.toContain("sk-"); + }); + + test("loopback binds use apiKey and omit the dedicated admission header", () => { + const block = buildOpencodeProviderBlock( + 10100, + [], + [], + () => undefined, + "127.0.0.1", + cfg({ hostname: "127.0.0.1" }), + ); + expect(block.options.apiKey).toBe(OPENCODE_API_KEY_ENV_REF); + expect(block.options.headers).toBeUndefined(); + }); + + test("routed models key on provider/id, native slugs stay bare", () => { + const block = buildOpencodeProviderBlock(10100, ["gpt-5.6-sol"], [ + { provider: "kiro", id: "glm-5" }, + ]); + expect(Object.keys(block.models).sort()).toEqual(["gpt-5.6-sol", "kiro/glm-5"]); + }); + + test("limit.context is emitted only from an authoritative contextWindow — never guessed", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "with-window", contextWindow: 200_000 }, + { provider: "kiro", id: "no-window" }, + { provider: "kiro", id: "zero-window", contextWindow: 0 }, + ]); + expect(block.models["kiro/with-window"]?.limit?.context).toBe(200_000); + expect(block.models["kiro/no-window"]?.limit).toBeUndefined(); + expect(block.models["kiro/zero-window"]?.limit).toBeUndefined(); + }); + + test("limit.output rides along with context because opencode's schema requires the pair", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "m", contextWindow: 200_000 }, + ]); + expect(block.models["kiro/m"]?.limit).toEqual({ context: 200_000, output: SCHEMA_REQUIRED_OUTPUT_BUDGET }); + }); + + test("limit.output is clamped to the context window for small-context models", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "local", id: "tiny", contextWindow: 8_192 }, + ]); + expect(block.models["local/tiny"]?.limit).toEqual({ context: 8_192, output: 8_192 }); + }); + + test("native slugs pick up authoritative context windows from the resolver", () => { + const block = buildOpencodeProviderBlock(10100, ["gpt-5.4", "unknown-native"], [], slug => + slug === "gpt-5.4" ? 1_000_000 : undefined); + expect(block.models["gpt-5.4"]?.limit).toEqual({ context: 1_000_000, output: SCHEMA_REQUIRED_OUTPUT_BUDGET }); + expect(block.models["unknown-native"]?.limit).toBeUndefined(); + }); + + test("displayName is used for the label when the catalog provides one", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "glm-5", displayName: "GLM-5" }, + { provider: "kiro", id: "qwen3-coder-next" }, + ]); + expect(block.models["kiro/glm-5"]?.name).toBe("GLM-5 (kiro)"); + expect(block.models["kiro/qwen3-coder-next"]?.name).toBe("qwen3-coder-next (kiro)"); + }); + + test("duplicate keys keep the first entry instead of throwing", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "dup", displayName: "First" }, + { provider: "kiro", id: "dup", displayName: "Second" }, + ]); + expect(block.models["kiro/dup"]?.name).toBe("First (kiro)"); + }); + + test("model key helper distinguishes native from routed", () => { + expect(opencodeModelKey("native", "gpt-5.6-sol")).toBe("gpt-5.6-sol"); + expect(opencodeModelKey("kiro", "glm-5")).toBe("kiro/glm-5"); + }); +}); + +describe("ocx opencode runtime config", () => { + test("serializes only the generated provider block for OPENCODE_CONFIG_CONTENT", () => { + const runtime = buildOpencodeConfig(10100, [], [{ provider: "kiro", id: "glm-5" }]); + const parsed = JSON.parse(serializeOpencodeRuntimeConfig(runtime)) as { provider?: Record }; + expect(Object.keys(parsed.provider ?? {})).toEqual([OPENCODE_PROVIDER_ID]); + expect(parsed.provider?.[OPENCODE_PROVIDER_ID]).toBeTruthy(); + }); + + test("merges inherited inline settings and overrides only provider.opencodex", () => { + const inherited = JSON.stringify({ + model: "other/default", + agents: { coder: { model: "x" } }, + provider: { + other: { npm: "@other/pkg", name: "Other" }, + [OPENCODE_PROVIDER_ID]: { npm: "stale", name: "Stale" }, + }, + }); + const block = buildOpencodeProviderBlock(10100, [], [{ provider: "kiro", id: "glm-5" }]); + const merged = mergeOpencodeRuntimeConfig(inherited, block); + expect(isOpencodeRuntimeConfigError(merged)).toBe(false); + if (isOpencodeRuntimeConfigError(merged)) return; + expect(merged.model).toBe("other/default"); + expect(merged.agents).toEqual({ coder: { model: "x" } }); + expect(merged.provider.other).toEqual({ npm: "@other/pkg", name: "Other" }); + expect(merged.provider[OPENCODE_PROVIDER_ID]).toEqual(block); + }); + + test("rejects invalid inherited OPENCODE_CONFIG_CONTENT", () => { + const block = buildOpencodeProviderBlock(10100, [], []); + expect(mergeOpencodeRuntimeConfig("{ not json", block)).toEqual({ error: "OPENCODE_CONFIG_CONTENT is not valid JSON." }); + expect(mergeOpencodeRuntimeConfig("[]", block)).toEqual({ error: "OPENCODE_CONFIG_CONTENT must be a JSON object." }); + expect(mergeOpencodeRuntimeConfig(JSON.stringify({ provider: "bad" }), block)) + .toEqual({ error: "OPENCODE_CONFIG_CONTENT provider must be a JSON object when present." }); + }); +}); + +describe("ocx opencode JSONC parsing", () => { + test("plain JSON parses unchanged", () => { + expect(parseJsonc('{"a":1}')).toEqual({ a: 1 }); + }); + + test("line and block comments are accepted", () => { + expect(parseJsonc('{\n // lead\n "a": 1 /* trail */\n}')).toEqual({ a: 1 }); + }); + + test("trailing commas are accepted", () => { + expect(parseJsonc('{"a":[1,2,],"b":2,}')).toEqual({ a: [1, 2], b: 2 }); + }); + + test("comment-like and comma-like text inside strings is preserved", () => { + expect(parseJsonc('{"url":"http://x/v1","note":"a // b /* c */","t":"x,"}')) + .toEqual({ url: "http://x/v1", note: "a // b /* c */", t: "x," }); + }); + + test("escaped quotes do not break string tracking", () => { + expect(parseJsonc('{"a":"he said \\"hi\\" // not a comment"}')) + .toEqual({ a: 'he said "hi" // not a comment' }); + }); + + test("genuinely malformed input still throws", () => { + expect(() => parseJsonc("{ not json")).toThrow(); + }); +}); + +describe("ocx opencode proxy model catalog", () => { + const ENV_KEY = "OCX_TEST_OPENCODE_PROXY_ONLY_KEY"; + const RESOLVED = "proxy-only-resolved-key"; + const PROVIDER = "proxyenv"; + + test("uses /api/models namespaced selectors and resolves env-backed provider keys only in the proxy", async () => { + const originalFetch = globalThis.fetch; + let requestedAuth: string | undefined; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const target = String(url); + if (target.includes("proxyenv.test")) { + requestedAuth = new Headers(init?.headers).get("authorization") ?? undefined; + if (requestedAuth === `Bearer ${RESOLVED}`) { + return new Response(JSON.stringify({ + data: [{ id: "live-via-proxy-env", context_length: 128_000 }], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("unauthorized", { status: 401 }); + } + return originalFetch(url, init); + }) as typeof fetch; + + const config = { + port: 10100, + defaultProvider: PROVIDER, + providers: { + [PROVIDER]: { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://proxyenv.test/v1", + apiKey: `\${${ENV_KEY}}`, + models: ["static-fallback"], + }, + }, + } as OcxConfig; + + const previous = process.env[ENV_KEY]; + delete process.env[ENV_KEY]; + clearModelCache(PROVIDER); + try { + const { fetchAllModels } = await import("../src/server/management/shared"); + const cliModels = await fetchAllModels(config); + const cliIds = cliModels.filter(m => m.provider === PROVIDER).map(m => m.id).sort(); + expect(cliIds).toEqual(["static-fallback"]); + expect(cliIds).not.toContain("live-via-proxy-env"); + + process.env[ENV_KEY] = RESOLVED; + clearModelCache(PROVIDER); + requestedAuth = undefined; + + const { handleManagementAPI } = await import("../src/server/management-api"); + const modelsRes = await handleManagementAPI( + new Request("http://localhost/api/models"), + new URL("http://localhost/api/models"), + config, + ); + const rows = await modelsRes!.json() as Array<{ + namespaced?: string; + contextWindow?: number; + }>; + expect(requestedAuth).toBe(`Bearer ${RESOLVED}`); + + const liveRow = rows.find(r => r.namespaced === `${PROVIDER}/live-via-proxy-env`); + expect(liveRow).toBeTruthy(); + expect(liveRow?.contextWindow).toBe(128_000); + + const catalog = opencodeCatalogFromProxyRows(rows, config); + expect(catalog.map(m => m.namespaced)).toContain(`${PROVIDER}/live-via-proxy-env`); + + const block = buildOpencodeProviderBlockFromCatalog(10100, catalog, undefined, config); + expect(block.models[`${PROVIDER}/live-via-proxy-env`]?.limit?.context).toBe(128_000); + expect(block.models[`${PROVIDER}/live-via-proxy-env`]?.name).toBe("live-via-proxy-env (proxyenv)"); + + const fetched = await fetchOpencodeProxyModels( + { port: 10100, hostname: "127.0.0.1", pid: 1 }, + "sk-mgmt", + { + fetchImpl: async (url, init) => { + expect(String(url)).toBe("http://127.0.0.1:10100/api/models"); + expect(new Headers(init?.headers).get("X-OpenCodex-API-Key")).toBe("sk-mgmt"); + return new Response(JSON.stringify(rows), { status: 200 }); + }, + }, + ); + expect(fetched.find(r => r.namespaced === `${PROVIDER}/live-via-proxy-env`)).toBeTruthy(); + } finally { + globalThis.fetch = originalFetch; + clearModelCache(PROVIDER); + if (previous === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = previous; + } + }); + + test("fetchOpencodeProxyModels aborts stalled /api/models fetch and body reads", async () => { + const live = { port: 10100, hostname: "127.0.0.1", pid: 1 }; + const stall = (init?: RequestInit) => new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("The operation was aborted.", "AbortError"))); + }); + + await expect(fetchOpencodeProxyModels(live, "sk-mgmt", { + timeoutMs: 25, + fetchImpl: async (_url, init) => stall(init), + })).rejects.toThrow("Management API timed out while fetching /api/models."); + + await expect(fetchOpencodeProxyModels(live, "sk-mgmt", { + timeoutMs: 25, + fetchImpl: async () => ({ + ok: true, + status: 200, + text: () => new Promise(() => {}), + } as Response), + })).rejects.toThrow("Management API timed out while fetching /api/models."); + }); + + test("opencodeCatalogFromProxyRows omits disabled and direct-mode native rows", () => { + const directConfig = cfg({ + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://x/v1" }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + }); + const rows = [ + { namespaced: "gpt-5.6-sol", native: true, disabled: false, provider: "openai", id: "gpt-5.6-sol" }, + { namespaced: "gpt-5.5", native: true, disabled: true, provider: "openai", id: "gpt-5.5" }, + { namespaced: "kiro/glm-5", native: false, disabled: false, provider: "kiro", id: "glm-5", displayName: "GLM-5" }, + ]; + expect(opencodeCatalogFromProxyRows(rows, directConfig).map(m => m.namespaced)).toEqual(["kiro/glm-5"]); + + const poolConfig = cfg({ + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://x/v1" }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + }); + expect(opencodeCatalogFromProxyRows(rows, poolConfig).map(m => m.namespaced)).toEqual([ + "gpt-5.6-sol", + "kiro/glm-5", + ]); + }); +}); + +describe("ocx opencode native slug selection", () => { + test("omits native slugs in Codex Direct mode", () => { + const config = cfg({ + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://x/v1" }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + }); + expect(opencodeLaunchNativeSlugs(config)).toEqual([]); + const block = buildOpencodeProviderBlock(10100, opencodeLaunchNativeSlugs(config), [], () => undefined, undefined, config); + expect(Object.keys(block.models)).toEqual([]); + }); + + test("keeps native slugs in pool mode", () => { + const config = cfg({ + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://x/v1" }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + }); + expect(opencodeLaunchNativeSlugs(config).length).toBeGreaterThan(0); + }); +}); + +describe("ocx opencode project-layer detection", () => { + test("detects a global config that redefines our provider key", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-opencode-global-")); + const globalDir = join(home, ".config", "opencode"); + mkdirSync(globalDir, { recursive: true }); + const globalPath = join(globalDir, "opencode.json"); + writeFileSync(globalPath, JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { npm: "x" } } })); + expect(opencodeProviderOverridePath(join(home, "project"), { XDG_CONFIG_HOME: join(home, ".config") }, home)) + .toBe(globalPath); + }); + + test("detects a project config that redefines our provider key", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); + writeFileSync(join(dir, "opencode.json"), JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { npm: "x" } } })); + expect(projectConfigOverridesProvider(dir)).toBe(join(dir, "opencode.json")); + }); + + test("detects opencode.jsonc and parent directories up to the git root", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-root-")); + mkdirSync(join(root, "packages", "app"), { recursive: true }); + mkdirSync(join(root, ".git")); + writeFileSync(join(root, "packages", "opencode.jsonc"), `{ + // project override + "provider": { "${OPENCODE_PROVIDER_ID}": { "npm": "x" } } + }`); + expect(projectConfigOverridesProvider(join(root, "packages", "app"))).toBe(join(root, "packages", "opencode.jsonc")); + }); + + test("does not walk above the git root", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-stop-")); + const parent = join(root, "parent"); + const repo = join(parent, "repo"); + mkdirSync(repo, { recursive: true }); + mkdirSync(join(repo, ".git")); + writeFileSync(join(root, "opencode.json"), JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { npm: "x" } } })); + expect(projectConfigOverridesProvider(join(repo, "src"))).toBeNull(); + }); + + test("ignores a project config that defines other providers", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); + writeFileSync(join(dir, "opencode.json"), JSON.stringify({ provider: { other: { npm: "x" } } })); + expect(projectConfigOverridesProvider(dir)).toBeNull(); + }); + + test("no project config is not a warning", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); + expect(projectConfigOverridesProvider(dir)).toBeNull(); + }); +}); + +describe("ocx opencode env assembly", () => { + test("OPENCODE_CONFIG_CONTENT carries only the runtime provider block", () => { + const block = buildOpencodeProviderBlock(10100, [], [{ provider: "kiro", id: "glm-5" }]); + const built = buildOpencodeEnv(block, "sk-ocx-123", { OPENCODE_CONFIG: "/user/mine.json", PATH: "/bin" }); + expect(isOpencodeRuntimeConfigError(built)).toBe(false); + if (isOpencodeRuntimeConfigError(built)) return; + expect(built.OPENCODE_CONFIG).toBe("/user/mine.json"); + expect(built.PATH).toBe("/bin"); + const parsed = JSON.parse(built[OPENCODE_CONFIG_CONTENT_ENV]!) as { provider?: Record }; + expect(Object.keys(parsed.provider ?? {})).toEqual([OPENCODE_PROVIDER_ID]); + }); + + test("preserves inherited inline settings in OPENCODE_CONFIG_CONTENT", () => { + const block = buildOpencodeProviderBlock(10100, [], [{ provider: "kiro", id: "glm-5" }]); + const inherited = JSON.stringify({ + model: "custom/model", + provider: { other: { npm: "@other/pkg" } }, + }); + const built = buildOpencodeEnv(block, "sk-ocx-123", { [OPENCODE_CONFIG_CONTENT_ENV]: inherited }); + expect(isOpencodeRuntimeConfigError(built)).toBe(false); + if (isOpencodeRuntimeConfigError(built)) return; + const parsed = JSON.parse(built[OPENCODE_CONFIG_CONTENT_ENV]!) as { + model?: string; + provider?: Record; + }; + expect(parsed.model).toBe("custom/model"); + expect(parsed.provider?.other).toEqual({ npm: "@other/pkg" }); + expect(parsed.provider?.[OPENCODE_PROVIDER_ID]).toEqual(block); + }); + + test("surfaces invalid inherited OPENCODE_CONFIG_CONTENT as an error", () => { + const block = buildOpencodeProviderBlock(10100, [], []); + expect(buildOpencodeEnv(block, "sk-ocx-123", { [OPENCODE_CONFIG_CONTENT_ENV]: "[]" })) + .toEqual({ error: "OPENCODE_CONFIG_CONTENT must be a JSON object." }); + }); + + test("the admission key travels in the child env, matching the config's {env:…} reference", () => { + const block = buildOpencodeProviderBlock(10100, [], []); + const built = buildOpencodeEnv(block, "sk-ocx-123", {}); + expect(isOpencodeRuntimeConfigError(built)).toBe(false); + if (isOpencodeRuntimeConfigError(built)) return; + expect(built[OPENCODE_API_KEY_ENV]).toBe("sk-ocx-123"); + expect(built[OPENCODE_CONFIG_CONTENT_ENV]).not.toContain("sk-ocx-123"); + }); +}); + +describe("ocx opencode admission key", () => { + test("the environment token wins over a configured API key", () => { + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeApiKey(config, { OPENCODEX_API_AUTH_TOKEN: "sk-env" })).toBe("sk-env"); + }); + + test("falls back to the hardened service token file before config.apiKeys", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-token-")); + const tokenFile = join(dir, "service-api-token"); + writeFileSync(tokenFile, "sk-service\n", "utf8"); + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeApiKey(config, { OCX_API_TOKEN_FILE: tokenFile })).toBe("sk-service"); + }); + + test("falls back to the configured proxy API key", () => { + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeApiKey(config, {})).toBe("sk-cfg"); + }); + + test("falls back to a placeholder on an open loopback proxy", () => { + expect(opencodeApiKey(cfg(), {})).toBe("ocx"); + }); +}); + +describe("ocx opencode proxy auto-start env", () => { + test("passes OCX_API_TOKEN_FILE to ocx start when only the hardened service token exists", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-start-")); + const tokenFile = join(dir, "service-api-token"); + writeFileSync(tokenFile, "sk-service-only\n", "utf8"); + const config = cfg({ hostname: "0.0.0.0", apiKeys: [] as OcxConfig["apiKeys"] }); + + const startEnv = opencodeProxyStartEnv({ OCX_API_TOKEN_FILE: tokenFile }); + expect(startEnv.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(startEnv.OCX_API_TOKEN_FILE).toBe(tokenFile); + expect(startEnv.OCX_SERVICE).toBe("1"); + expect(JSON.stringify(startEnv)).not.toContain("sk-service-only"); + expect(loadServiceTokenFromFile(startEnv)).toBe("sk-service-only"); + expect(opencodeApiKey(config, startEnv)).toBe("sk-service-only"); + }); + + test("defaults OCX_API_TOKEN_FILE when admission env token is absent", () => { + const startEnv = opencodeProxyStartEnv({ hostname: "0.0.0.0" }); + expect(startEnv.OPENCODEX_API_AUTH_TOKEN).toBeUndefined(); + expect(startEnv.OCX_API_TOKEN_FILE).toBe(serviceApiTokenFilePath()); + expect(startEnv.OCX_SERVICE).toBe("1"); + }); + + test("does not inject OCX_API_TOKEN_FILE when OPENCODEX_API_AUTH_TOKEN is already set", () => { + const startEnv = opencodeProxyStartEnv({ OPENCODEX_API_AUTH_TOKEN: "sk-env", hostname: "0.0.0.0" }); + expect(startEnv.OPENCODEX_API_AUTH_TOKEN).toBe("sk-env"); + expect(startEnv.OCX_API_TOKEN_FILE).toBeUndefined(); + }); +}); + +describe("ocx opencode global config path", () => { + test("global path follows XDG_CONFIG_HOME when set", () => { + expect(opencodeGlobalConfigPath({ XDG_CONFIG_HOME: "/xdg" }, "/home/u")).toBe(join("/xdg", "opencode", "opencode.json")); + expect(opencodeGlobalConfigPath({}, "/home/u")).toBe(join("/home/u", ".config", "opencode", "opencode.json")); + }); +}); + +describe("ocx opencode not-found hint", () => { + test("cmd.exe reports command-not-found as 9009", () => { + expect(opencodeNotFoundHint(9009, null, "win32")).toContain("npm install -g opencode-ai"); + }); + + test("signal exits and other platforms are not hints", () => { + expect(opencodeNotFoundHint(9009, "SIGTERM", "win32")).toBeNull(); + expect(opencodeNotFoundHint(9009, null, "linux")).toBeNull(); + expect(opencodeNotFoundHint(0, null, "win32")).toBeNull(); + }); +}); From 40fdc3deec635bd13462e4e3d63676ffbb26d17f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:31:33 +0200 Subject: [PATCH 011/129] feat(storage): restore quarantined archived sessions (phase 2.1 of #42) (#558) * feat(storage): restore quarantined archived sessions (phase 2.1 of #42) Add trash list/restore APIs, Storage GUI quarantine panel, and round-trip tests so default cleanup can be undone without Phase 3 purge. * fix(storage): address PR #558 restore review and GUI loading races Keep satellite backups for quarantine restore, snapshot full thread rows and state dependents, remap satellite DB paths, and make satellite failures retryable. Drop duplicate trash probing so Storage abort/generation ownership stays correct. * fix(storage): harden restore moves and Windows lock-test timeouts Use link+unlink no-replace for trash restore so a raced dest cannot be overwritten, and raise timeouts for multi-satellite cleanup tests that overrun 5s on Windows CI. * fix(storage): atomic trash parse and legacy thread restore Reject malformed manifests wholesale, gate stage deletion on restore completeness, and reconstruct production thread rows from rollout session_meta when satellite snapshots are missing. * fix(storage): release memories lock before post-image backup write Holding BEGIN IMMEDIATE across writeFileSync let Windows CI disk/AV latency stall the watermark concurrent-restore test past bun's 5s default. Persist consolidatePostImage after COMMIT and raise the two concurrent restore test budgets to match the other multi-satellite cases. * fix(storage): compensate restore metadata on late failure Restage files then delete rows this restore committed so satellite/leftover failures leave a clean retryable trash entry. * fix(storage): enforce restore compensation success and preserve pre-existing rows Track exact conflict-ignored inserts, require compensating DB txs to commit before restaging, and leave accurate partial counts when compensation is busy. * fix(storage): resume incomplete restore instead of non-atomic compensation Late failures after file moves now persist restore-pending.json and keep restored files in place, so retries accept destinations and finish only missing metadata without deleting rows before a guaranteed restage. * fix(storage): write restore-pending atomically before file moves Persist the resume marker with stage-local temp+fsync+rename before any rollout leaves quarantine, distinguish missing/valid/invalid markers, and fail closed when a resume still owes satellite work but satellite-backup.json is gone. * fix(storage): keep placed dests on mid-move restore failure Once restore-pending is durable, a later rename failure must not reverse successful moves or drop the planned acceptedDestRels marker so resume can finish staged files. * fix(storage): close PR #558 restore blockers (fail-closed resume, tombstone finalize, worker job) Fail closed per owed satellite section on resume, finalize successful restores via non-listable tombstone rename, and run restoreTrashEntry in a serialized Bun Worker so the event loop stays responsive. * fix(storage): serialize cleanup and restore via shared mutation gate Route manual cleanup and trash restore through one per-CODEX_HOME coordinator that returns 409 storage_mutation_busy instead of queuing. Stub runPolicyStorageMutation for Phase 3 policy worker integration. * fix(storage): export policy overlap guard for pending restore * fix(storage): surface restore worker failures and gate test-stream route Map worker rejections to distinct restore error codes end-to-end (API, GUI, i18n) instead of generic restore_failed, and register the restore test-stream endpoint only when OPENCODEX_CLEANUP_TEST_HOOKS=1. * fix(storage): drop broken pending-overlap hooks from cleanup preview Keep only restore worker error types in cleanup.ts; remove calls to undefined pending-restore guard helpers introduced in the prior commit. * fix(storage): guard cleanup against pending restore overlap Reject cleanup when selected archives overlap acceptedDestRels from valid restore-pending markers, fail closed on unfinished satellite sections, and address tip CodeRabbit nits (healthz flake, preview status). * fix(tests): stabilize Windows CI storage policy and rollback cases Raise injected satellite rollback test timeout for slow Windows SQLite reconcile, and delay concurrent policy PUT until the worker has loaded the enabled snapshot inside holdAfterLoadMs. * fix(storage): hold policy mutation slot in parent and backfill pending-safe selection Acquire the shared CODEX_HOME mutation slot in the policy job controller before spawning the worker and release it on success, failure, timeout, and abort. Pending-restore destinations no longer consume percent or reduceToBytes selection budget; cleanup backfills with the next oldest safe archives. Adds API regressions for policy, restore, and manual cleanup contention in both orderings. * fix(storage): clear policy-job inflight after busy-path settle The request handler assignment overwrote executeJob's inflight=null on mutation-busy returns, leaving a settled Promise latched forever. Clear inflight in a finally on the assigned job promise instead. --- .../src/content/docs/guides/web-dashboard.md | 2 +- .../content/docs/ja/guides/web-dashboard.md | 2 +- .../content/docs/ko/guides/web-dashboard.md | 2 +- .../content/docs/ru/guides/web-dashboard.md | 2 +- .../docs/zh-cn/guides/web-dashboard.md | 2 +- gui/src/i18n/de.ts | 33 +- gui/src/i18n/en.ts | 33 +- gui/src/i18n/ja.ts | 33 +- gui/src/i18n/ko.ts | 33 +- gui/src/i18n/ru.ts | 33 +- gui/src/i18n/zh.ts | 33 +- gui/src/pages/Storage.tsx | 393 ++++- src/codex/history-provider.ts | 114 +- src/server/management/logs-usage-routes.ts | 107 +- src/storage/cleanup-job.ts | 57 + src/storage/cleanup.ts | 1374 ++++++++++++++++- src/storage/policy-job.ts | 48 +- src/storage/policy.ts | 9 +- src/storage/restore-job.ts | 242 +++ src/storage/restore-worker.ts | 52 + src/storage/storage-mutation-coordinator.ts | 109 ++ tests/api-storage-cleanup.test.ts | 143 ++ tests/api-storage-policy.test.ts | 87 +- tests/storage-cleanup.test.ts | 892 ++++++++++- tests/storage-mutation-race.test.ts | 413 +++++ tests/storage-policy.test.ts | 61 + tests/storage-restore-job-errors.test.ts | 36 + tests/storage-restore-job-responsive.test.ts | 151 ++ 28 files changed, 4389 insertions(+), 107 deletions(-) create mode 100644 src/storage/cleanup-job.ts create mode 100644 src/storage/restore-job.ts create mode 100644 src/storage/restore-worker.ts create mode 100644 src/storage/storage-mutation-coordinator.ts create mode 100644 tests/storage-mutation-race.test.ts create mode 100644 tests/storage-restore-job-errors.test.ts create mode 100644 tests/storage-restore-job-responsive.test.ts diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 730425fe0..739e6acef 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -39,7 +39,7 @@ bun run dev:gui | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | | **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | -| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Active sessions stay read-only. Cleanup is refused while Codex holds the newest/active `state_*.sqlite` locked. Quarantined files are **not** restorable from the dashboard — recover manually from `.trash//` using `manifest.json` if needed. | +| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). | ### Linking to a section diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 737a4342f..2a253dc9c 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -39,7 +39,7 @@ bun run dev:gui | **モデル** | ネイティブ GPT とルーティングモデルをオン/オフし、プロバイダー許可リストとコンテキスト上限、v1/base/v2、v2 スレッド数を設定します。 | | **ログ** | トークン、要求された強度と(利用可能な場合は)実際に送信された強度、実際のモデル、プロバイダー、状態、リクエスト ID、所要時間、エラー詳細を含む最近のリクエストを自動更新します。アダプターが reasoning パラメーターを送信した場合、詳細表示に正確な wire field も表示されます。 | | **使用量 / デバッグ** | トークン使用量の測定範囲と推移を見るか、オプションのプロバイダートランスポート/使用量抽出診断をオンにします。 | -| **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。**自動クリーンアップ方針**はオプトインで**既定 OFF**(`storageCleanupPolicy.enabled`)。Storage ページでしきい値/目標/スケジュール/モードを設定するか **今すぐ実行**。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中は拒否。隔離分はダッシュボードから復元不可 — 必要なら `.trash//` と `manifest.json` から手動復旧。 | +| **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。**自動クリーンアップ方針**はオプトインで**既定 OFF**(`storageCleanupPolicy.enabled`)。Storage ページでしきい値/目標/スケジュール/モードを設定するか **今すぐ実行**。隔離エントリは Storage ページから復元可能(JSONL + スレッド)。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中はクリーンアップと復元を拒否。 | | **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。 | ### セクションへのリンク diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 6d7ca6baf..8ab11d4e6 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -39,7 +39,7 @@ bun run dev:gui | **Models** | 네이티브 GPT와 라우팅 모델을 켜고 끄고, 프로바이더 allowlist와 컨텍스트 상한, v1/base/v2, v2 thread 수를 설정합니다. | | **Logs** | 토큰, 요청한 강도와 (사용 가능한 경우) 실제 전송 강도, 실제 모델, 프로바이더, 상태, 요청 id, 소요 시간, 오류 상세가 포함된 최근 요청을 자동 갱신합니다. 어댑터가 reasoning 매개변수를 전송한 경우 상세 보기에 정확한 wire field도 표시됩니다. 클라이언트가 보낸 불투명 대화/세션 id로 필터하면 현재 로드된 Logs 링의 토큰·추정 정가 합계를 볼 수 있습니다. | | **Usage / Debug** | 토큰 사용량의 측정 범위와 추이를 보거나, 선택적 프로바이더 전송/사용량 추출 진단을 켭니다. | -| **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. **자동 정리 정책**은 opt-in이며 **기본 OFF**(`storageCleanupPolicy.enabled`)입니다. Storage 페이지에서 임계값/목표/일정/모드를 설정하거나 **지금 실행**하세요. 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 거절합니다. 격리 파일은 대시보드에서 복원할 수 없습니다 — 필요하면 `.trash//`와 `manifest.json`으로 수동 복구하세요. | +| **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. **자동 정리 정책**은 opt-in이며 **기본 OFF**(`storageCleanupPolicy.enabled`)입니다. Storage 페이지에서 임계값/목표/일정/모드를 설정하거나 **지금 실행**하세요. Storage 페이지에서 격리 항목을 복원할 수 있습니다(JSONL + 스레드). 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 정리와 복원을 거절합니다. | | **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). | ### 섹션으로 바로 가기 diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index fda9b25d2..70d8e9e22 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -39,7 +39,7 @@ bun run dev:gui | **Models** | Включение и отключение нативных GPT и маршрутизируемых моделей, настройка списков разрешённых провайдеров и лимитов контекста, выбор v1/base/v2 и настройка лимита потоков v2. | | **Logs** | Автообновляемый список недавних запросов: токены, запрошенный и, когда доступен, фактически отправленный уровень рассуждений, фактическая модель, провайдер, статус, id запроса, длительность и подробности ошибок. Если адаптер отправляет параметр рассуждений, в подробностях также отображается точное wire-поле. Можно фильтровать по непрозрачному id диалога/сессии (если клиент его передаёт) и суммировать токены и оценочную стоимость по прайс-листу в пределах загруженного кольца Logs. | | **Usage / Debug** | Просмотр покрытия и трендов расхода токенов либо включение опциональной диагностики транспорта провайдеров и извлечения данных об использовании. | -| **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. **Политика автоочистки** — opt-in и **по умолчанию ВЫКЛ** (`storageCleanupPolicy.enabled`); порог/цель/расписание/режим на странице Storage или **Запустить сейчас**. Активные сессии только для чтения. Очистка отклоняется, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. Из карантина **нельзя** восстановить через панель — вручную из `.trash//` и `manifest.json`. | +| **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. **Политика автоочистки** — opt-in и **по умолчанию ВЫКЛ** (`storageCleanupPolicy.enabled`); порог/цель/расписание/режим на странице Storage или **Запустить сейчас**. Записи карантина можно восстановить со страницы Storage (JSONL + threads). Активные сессии только для чтения. Очистка и восстановление отклоняются, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. | | **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). | ### Ссылки на разделы diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 4d6752131..d799eea53 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -38,7 +38,7 @@ bun run dev:gui | **Models** | 开关原生 GPT 与路由模型,配置 provider allowlist、上下文上限、v1/base/v2 以及 v2 thread 数量。 | | **Logs** | 自动刷新近期请求,显示 token、请求强度以及(可用时)实际发送强度、实际模型、provider、状态、request id、耗时和错误详情。适配器发送 reasoning 参数时,详情中还会显示准确的 wire field。可按不透明会话/对话 ID(客户端提供时)筛选,并对当前已加载的 Logs 环形缓冲合计 token 与估算标价成本。 | | **Usage / Debug** | 查看 token usage 覆盖率与趋势,或启用可选的 provider transport 和 usage 提取诊断。 | -| **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。**自动清理策略**为可选且**默认关闭**(`storageCleanupPolicy.enabled`);可在 Storage 页配置阈值/目标/计划/模式,或点「立即运行」。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理。隔离文件**不能**从仪表盘恢复——如需恢复,请根据 `manifest.json` 将文件从 `.trash//` 手动移回原位置。 | +| **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。**自动清理策略**为可选且**默认关闭**(`storageCleanupPolicy.enabled`);可在 Storage 页配置阈值/目标/计划/模式,或点「立即运行」。可在 Storage 页从隔离区恢复(JSONL + 线程)。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理与恢复。 | | **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。 | ### 链接到某个部分 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 435768a4c..d206e2c2d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -901,7 +901,7 @@ export const de: Record = { "storage.cleanup.moreFiles": "…und {n} weitere", "storage.cleanup.permanent": "Dauerhaft löschen (ohne Quarantäne)", "storage.cleanup.permanentWarn": "Dauerhaftes Löschen kann nicht rückgängig gemacht werden.", - "storage.cleanup.quarantineNote": "Dateien wandern nach .trash unter CODEX_HOME. In dieser Version gibt es keine App-Wiederherstellung — behalte den Ordner oder verschiebe Dateien manuell anhand von manifest.json zurück.", + "storage.cleanup.quarantineNote": "Dateien wandern nach .trash unter CODEX_HOME. Du kannst sie im Quarantäne-Abschnitt darunter wiederherstellen.", "storage.cleanup.cancel": "Abbrechen", "storage.cleanup.confirmQuarantine": "In Quarantäne", "storage.cleanup.confirmPermanent": "Dauerhaft löschen", @@ -911,6 +911,7 @@ export const de: Record = { "storage.cleanup.cleanupFailed": "Bereinigung fehlgeschlagen.", "storage.cleanup.err.codex_busy": "Codex verwendet state.sqlite — beende Codex und versuche es erneut.", "storage.cleanup.err.stale_preview": "Archivdateien haben sich seit der Vorschau geändert — führe Vorschau erneut aus.", + "storage.cleanup.err.restore_pending_overlap": "Ausgewählte Archive überschneiden sich mit einer unvollständigen Wiederherstellung — zuerst Wiederherstellung abschließen oder erneut versuchen.", "storage.cleanup.err.referenced_history": "Ausgewählte Archive werden noch von Fork- oder paginierter Historie referenziert.", "storage.cleanup.err.invalid_digest": "Vorschaudigest fehlt oder ist ungültig.", "storage.cleanup.err.invalid_mode": "Modus muss quarantine oder permanent sein.", @@ -919,6 +920,36 @@ export const de: Record = { "storage.cleanup.err.db_reconcile_failed": "Codex-Statusdatenbank konnte nicht aktualisiert werden.", "storage.cleanup.err.cleanup_failed": "Bereinigung fehlgeschlagen.", + "storage.trash.title": "Quarantäne", + "storage.trash.help": "Archivierte Sitzungen in CODEX_HOME/.trash. Wiederherstellen legt JSONL-Dateien und Thread-Zeilen zurück.", + "storage.trash.empty": "Keine Quarantäne-Einträge.", + "storage.trash.loading": "Quarantäne wird geladen…", + "storage.trash.col.when": "Quarantäne seit", + "storage.trash.col.files": "Dateien", + "storage.trash.col.size": "Größe", + "storage.trash.col.mode": "Modus", + "storage.trash.col.id": "Eintrag", + "storage.trash.restore": "Wiederherstellen", + "storage.trash.confirmTitle": "Quarantäne-Eintrag wiederherstellen?", + "storage.trash.confirmBody": "{count} Datei(en) (~{size}) aus {id} zurück in archivierte Sitzungen legen.", + "storage.trash.cancel": "Abbrechen", + "storage.trash.confirmRestore": "Wiederherstellen", + "storage.trash.done": "{count} Datei(en) wiederhergestellt ({size}).", + "storage.trash.restoreFailed": "Wiederherstellung fehlgeschlagen.", + "storage.trash.listFailed": "Quarantäne-Einträge konnten nicht geladen werden.", + "storage.trash.mode.quarantine": "quarantine", + "storage.trash.mode.permanent": "permanent (unvollständig)", + "storage.trash.err.codex_busy": "Codex verwendet state.sqlite — beende Codex und versuche es erneut.", + "storage.trash.err.invalid_trash": "Trash-Eintrags-ID fehlt oder ist ungültig.", + "storage.trash.err.missing_trash": "Trash-Eintrag wurde nicht gefunden.", + "storage.trash.err.dest_exists": "Wiederherstellungsziel existiert bereits — entferne oder benenne die Archivdatei um und versuche es erneut.", + "storage.trash.err.fs_failed": "Dateisystem-Wiederherstellung fehlgeschlagen. Einige Dateien können bereits wiederhergestellt sein — prüfe archived_sessions und .trash.", + "storage.trash.err.storage_mutation_busy": "Eine andere Speicher-Bereinigung oder Wiederherstellung läuft — bitte kurz warten.", + "storage.trash.err.db_reconcile_failed": "Codex-Statusdatenbankzeilen konnten nicht wiederhergestellt werden.", + "storage.trash.err.restore_failed": "Wiederherstellung fehlgeschlagen.", + "storage.trash.err.restore_worker_timeout": "Wiederherstellung dauerte zu lange (über 10 Minuten) und wurde abgebrochen.", + "storage.trash.err.restore_worker_aborted": "Wiederherstellung wurde beim Herunterfahren abgebrochen.", + "storage.trash.err.restore_worker_failed": "Wiederherstellungs-Worker ist abgestürzt oder unerwartet fehlgeschlagen.", "storage.policy.title": "Automatische Bereinigungsrichtlinie", "storage.policy.help": "Optionale Stapelbereinigung, wenn archivierte Sitzungen einen Schwellwert überschreiten. Standardmäßig aus — wird nie automatisch aktiviert.", "storage.policy.loading": "Richtlinie wird geladen…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 345ba36ef..a2b07434d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -653,7 +653,7 @@ export const en = { "storage.cleanup.moreFiles": "…and {n} more", "storage.cleanup.permanent": "Delete permanently (skip quarantine)", "storage.cleanup.permanentWarn": "Permanent delete cannot be undone.", - "storage.cleanup.quarantineNote": "Files move to .trash under CODEX_HOME. There is no in-app restore in this release — keep the trash folder, or manually move files back using manifest.json.", + "storage.cleanup.quarantineNote": "Files move to .trash under CODEX_HOME. You can restore them from the Quarantine section below.", "storage.cleanup.cancel": "Cancel", "storage.cleanup.confirmQuarantine": "Quarantine", "storage.cleanup.confirmPermanent": "Delete permanently", @@ -663,6 +663,7 @@ export const en = { "storage.cleanup.cleanupFailed": "Cleanup failed.", "storage.cleanup.err.codex_busy": "Codex is using state.sqlite — try again after quitting Codex.", "storage.cleanup.err.stale_preview": "Archived files changed since preview — run Preview again.", + "storage.cleanup.err.restore_pending_overlap": "Selected archives overlap an incomplete trash restore — finish or retry restore first.", "storage.cleanup.err.referenced_history": "Selected archives are still referenced by forked or paginated history.", "storage.cleanup.err.invalid_digest": "Preview digest is missing or invalid.", "storage.cleanup.err.invalid_mode": "Cleanup mode must be quarantine or permanent.", @@ -671,6 +672,36 @@ export const en = { "storage.cleanup.err.db_reconcile_failed": "Could not update Codex state database.", "storage.cleanup.err.cleanup_failed": "Cleanup failed.", + "storage.trash.title": "Quarantine", + "storage.trash.help": "Archived sessions moved to CODEX_HOME/.trash. Restore puts JSONL files and thread rows back.", + "storage.trash.empty": "No quarantined entries.", + "storage.trash.loading": "Loading quarantine…", + "storage.trash.col.when": "Quarantined", + "storage.trash.col.files": "Files", + "storage.trash.col.size": "Size", + "storage.trash.col.mode": "Mode", + "storage.trash.col.id": "Entry", + "storage.trash.restore": "Restore", + "storage.trash.confirmTitle": "Restore quarantine entry?", + "storage.trash.confirmBody": "Restore {count} file(s) (~{size}) from {id} back to archived sessions.", + "storage.trash.cancel": "Cancel", + "storage.trash.confirmRestore": "Restore", + "storage.trash.done": "Restored {count} file(s) ({size}).", + "storage.trash.restoreFailed": "Restore failed.", + "storage.trash.listFailed": "Could not list quarantine entries.", + "storage.trash.mode.quarantine": "quarantine", + "storage.trash.mode.permanent": "permanent (incomplete)", + "storage.trash.err.codex_busy": "Codex is using state.sqlite — try again after quitting Codex.", + "storage.trash.err.invalid_trash": "Trash entry id is missing or invalid.", + "storage.trash.err.missing_trash": "Trash entry was not found.", + "storage.trash.err.dest_exists": "Restore destination already exists — remove or rename the archived file and retry.", + "storage.trash.err.fs_failed": "Filesystem restore failed. Some files may already be restored — check archived_sessions and .trash.", + "storage.trash.err.db_reconcile_failed": "Could not restore Codex state database rows.", + "storage.trash.err.storage_mutation_busy": "Another storage cleanup or restore is in progress — try again shortly.", + "storage.trash.err.restore_failed": "Restore failed.", + "storage.trash.err.restore_worker_timeout": "Restore took too long (over 10 minutes) and was stopped.", + "storage.trash.err.restore_worker_aborted": "Restore was cancelled during shutdown.", + "storage.trash.err.restore_worker_failed": "Restore worker crashed or failed unexpectedly.", "storage.policy.title": "Auto-cleanup policy", "storage.policy.help": "Optional batch cleanup when archived sessions exceed a threshold. Off by default — never enabled automatically.", "storage.policy.loading": "Loading policy…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index efc81243c..d27de2415 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -620,7 +620,7 @@ export const ja: Record = { "storage.cleanup.moreFiles": "…ほか {n} 件", "storage.cleanup.permanent": "完全に削除する(隔離しない)", "storage.cleanup.permanentWarn": "完全削除は元に戻せません。", - "storage.cleanup.quarantineNote": "ファイルは CODEX_HOME 下の .trash へ移動します。この版にアプリ内復元はありません — ゴミ箱を残すか、manifest.json を使って手動で戻してください。", + "storage.cleanup.quarantineNote": "ファイルは CODEX_HOME 下の .trash へ移動します。下の隔離セクションから復元できます。", "storage.cleanup.cancel": "キャンセル", "storage.cleanup.confirmQuarantine": "隔離する", "storage.cleanup.confirmPermanent": "完全に削除", @@ -630,6 +630,7 @@ export const ja: Record = { "storage.cleanup.cleanupFailed": "クリーンアップに失敗しました。", "storage.cleanup.err.codex_busy": "Codex が state.sqlite を使用中です — Codex を終了して再試行してください。", "storage.cleanup.err.stale_preview": "プレビュー以降にアーカイブが変わりました — プレビューをやり直してください。", + "storage.cleanup.err.restore_pending_overlap": "選択したアーカイブは未完了の隔離復元と重なっています — 復元を完了するか再試行してください。", "storage.cleanup.err.referenced_history": "選択したアーカイブはフォークまたはページング履歴から参照されています。", "storage.cleanup.err.invalid_digest": "プレビューのダイジェストが無い、または無効です。", "storage.cleanup.err.invalid_mode": "モードは quarantine または permanent である必要があります。", @@ -638,6 +639,36 @@ export const ja: Record = { "storage.cleanup.err.db_reconcile_failed": "Codex の状態データベースを更新できませんでした。", "storage.cleanup.err.cleanup_failed": "クリーンアップに失敗しました。", + "storage.trash.title": "隔離", + "storage.trash.help": "CODEX_HOME/.trash へ移したアーカイブセッションです。復元すると JSONL とスレッド行が戻ります。", + "storage.trash.empty": "隔離エントリはありません。", + "storage.trash.loading": "隔離を読み込み中…", + "storage.trash.col.when": "隔離日時", + "storage.trash.col.files": "ファイル", + "storage.trash.col.size": "サイズ", + "storage.trash.col.mode": "モード", + "storage.trash.col.id": "エントリ", + "storage.trash.restore": "復元", + "storage.trash.confirmTitle": "隔離エントリを復元しますか?", + "storage.trash.confirmBody": "{id} から {count} 件(約 {size})をアーカイブセッションへ戻します。", + "storage.trash.cancel": "キャンセル", + "storage.trash.confirmRestore": "復元", + "storage.trash.done": "{count} 件を復元しました({size})。", + "storage.trash.restoreFailed": "復元に失敗しました。", + "storage.trash.listFailed": "隔離一覧を取得できませんでした。", + "storage.trash.mode.quarantine": "隔離", + "storage.trash.mode.permanent": "完全削除(未完了)", + "storage.trash.err.codex_busy": "Codex が state.sqlite を使用中です — Codex を終了して再試行してください。", + "storage.trash.err.invalid_trash": "隔離エントリ ID が無い、または無効です。", + "storage.trash.err.missing_trash": "隔離エントリが見つかりません。", + "storage.trash.err.dest_exists": "復元先が既に存在します — アーカイブファイルを削除または改名して再試行してください。", + "storage.trash.err.fs_failed": "ファイルシステムの復元に失敗しました。一部は既に復元されている可能性があります — archived_sessions と .trash を確認してください。", + "storage.trash.err.storage_mutation_busy": "別のストレージクリーンアップまたは復元が進行中です — しばらくして再試行してください。", + "storage.trash.err.db_reconcile_failed": "Codex の状態データベース行を復元できませんでした。", + "storage.trash.err.restore_failed": "復元に失敗しました。", + "storage.trash.err.restore_worker_timeout": "復元が長時間(10 分超)かかったため停止しました。", + "storage.trash.err.restore_worker_aborted": "シャットダウン中に復元がキャンセルされました。", + "storage.trash.err.restore_worker_failed": "復元ワーカーがクラッシュまたは予期しないエラーで失敗しました。", "storage.policy.title": "自動クリーンアップ方針", "storage.policy.help": "アーカイブがしきい値を超えたときの任意の一括クリーンアップ。既定はオフ — 自動では有効になりません。", "storage.policy.loading": "方針を読み込み中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d44d0ab0a..1257596e9 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -921,7 +921,7 @@ export const ko: Record = { "storage.cleanup.moreFiles": "…외 {n}개", "storage.cleanup.permanent": "영구 삭제(격리 건너뛰기)", "storage.cleanup.permanentWarn": "영구 삭제는 되돌릴 수 없습니다.", - "storage.cleanup.quarantineNote": "파일은 CODEX_HOME 아래 .trash로 이동합니다. 이 버전에는 앱 내 복원이 없습니다 — 휴지통을 유지하거나 manifest.json으로 수동으로 되돌리세요.", + "storage.cleanup.quarantineNote": "파일은 CODEX_HOME 아래 .trash로 이동합니다. 아래 격리 섹션에서 복원할 수 있습니다.", "storage.cleanup.cancel": "취소", "storage.cleanup.confirmQuarantine": "격리", "storage.cleanup.confirmPermanent": "영구 삭제", @@ -931,6 +931,7 @@ export const ko: Record = { "storage.cleanup.cleanupFailed": "정리에 실패했습니다.", "storage.cleanup.err.codex_busy": "Codex가 state.sqlite를 사용 중입니다 — Codex를 종료한 뒤 다시 시도하세요.", "storage.cleanup.err.stale_preview": "미리보기 이후 보관 파일이 변경되었습니다 — 미리보기를 다시 실행하세요.", + "storage.cleanup.err.restore_pending_overlap": "선택한 보관 파일이 미완료 휴지통 복원과 겹칩니다 — 복원을 완료하거나 다시 시도하세요.", "storage.cleanup.err.referenced_history": "선택한 보관본이 포크 또는 페이지 기록에서 아직 참조됩니다.", "storage.cleanup.err.invalid_digest": "미리보기 digest가 없거나 잘못되었습니다.", "storage.cleanup.err.invalid_mode": "모드는 quarantine 또는 permanent여야 합니다.", @@ -939,6 +940,36 @@ export const ko: Record = { "storage.cleanup.err.db_reconcile_failed": "Codex 상태 데이터베이스를 업데이트할 수 없습니다.", "storage.cleanup.err.cleanup_failed": "정리에 실패했습니다.", + "storage.trash.title": "격리", + "storage.trash.help": "CODEX_HOME/.trash로 옮긴 보관 세션입니다. 복원하면 JSONL과 스레드 행이 돌아갑니다.", + "storage.trash.empty": "격리된 항목이 없습니다.", + "storage.trash.loading": "격리 목록 불러오는 중…", + "storage.trash.col.when": "격리 시각", + "storage.trash.col.files": "파일", + "storage.trash.col.size": "크기", + "storage.trash.col.mode": "모드", + "storage.trash.col.id": "항목", + "storage.trash.restore": "복원", + "storage.trash.confirmTitle": "격리 항목을 복원할까요?", + "storage.trash.confirmBody": "{id}에서 파일 {count}개(약 {size})를 보관 세션으로 되돌립니다.", + "storage.trash.cancel": "취소", + "storage.trash.confirmRestore": "복원", + "storage.trash.done": "파일 {count}개를 복원했습니다({size}).", + "storage.trash.restoreFailed": "복원에 실패했습니다.", + "storage.trash.listFailed": "격리 목록을 불러오지 못했습니다.", + "storage.trash.mode.quarantine": "격리", + "storage.trash.mode.permanent": "영구(미완료)", + "storage.trash.err.codex_busy": "Codex가 state.sqlite를 사용 중입니다 — Codex를 종료한 뒤 다시 시도하세요.", + "storage.trash.err.invalid_trash": "격리 항목 ID가 없거나 잘못되었습니다.", + "storage.trash.err.missing_trash": "격리 항목을 찾을 수 없습니다.", + "storage.trash.err.dest_exists": "복원 대상이 이미 있습니다 — 보관 파일을 삭제하거나 이름을 바꾼 뒤 다시 시도하세요.", + "storage.trash.err.fs_failed": "파일 시스템 복원에 실패했습니다. 일부 파일이 이미 복원되었을 수 있습니다 — archived_sessions와 .trash를 확인하세요.", + "storage.trash.err.storage_mutation_busy": "다른 저장소 정리 또는 복원이 진행 중입니다 — 잠시 후 다시 시도하세요.", + "storage.trash.err.db_reconcile_failed": "Codex 상태 데이터베이스 행을 복원할 수 없습니다.", + "storage.trash.err.restore_failed": "복원에 실패했습니다.", + "storage.trash.err.restore_worker_timeout": "복원 시간이 너무 길어(10분 초과) 중단되었습니다.", + "storage.trash.err.restore_worker_aborted": "종료 중 복원이 취소되었습니다.", + "storage.trash.err.restore_worker_failed": "복원 워커가 충돌하거나 예기치 않게 실패했습니다.", "storage.policy.title": "자동 정리 정책", "storage.policy.help": "보관 세션이 임계값을 넘을 때 선택적으로 일괄 정리합니다. 기본은 꺼짐 — 자동으로 켜지지 않습니다.", "storage.policy.loading": "정책을 불러오는 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 978ee28f5..82efcb20d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -652,7 +652,7 @@ export const ru: Record = { "storage.cleanup.moreFiles": "…и ещё {n}", "storage.cleanup.permanent": "Удалить навсегда (без карантина)", "storage.cleanup.permanentWarn": "Безвозвратное удаление нельзя отменить.", - "storage.cleanup.quarantineNote": "Файлы перемещаются в .trash под CODEX_HOME. В этом выпуске нет восстановления в приложении — сохраняйте корзину или вручную верните файлы по manifest.json.", + "storage.cleanup.quarantineNote": "Файлы перемещаются в .trash под CODEX_HOME. Восстановить можно в разделе «Карантин» ниже.", "storage.cleanup.cancel": "Отмена", "storage.cleanup.confirmQuarantine": "В карантин", "storage.cleanup.confirmPermanent": "Удалить навсегда", @@ -662,6 +662,7 @@ export const ru: Record = { "storage.cleanup.cleanupFailed": "Не удалось выполнить очистку.", "storage.cleanup.err.codex_busy": "Codex использует state.sqlite — закройте Codex и повторите попытку.", "storage.cleanup.err.stale_preview": "Архивы изменились после предпросмотра — выполните предпросмотр снова.", + "storage.cleanup.err.restore_pending_overlap": "Выбранные архивы пересекаются с незавершённым восстановлением из корзины — завершите или повторите восстановление.", "storage.cleanup.err.referenced_history": "Выбранные архивы всё ещё ссылаются из forked или paginated history.", "storage.cleanup.err.invalid_digest": "Digest предпросмотра отсутствует или недействителен.", "storage.cleanup.err.invalid_mode": "Режим должен быть quarantine или permanent.", @@ -670,6 +671,36 @@ export const ru: Record = { "storage.cleanup.err.db_reconcile_failed": "Не удалось обновить базу состояния Codex.", "storage.cleanup.err.cleanup_failed": "Не удалось выполнить очистку.", + "storage.trash.title": "Карантин", + "storage.trash.help": "Архивные сессии в CODEX_HOME/.trash. Восстановление возвращает JSONL и строки потоков.", + "storage.trash.empty": "Нет записей в карантине.", + "storage.trash.loading": "Загрузка карантина…", + "storage.trash.col.when": "В карантине с", + "storage.trash.col.files": "Файлы", + "storage.trash.col.size": "Размер", + "storage.trash.col.mode": "Режим", + "storage.trash.col.id": "Запись", + "storage.trash.restore": "Восстановить", + "storage.trash.confirmTitle": "Восстановить запись карантина?", + "storage.trash.confirmBody": "Вернуть {count} файл(ов) (~{size}) из {id} в архивные сессии.", + "storage.trash.cancel": "Отмена", + "storage.trash.confirmRestore": "Восстановить", + "storage.trash.done": "Восстановлено {count} файл(ов) ({size}).", + "storage.trash.restoreFailed": "Не удалось восстановить.", + "storage.trash.listFailed": "Не удалось получить список карантина.", + "storage.trash.mode.quarantine": "карантин", + "storage.trash.mode.permanent": "permanent (незавершён)", + "storage.trash.err.codex_busy": "Codex использует state.sqlite — закройте Codex и повторите попытку.", + "storage.trash.err.invalid_trash": "Идентификатор записи корзины отсутствует или недействителен.", + "storage.trash.err.missing_trash": "Запись корзины не найдена.", + "storage.trash.err.dest_exists": "Цель восстановления уже существует — удалите или переименуйте архивный файл и повторите.", + "storage.trash.err.fs_failed": "Ошибка восстановления файлов. Часть файлов могла уже восстановиться — проверьте archived_sessions и .trash.", + "storage.trash.err.storage_mutation_busy": "Выполняется другая очистка или восстановление — повторите позже.", + "storage.trash.err.db_reconcile_failed": "Не удалось восстановить строки базы состояния Codex.", + "storage.trash.err.restore_failed": "Не удалось восстановить.", + "storage.trash.err.restore_worker_timeout": "Восстановление заняло слишком много времени (более 10 минут) и было остановлено.", + "storage.trash.err.restore_worker_aborted": "Восстановление отменено при завершении работы.", + "storage.trash.err.restore_worker_failed": "Worker восстановления завершился с ошибкой или аварийно.", "storage.policy.title": "Политика автоочистки", "storage.policy.help": "Необязательная пакетная очистка, когда архивные сессии превышают порог. По умолчанию выкл. — никогда не включается сама.", "storage.policy.loading": "Загрузка политики…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b6d1b37aa..796724fc1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -921,7 +921,7 @@ export const zh: Record = { "storage.cleanup.moreFiles": "…以及另外 {n} 个", "storage.cleanup.permanent": "永久删除(跳过隔离)", "storage.cleanup.permanentWarn": "永久删除无法撤销。", - "storage.cleanup.quarantineNote": "文件会移到 CODEX_HOME 下的 .trash。本版本没有应用内恢复——请保留该文件夹,或按 manifest.json 手动移回原位置。", + "storage.cleanup.quarantineNote": "文件会移到 CODEX_HOME 下的 .trash。可在下方「隔离区」恢复。", "storage.cleanup.cancel": "取消", "storage.cleanup.confirmQuarantine": "隔离", "storage.cleanup.confirmPermanent": "永久删除", @@ -931,6 +931,7 @@ export const zh: Record = { "storage.cleanup.cleanupFailed": "清理失败。", "storage.cleanup.err.codex_busy": "Codex 正在使用 state.sqlite — 请退出 Codex 后重试。", "storage.cleanup.err.stale_preview": "预览后归档文件已变化 — 请重新预览。", + "storage.cleanup.err.restore_pending_overlap": "所选归档与未完成的隔离区恢复重叠 — 请先完成或重试恢复。", "storage.cleanup.err.referenced_history": "所选归档仍被 fork 或分页历史引用。", "storage.cleanup.err.invalid_digest": "预览摘要缺失或无效。", "storage.cleanup.err.invalid_mode": "模式必须是 quarantine 或 permanent。", @@ -939,6 +940,36 @@ export const zh: Record = { "storage.cleanup.err.db_reconcile_failed": "无法更新 Codex 状态数据库。", "storage.cleanup.err.cleanup_failed": "清理失败。", + "storage.trash.title": "隔离区", + "storage.trash.help": "已移至 CODEX_HOME/.trash 的归档会话。恢复会把 JSONL 与线程行写回。", + "storage.trash.empty": "没有隔离条目。", + "storage.trash.loading": "正在加载隔离区…", + "storage.trash.col.when": "隔离时间", + "storage.trash.col.files": "文件", + "storage.trash.col.size": "大小", + "storage.trash.col.mode": "模式", + "storage.trash.col.id": "条目", + "storage.trash.restore": "恢复", + "storage.trash.confirmTitle": "恢复隔离条目?", + "storage.trash.confirmBody": "将 {count} 个文件(约 {size})从 {id} 恢复到归档会话。", + "storage.trash.cancel": "取消", + "storage.trash.confirmRestore": "恢复", + "storage.trash.done": "已恢复 {count} 个文件({size})。", + "storage.trash.restoreFailed": "恢复失败。", + "storage.trash.listFailed": "无法列出隔离条目。", + "storage.trash.mode.quarantine": "隔离", + "storage.trash.mode.permanent": "永久(未完成)", + "storage.trash.err.codex_busy": "Codex 正在使用 state.sqlite — 请退出 Codex 后重试。", + "storage.trash.err.invalid_trash": "隔离条目 ID 缺失或无效。", + "storage.trash.err.missing_trash": "未找到隔离条目。", + "storage.trash.err.dest_exists": "恢复目标已存在 — 请删除或重命名归档文件后重试。", + "storage.trash.err.fs_failed": "文件系统恢复失败。部分文件可能已恢复 — 请检查 archived_sessions 与 .trash。", + "storage.trash.err.storage_mutation_busy": "另一项存储清理或恢复正在进行 — 请稍后再试。", + "storage.trash.err.db_reconcile_failed": "无法恢复 Codex 状态数据库行。", + "storage.trash.err.restore_failed": "恢复失败。", + "storage.trash.err.restore_worker_timeout": "恢复耗时过长(超过 10 分钟)已停止。", + "storage.trash.err.restore_worker_aborted": "关闭过程中恢复已取消。", + "storage.trash.err.restore_worker_failed": "恢复 worker 崩溃或意外失败。", "storage.policy.title": "自动清理策略", "storage.policy.help": "当归档大小超过阈值时可选批量清理。默认关闭——不会自动启用。", "storage.policy.loading": "正在加载策略…", diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 57e63a35d..171172666 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -46,6 +46,56 @@ interface CleanupResult { message?: string; } +interface TrashEntry { + id: string; + epoch: string; + fileCount: number; + bytes: number; + quarantinedAt?: number; + mode?: "quarantine" | "permanent"; +} + +interface TrashList { + entries: TrashEntry[]; +} + +interface RestoreResult { + ok: boolean; + count: number; + bytes: number; + trashDir?: string; + error?: string; + message?: string; +} + +const GB = 1024 ** 3; + +interface CleanupPolicy { + enabled: boolean; + trigger: { archivedBytesOver: number }; + target: { reduceToBytes?: number; removeOldestPercent?: number }; + schedule: "startup" | "daily" | "weekly" | "manual"; + mode: "quarantine" | "permanent"; + lastRun?: { at: number; freedBytes: number; removed: number }; + nextRun?: number; + job?: { + status: "idle" | "running"; + reason?: string; + startedAt?: number; + finishedAt?: number; + lastError?: string; + lastOutcome?: { + ok: boolean; + skipped?: string; + deferred?: string; + error?: string; + mode?: string; + freedBytes?: number; + removed?: number; + }; + }; +} + const BUCKET_TKEYS: Record = { sessions: "storage.bucket.sessions", archived_sessions: "storage.bucket.archived_sessions", @@ -58,6 +108,21 @@ const BUCKET_TKEYS: Record = { const PRESETS = [10, 25, 50] as const; +const localizedCatch = (e: unknown, fallback: string): string => { + if (!(e instanceof Error)) return fallback; + const msg = e.message; + if ( + msg === "Failed to fetch" + || msg.includes("NetworkError") + || msg.includes("network error") + || msg.includes("JSON") + || msg.includes("Unexpected end of") + ) { + return fallback; + } + return msg || fallback; +}; + function bucketLabel(bucket: StorageBucket, t: TFn): string { const tkey = BUCKET_TKEYS[bucket.key]; return tkey ? t(tkey) : bucket.label; @@ -180,6 +245,7 @@ function ArchivedCleanupPanel({ switch (code) { case "codex_busy": return t("storage.cleanup.err.codex_busy"); case "stale_preview": return t("storage.cleanup.err.stale_preview"); + case "restore_pending_overlap": return t("storage.cleanup.err.restore_pending_overlap"); case "referenced_history": return t("storage.cleanup.err.referenced_history"); case "invalid_digest": return t("storage.cleanup.err.invalid_digest"); case "invalid_mode": return t("storage.cleanup.err.invalid_mode"); @@ -198,21 +264,6 @@ function ArchivedCleanupPanel({ percent: new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 }).format(value / 100), }); - const localizedCatch = (e: unknown, fallback: string): string => { - if (!(e instanceof Error)) return fallback; - const msg = e.message; - if ( - msg === "Failed to fetch" - || msg.includes("NetworkError") - || msg.includes("network error") - || msg.includes("JSON") - || msg.includes("Unexpected end of") - ) { - return fallback; - } - return msg || fallback; - }; - const runPreview = async () => { setBusy(true); setError(null); @@ -377,32 +428,239 @@ function ArchivedCleanupPanel({ ); } -const GB = 1024 ** 3; +function QuarantineTrashPanel({ + apiBase, + locale, + t, + onDone, + reloadToken, + onEntriesChange, +}: { + apiBase: string; + locale: Locale; + t: TFn; + onDone: () => void; + reloadToken: number; + onEntriesChange?: (entries: TrashEntry[]) => void; +}) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [confirmEntry, setConfirmEntry] = useState(null); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const cancelRef = useRef(null); + const previousFocusRef = useRef(null); + const busyRef = useRef(false); + const loadGenerationRef = useRef(0); -interface CleanupPolicy { - enabled: boolean; - trigger: { archivedBytesOver: number }; - target: { reduceToBytes?: number; removeOldestPercent?: number }; - schedule: "startup" | "daily" | "weekly" | "manual"; - mode: "quarantine" | "permanent"; - lastRun?: { at: number; freedBytes: number; removed: number }; - nextRun?: number; - job?: { - status: "idle" | "running"; - reason?: string; - startedAt?: number; - finishedAt?: number; - lastError?: string; - lastOutcome?: { - ok: boolean; - skipped?: string; - deferred?: string; - error?: string; - mode?: string; - freedBytes?: number; - removed?: number; + useEffect(() => { + busyRef.current = busy; + }, [busy]); + + const closeConfirm = useCallback(() => setConfirmEntry(null), []); + + useEffect(() => { + if (!confirmEntry) return; + previousFocusRef.current = document.activeElement as HTMLElement | null; + cancelRef.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !busyRef.current) closeConfirm(); }; + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + previousFocusRef.current?.focus(); + }; + }, [confirmEntry, closeConfirm]); + + const loadTrash = useCallback(async (signal?: AbortSignal) => { + const generation = ++loadGenerationRef.current; + setLoading(true); + try { + const res = await fetch(`${apiBase}/api/storage/trash`, { signal }); + const json = await res.json() as TrashList & { error?: string }; + if (signal?.aborted || generation !== loadGenerationRef.current) return; + if (!res.ok) throw new Error(json.error ?? "list_failed"); + const next = Array.isArray(json.entries) ? json.entries : []; + setEntries(next); + onEntriesChange?.(next); + setError(null); + } catch (e) { + if (signal?.aborted || generation !== loadGenerationRef.current) return; + if (e instanceof DOMException && e.name === "AbortError") return; + setEntries([]); + onEntriesChange?.([]); + setError(localizedCatch(e, t("storage.trash.listFailed"))); + } finally { + if (generation === loadGenerationRef.current) setLoading(false); + } + }, [apiBase, t, onEntriesChange]); + + useEffect(() => { + const controller = new AbortController(); + const timeout = window.setTimeout(() => { + void loadTrash(controller.signal); + }, 0); + return () => { + window.clearTimeout(timeout); + loadGenerationRef.current += 1; + controller.abort(); + }; + }, [loadTrash, reloadToken]); + + const mapRestoreError = (code: string | undefined, fallback?: string) => { + switch (code) { + case "codex_busy": return t("storage.trash.err.codex_busy"); + case "invalid_trash": return t("storage.trash.err.invalid_trash"); + case "missing_trash": return t("storage.trash.err.missing_trash"); + case "dest_exists": return t("storage.trash.err.dest_exists"); + case "fs_failed": return t("storage.trash.err.fs_failed"); + case "db_reconcile_failed": return t("storage.trash.err.db_reconcile_failed"); + case "storage_mutation_busy": return t("storage.trash.err.storage_mutation_busy"); + case "restore_failed": return t("storage.trash.err.restore_failed"); + case "restore_worker_timeout": return t("storage.trash.err.restore_worker_timeout"); + case "restore_worker_aborted": return t("storage.trash.err.restore_worker_aborted"); + case "restore_worker_failed": + return fallback ?? t("storage.trash.err.restore_worker_failed"); + default: return fallback ?? t("storage.trash.restoreFailed"); + } }; + + const runRestore = async () => { + if (!confirmEntry) return; + setBusy(true); + setError(null); + try { + const res = await fetch(`${apiBase}/api/storage/trash/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: confirmEntry.id }), + }); + const json = await res.json() as RestoreResult; + if (!res.ok || !json.ok) { + throw new Error(mapRestoreError(json.error, json.message)); + } + closeConfirm(); + setStatus(t("storage.trash.done", { + count: String(json.count), + size: formatBytes(json.bytes, locale), + })); + onDone(); + } catch (e) { + setError(localizedCatch(e, t("storage.trash.restoreFailed"))); + } finally { + setBusy(false); + } + }; + + const formatWhen = (entry: TrashEntry) => { + const ms = entry.quarantinedAt ?? Number(entry.epoch.split("-")[0]); + if (!Number.isFinite(ms) || ms <= 0) return "—"; + return new Date(ms).toLocaleString(locale); + }; + + const modeLabel = (mode: TrashEntry["mode"]) => { + if (mode === "permanent") return t("storage.trash.mode.permanent"); + if (mode === "quarantine") return t("storage.trash.mode.quarantine"); + return "—"; + }; + + return ( +
+

{t("storage.trash.title")}

+

{t("storage.trash.help")}

+ + {status &&

{status}

} + {error && !confirmEntry &&

{error}

} + + {loading ? ( +

{t("storage.trash.loading")}

+ ) : entries.length === 0 ? ( +

{t("storage.trash.empty")}

+ ) : ( +
+ + + + + + + + + + + + {entries.map(entry => ( + + + + + + + + + ))} + +
{t("storage.trash.col.when")}{t("storage.trash.col.files")}{t("storage.trash.col.size")}{t("storage.trash.col.mode")}{t("storage.trash.col.id")} +
{formatWhen(entry)}{entry.fileCount}{formatBytes(entry.bytes, locale)}{modeLabel(entry.mode)}{entry.id} + +
+
+ )} + + {confirmEntry && ( +
!busy && closeConfirm()} + > +
e.stopPropagation()}> +

{t("storage.trash.confirmTitle")}

+

+ {t("storage.trash.confirmBody", { + count: String(confirmEntry.fileCount), + size: formatBytes(confirmEntry.bytes, locale), + id: confirmEntry.id, + })} +

+ {error &&

{error}

} +
+ + +
+
+
+ )} +
+ ); } function policyFieldsFromResponse(json: CleanupPolicy): CleanupPolicy { @@ -469,7 +727,6 @@ function AutoCleanupPolicyPanel({ useEffect(() => { const controller = new AbortController(); - // Defer so loadPolicy's setState is not synchronous inside the effect body. const timeout = window.setTimeout(() => { void loadPolicy(controller.signal); }, 0); @@ -555,7 +812,6 @@ function AutoCleanupPolicyPanel({ setError(null); setStatus(null); try { - // Persist current form values before running — abort if the form is invalid. const base = buildBody(); if (!base) { setError(t("storage.policy.invalid")); @@ -624,7 +880,6 @@ function AutoCleanupPolicyPanel({ outcome = job.lastOutcome; break; } - // Job finished so fast that startedAt advanced; accept matching finished marker. if (job.finishedAt && job.finishedAt >= startedAt && job.lastOutcome) { outcome = job.lastOutcome; break; @@ -849,6 +1104,9 @@ export default function Storage({ apiBase }: { apiBase: string }) { const { t, locale } = useI18n(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + const [trashReloadToken, setTrashReloadToken] = useState(0); + // Stamp trash awareness with apiBase so a base change invalidates without an effect. + const [trashInfo, setTrashInfo] = useState({ apiBase, settled: false, hasEntries: false }); const loadGenerationRef = useRef(0); const fetchStorage = useCallback(async (signal?: AbortSignal) => { @@ -880,15 +1138,30 @@ export default function Storage({ apiBase }: { apiBase: string }) { }; }, [fetchStorage]); + const refreshAll = useCallback(() => { + void fetchStorage(); + setTrashReloadToken(n => n + 1); + }, [fetchStorage]); + + const onTrashEntriesChange = useCallback((entries: TrashEntry[]) => { + setTrashInfo({ apiBase, settled: true, hasEntries: entries.length > 0 }); + }, [apiBase]); + + const trashSettled = trashInfo.apiBase === apiBase && trashInfo.settled; + const trashHasEntries = trashInfo.apiBase === apiBase && trashInfo.hasEntries; const failed = !loading && (!data || data.error !== undefined); - const empty = !loading && !failed && data!.total.fileCount === 0; + const empty = !loading && !failed && data!.total.fileCount === 0 && trashSettled && !trashHasEntries; const archivedCount = data?.buckets.find(b => b.key === "archived_sessions")?.fileCount ?? 0; + const showBody = Boolean(data) && !failed; + // While storage is empty, keep the trash panel mounted until it reports so we + // do not flash the empty state over a non-empty quarantine. + const showTrashWhileSettling = showBody && (data!.total.fileCount > 0 || !trashSettled || trashHasEntries); return ( <>

{t("storage.title")}

-
@@ -905,30 +1178,44 @@ export default function Storage({ apiBase }: { apiBase: string }) { apiBase={apiBase} locale={locale} t={t} - onDone={() => void fetchStorage()} + onDone={() => refreshAll()} /> ) : ( <> -
-
{t("storage.card.total")}
{formatBytes(data!.total.bytes, locale)}
-
{t("storage.card.files")}
{data!.total.fileCount.toLocaleString(locale)}
-
{t("storage.card.home")}
{data!.codexHome}
-
- - + {data && data.total.fileCount > 0 && ( + <> +
+
{t("storage.card.total")}
{formatBytes(data.total.bytes, locale)}
+
{t("storage.card.files")}
{data.total.fileCount.toLocaleString(locale)}
+
{t("storage.card.home")}
{data.codexHome}
+
+ + + + )} void fetchStorage()} + onDone={() => refreshAll()} /> {archivedCount > 0 && ( void fetchStorage()} + onDone={() => refreshAll()} + /> + )} + {showTrashWhileSettling && ( + refreshAll()} /> )} diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index d08e68e64..b333e0dbb 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -252,7 +252,7 @@ function parseSessionMetaLine(line: string): ParsedSessionMeta | null { * (codex-rs `apply_session_meta_from_item`). We base our patch on the most recent metadata so we * never resurrect a stale provider that a later app-written `session_meta` already changed. */ -function readLatestSessionMeta(path: string): ParsedSessionMeta | null { +export function readLatestSessionMeta(path: string): ParsedSessionMeta | null { const raw = readFileSync(path, "utf8"); const lines = raw.split("\n"); for (let i = lines.length - 1; i >= 0; i--) { @@ -265,6 +265,118 @@ function readLatestSessionMeta(path: string): ParsedSessionMeta | null { return null; } +/** + * Fields needed to re-insert a production-shaped `threads` row from a rollout JSONL when a + * Phase-2 quarantine predates full `satellite-backup.json` thread snapshots. + * + * Uses the same last-writer-wins `session_meta` fold as {@link readLatestSessionMeta}, plus the + * first user-message preview (codex-rs `list.rs` / `EventMsg::UserMessage` path). + */ +export interface RolloutThreadFields { + id: string; + modelProvider: string; + source: string; + firstUserMessage: string; + hasUserEvent: number; + cwd?: string; + historyMode?: string; + cliVersion?: string; +} + +function textFromContentParts(content: unknown): string | null { + if (typeof content === "string" && content.trim()) return content.trim(); + if (!Array.isArray(content)) return null; + const parts: string[] = []; + for (const part of content) { + if (!part || typeof part !== "object") continue; + const p = part as Record; + if (typeof p.text === "string" && p.text.trim()) parts.push(p.text.trim()); + else if (typeof p.input_text === "string" && p.input_text.trim()) parts.push(p.input_text.trim()); + } + const joined = parts.join("\n").trim(); + return joined || null; +} + +/** Extract the first user-message preview from a rollout line, or null. */ +function extractUserMessagePreview(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const record = parsed as { type?: unknown; payload?: unknown }; + const payload = record.payload; + if (!payload || typeof payload !== "object") return null; + const p = payload as Record; + + if (record.type === "event_msg") { + // codex-rs EventMsg::UserMessage — payload.type is "user_message" (or omitted in fixtures). + if (p.type === "user_message" || typeof p.message === "string") { + if (typeof p.message === "string" && p.message.trim()) return p.message.trim(); + const fromContent = textFromContentParts(p.content); + if (fromContent) return fromContent; + } + return null; + } + + if (record.type === "response_item") { + if (p.type === "message" && p.role === "user") { + return textFromContentParts(p.content); + } + } + return null; +} + +/** + * Reconstruct thread identity + listing fields from a staged/restored rollout JSONL. + * Returns null when the file is missing or has no parseable `session_meta`. + */ +export function readThreadFieldsFromRollout(path: string): RolloutThreadFields | null { + if (!path || !existsSync(path)) return null; + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch { + return null; + } + const lines = raw.split("\n"); + let latest: ParsedSessionMeta | null = null; + let firstUserMessage = ""; + for (const line of lines) { + if (!line) continue; + if (line.includes("\"session_meta\"")) { + const meta = parseSessionMetaLine(line); + if (meta) latest = meta; + } + if (!firstUserMessage) { + const preview = extractUserMessagePreview(line); + if (preview) firstUserMessage = preview; + } + } + if (!latest) return null; + const payload = latest.record.payload; + const id = typeof payload.id === "string" ? payload.id : ""; + if (!id) return null; + const modelProvider = typeof payload.model_provider === "string" && payload.model_provider + ? payload.model_provider + : "openai"; + const source = typeof payload.source === "string" && payload.source + ? payload.source + : "cli"; + return { + id, + modelProvider, + source, + firstUserMessage, + hasUserEvent: firstUserMessage.trim() ? 1 : 0, + ...(typeof payload.cwd === "string" ? { cwd: payload.cwd } : {}), + ...(typeof payload.history_mode === "string" ? { historyMode: payload.history_mode } : {}), + ...(typeof payload.cli_version === "string" ? { cliVersion: payload.cli_version } : {}), + }; +} + /** * Make a thread's rollout reflect a provider/source change by APPENDING a new `session_meta` line, * rather than rewriting line 1. The appended line clones the latest metadata payload (so no field diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 2d7d8ec68..e5270fc7d 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -34,7 +34,9 @@ import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; import { scanStorage } from "../../storage/scanner"; -import { executeArchivedCleanup, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode } from "../../storage/cleanup"; +import { executeArchivedCleanup, listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup"; +import { runArchivedCleanupJob } from "../../storage/cleanup-job"; +import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../../storage/restore-job"; import { normalizeStorageCleanupPolicy, parseStorageCleanupPolicyInput, @@ -288,7 +290,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise = { codex_busy: "Codex is using state.sqlite — try again after quitting Codex.", + storage_mutation_busy: "Another storage cleanup or restore is in progress — try again shortly.", stale_preview: "Archived files changed since preview — run Preview again.", + restore_pending_overlap: "Selected archives overlap an incomplete trash restore — finish or retry restore first.", referenced_history: "Selected archives are still referenced by forked or paginated history.", invalid_digest: "Preview digest is missing or invalid.", invalid_mode: "mode must be quarantine or permanent.", @@ -336,6 +344,99 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise ({ + id, + epoch, + fileCount, + bytes, + ...(quarantinedAt !== undefined ? { quarantinedAt } : {}), + ...(mode ? { mode } : {}), + })), + }); + } catch { + return jsonResponse({ error: "trash_list_failed", entries: [] }, 500); + } + } + + if ( + process.env.OPENCODEX_CLEANUP_TEST_HOOKS === "1" && + url.pathname === "/api/storage/trash/restore/test-stream" && + req.method === "GET" + ) { + const stream = getRestoreTrashTestStreamResponse(); + if (stream) return stream; + return jsonResponse({ error: "not_available" }, 404); + } + + if (url.pathname === "/api/storage/trash/restore" && req.method === "POST") { + let body: { id?: unknown }; + try { body = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); } + const id = typeof body?.id === "string" ? body.id : ""; + if (!id.trim()) { + return jsonResponse({ error: "invalid_trash", message: "Trash entry id is required." }, 400); + } + try { + const result = await runRestoreTrashEntryJob(id); + if (!result.ok) { + const status = + result.error === "codex_busy" + || result.error === "dest_exists" + || result.error === "storage_mutation_busy" + ? 409 + : result.error === "missing_trash" + ? 404 + : result.error === "invalid_trash" + ? 400 + : 500; + const messages: Record = { + invalid_trash: "Trash entry id is missing or invalid.", + missing_trash: "Trash entry was not found.", + codex_busy: "Codex is using state.sqlite — try again after quitting Codex.", + storage_mutation_busy: "Another storage cleanup or restore is in progress — try again shortly.", + dest_exists: "Restore destination already exists — remove or rename the archived file and retry.", + fs_failed: "Filesystem restore failed. Some files may already be restored — check archived_sessions and .trash.", + db_reconcile_failed: "Could not restore Codex state database rows.", + restore_failed: "Restore failed.", + restore_worker_timeout: "Restore took too long (over 10 minutes) and was stopped.", + restore_worker_aborted: "Restore was cancelled during shutdown.", + restore_worker_failed: "Restore worker crashed or failed unexpectedly.", + }; + const errorCode = result.error ?? "restore_failed"; + const baseMessage = messages[errorCode] ?? messages.restore_failed; + const message = + result.message && errorCode === "restore_worker_failed" + ? `${baseMessage} (${result.message})` + : baseMessage; + return jsonResponse({ + ok: false, + error: errorCode, + message, + count: result.count, + bytes: result.bytes, + restoredPaths: result.restoredPaths, + ...(result.trashDir ? { trashDir: result.trashDir } : {}), + }, status); + } + return jsonResponse({ + ok: true, + trashDir: result.trashDir, + count: result.count, + bytes: result.bytes, + restoredPaths: result.restoredPaths, + }); + } catch { + return jsonResponse({ + ok: false, + error: "restore_failed", + message: "Restore failed.", + }, 500); + } + } + if (url.pathname === "/api/storage/cleanup-policy/test-stream" && req.method === "GET") { const stream = getStorageCleanupPolicyTestStreamResponse(); if (stream) return stream; diff --git a/src/storage/cleanup-job.ts b/src/storage/cleanup-job.ts new file mode 100644 index 000000000..147b3978e --- /dev/null +++ b/src/storage/cleanup-job.ts @@ -0,0 +1,57 @@ +/** + * Single-flight wrapper for manual archived cleanup (management API). + * + * Shares the CODEX_HOME storage-mutation coordinator with trash restore and + * (Phase 3) policy-driven cleanup. + */ +import { resolveCodexHomeDir } from "../codex/home"; +import { + executeArchivedCleanup, + type CleanupResult, + type ExecuteCleanupOptions, +} from "./cleanup"; +import { + resetStorageMutationCoordinatorForTests, + setStorageMutationCoordinatorTestHooks, + withStorageMutationSlot, + type StorageMutationCoordinatorTestHooks, +} from "./storage-mutation-coordinator"; + +export type CleanupJobTestHooks = StorageMutationCoordinatorTestHooks; + +export function setArchivedCleanupJobTestHooks(hooks: CleanupJobTestHooks | null): void { + setStorageMutationCoordinatorTestHooks(hooks); +} + +export function resetArchivedCleanupJobForTests(): void { + resetStorageMutationCoordinatorForTests(); +} + +/** + * Run archived cleanup under the shared storage-mutation gate. + * Returns immediately with `storage_mutation_busy` when restore or another + * cleanup is in flight for the same CODEX_HOME. + */ +export async function runArchivedCleanupJob( + options: ExecuteCleanupOptions, +): Promise { + const codexHome = options.codexHome ?? resolveCodexHomeDir(); + const mode = options.mode; + const percent = options.percent; + const result = await withStorageMutationSlot("cleanup", codexHome, () => + executeArchivedCleanup(options), + ); + if (result && typeof result === "object" && "ok" in result && result.ok === false + && "error" in result && result.error === "storage_mutation_busy") { + return { + ok: false, + mode, + percent, + count: 0, + bytes: 0, + removedPaths: [], + error: "storage_mutation_busy", + }; + } + return result as CleanupResult; +} diff --git a/src/storage/cleanup.ts b/src/storage/cleanup.ts index b8bb9885f..8577d82a2 100644 --- a/src/storage/cleanup.ts +++ b/src/storage/cleanup.ts @@ -12,21 +12,28 @@ * satellite rows before staged files. Success never carries soft `dbWarning` / * `failedPaths`. */ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { + closeSync, existsSync, + fsyncSync, + linkSync, mkdirSync, + openSync, readdirSync, + readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, + writeSync, chmodSync, } from "node:fs"; import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; import { Database } from "bun:sqlite"; import { resolveCodexHomeDir } from "../codex/home"; +import { readThreadFieldsFromRollout } from "../codex/history-provider"; export const ARCHIVED_SESSIONS_DIR = "archived_sessions"; export const TRASH_DIR = ".trash"; @@ -39,9 +46,11 @@ export type CleanupErrorCode = | "invalid_digest" | "stale_preview" | "codex_busy" + | "storage_mutation_busy" | "fs_failed" | "db_reconcile_failed" | "referenced_history" + | "restore_pending_overlap" | "cleanup_failed"; export interface ArchivedCandidate { @@ -327,24 +336,113 @@ export function selectOldestPercent(candidates: ArchivedCandidate[], percent: nu const pct = clampPercent(percent); if (pct <= 0 || candidates.length === 0) return []; if (pct >= 100) return [...candidates]; - const n = Math.max(1, Math.floor((candidates.length * pct) / 100)); + const n = percentSelectionTargetCount(candidates.length, pct); return candidates.slice(0, n); } +/** Count implied by percent selection over the full candidate list. */ +export function percentSelectionTargetCount(totalCount: number, percent: number): number { + const pct = clampPercent(percent); + if (pct <= 0 || totalCount === 0) return 0; + if (pct >= 100) return totalCount; + return Math.max(1, Math.floor((totalCount * pct) / 100)); +} + +function candidateOverlapsPendingRestore( + candidate: ArchivedCandidate, + pendingDestRels: ReadonlySet, +): boolean { + if (pendingDestRels.size === 0) return false; + for (const rel of candidate.physicalRelPaths) { + if (pendingDestRels.has(rel)) return true; + } + return pendingDestRels.has(candidate.relPath); +} + +/** Drop cleanup candidates whose physical paths overlap an in-progress restore. */ +export function filterCandidatesExcludingPendingRestore( + candidates: ArchivedCandidate[], + codexHome: string = resolveCodexHomeDir(), +): ArchivedCandidate[] { + const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome); + if (pendingDestRels.size === 0) return candidates; + return candidates.filter(c => !candidateOverlapsPendingRestore(c, pendingDestRels)); +} + +/** + * Oldest-first percent selection that skips pending-restore destinations without + * consuming the percent budget, backfilling with the next oldest safe candidates. + */ +export function selectOldestPercentSkippingPendingRestore( + candidates: ArchivedCandidate[], + percent: number, + codexHome: string = resolveCodexHomeDir(), +): ArchivedCandidate[] { + const target = percentSelectionTargetCount(candidates.length, percent); + if (target === 0) return []; + const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome); + const out: ArchivedCandidate[] = []; + for (const c of candidates) { + if (candidateOverlapsPendingRestore(c, pendingDestRels)) continue; + out.push(c); + if (out.length >= target) break; + } + return out; +} + +/** + * Reduce archived total toward `reduceToBytes` using oldest safe candidates only. + * Pending-restore destinations are skipped and do not count toward bytes freed. + */ +export function selectReduceToBytesSkippingPendingRestore( + candidates: ArchivedCandidate[], + reduceToBytes: number, + codexHome: string = resolveCodexHomeDir(), +): ArchivedCandidate[] { + if (!Number.isFinite(reduceToBytes) || reduceToBytes < 0) return []; + const total = candidates.reduce((sum, c) => sum + c.bytes, 0); + if (total <= reduceToBytes) return []; + const need = total - reduceToBytes; + const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome); + const out: ArchivedCandidate[] = []; + let freed = 0; + for (const c of candidates) { + if (candidateOverlapsPendingRestore(c, pendingDestRels)) continue; + out.push(c); + freed += c.bytes; + if (freed >= need) break; + } + return out; +} + +/** Accepted destination paths from every valid in-progress restore marker under `.trash`. */ +export function collectRestorePendingAcceptedDestRels(codexHome: string): Set { + const out = new Set(); + const trashRoot = join(codexHome, TRASH_DIR); + if (!existsSync(trashRoot)) return out; + for (const name of readdirSync(trashRoot)) { + if (!TRASH_EPOCH_DIR.test(name)) continue; + const read = readRestorePending(join(trashRoot, name)); + if (read.status !== "valid") continue; + for (const rel of read.state.acceptedDestRels) out.add(rel); + } + return out; +} + export function previewArchivedCleanup( percent: number, codexHome: string = resolveCodexHomeDir(), ): CleanupPreview { const all = listArchivedCandidates(codexHome); - const selected = selectOldestPercent(all, percent); + const safe = selectOldestPercentSkippingPendingRestore(all, percent, codexHome); const pct = clampPercent(percent); return { codexHome, percent: pct, - count: selected.length, - bytes: selected.reduce((sum, c) => sum + c.bytes, 0), - digest: computePreviewDigest(selected, pct), - candidates: selected, + count: safe.length, + bytes: safe.reduce((sum, c) => sum + c.bytes, 0), + digest: computePreviewDigest(safe, pct), + candidates: safe, }; } @@ -353,14 +451,14 @@ export function previewExactArchivedCleanup( candidates: ArchivedCandidate[], codexHome: string = resolveCodexHomeDir(), ): CleanupPreview { - const selected = [...candidates]; + const safe = filterCandidatesExcludingPendingRestore(candidates, codexHome); return { codexHome, percent: 0, - count: selected.length, - bytes: selected.reduce((sum, c) => sum + c.bytes, 0), - digest: computeExactPreviewDigest(selected), - candidates: selected, + count: safe.length, + bytes: safe.reduce((sum, c) => sum + c.bytes, 0), + digest: computeExactPreviewDigest(safe), + candidates: safe, }; } @@ -386,6 +484,13 @@ export function resolveExactArchivedCandidates( function openDbWritable(dbPath: string, busyTimeoutMs = 100): Database { const db = new Database(dbPath); + try { + // bun:sqlite exposes a binding-level timeout; set both so Windows lock waits + // honor the caller's budget (pragma alone has been flaky under CI contention). + (db as Database & { timeout?: number }).timeout = busyTimeoutMs; + } catch { + /* older bindings */ + } try { db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`); } catch { @@ -601,6 +706,10 @@ type SqlRow = Record; interface SatelliteBackup { threadIds: string[]; + /** Full `threads` row images (SELECT *) captured under the state write lock. */ + threads?: SqlRow[]; + dynamicTools?: SqlRow[]; + spawnEdges?: SqlRow[]; logs?: { path: string; rows: SqlRow[] }; memories?: { path: string; @@ -618,6 +727,11 @@ interface SatelliteBackup { }; } +type SatelliteBackupRead = + | { status: "missing" } + | { status: "ok"; backup: SatelliteBackup } + | { status: "invalid" }; + interface ReconcileTestHooks { failAfterLogsMutation?: boolean; failAfterMemoriesMutation?: boolean; @@ -630,6 +744,30 @@ interface ReconcileTestHooks { } const SATELLITE_BACKUP_FILE = "satellite-backup.json"; +/** Marks an incomplete restore so retries can accept dest files and resume metadata. */ +const RESTORE_PENDING_FILE = "restore-pending.json"; + +type StagedFile = { from: string; to: string; relPath: string }; + +interface RestorePendingSections { + state: boolean; + logs: boolean; + memories: boolean; + goals: boolean; +} + +interface RestorePendingState { + version: 1; + filesRestored: true; + /** + * Planned CODEX_HOME-relative destinations for this restore attempt. + * Written before moves so a mid-loop failure can still accept placed dests + * on resume while finishing files that remain staged. + */ + acceptedDestRels: string[]; + /** Sections that still need reconciliation on retry. */ + pending: RestorePendingSections; +} function quoteIdent(name: string): string { return `"${name.replaceAll('"', '""')}"`; @@ -639,15 +777,132 @@ function selectRows(db: Database, sql: string, params: Array): return db.query>(sql).all(...params) as SqlRow[]; } -function insertRowsConflictIgnore(db: Database, table: string, rows: SqlRow[]): void { +function tableColumnNames(db: Database, table: string): Set { + if (!tableExists(db, table)) return new Set(); + const rows = db.query<{ name: string }, []>( + `PRAGMA table_info("${table.replaceAll('"', '""')}")`, + ).all(); + return new Set(rows.map(r => r.name)); +} + +/** Insert rows with ON CONFLICT DO NOTHING; returns only rows that were newly inserted. */ +function insertRowsConflictIgnore(db: Database, table: string, rows: SqlRow[]): SqlRow[] { + const inserted: SqlRow[] = []; + if (rows.length === 0) return inserted; + const allowed = tableColumnNames(db, table); for (const row of rows) { - const cols = Object.keys(row); + const cols = Object.keys(row).filter(c => allowed.has(c)); if (cols.length === 0) continue; - db.run( + const result = db.run( `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`, cols.map(c => row[c] as string | number | bigint | null | Uint8Array), ); + if (result.changes > 0) inserted.push(row); + } + return inserted; +} + +/** Snapshot state-DB dependents that cleanup deletes with the thread rows. */ +function snapshotStateDependents( + db: Database, + threadIds: string[], +): Pick { + const out: Pick = {}; + if (threadIds.length === 0 || !tableExists(db, "threads")) return out; + + const threads: SqlRow[] = []; + for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) { + const placeholders = chunk.map(() => "?").join(","); + threads.push(...selectRows(db, `SELECT * FROM threads WHERE id IN (${placeholders})`, chunk)); } + out.threads = threads; + + if (tableExists(db, "thread_dynamic_tools")) { + const dynamicTools: SqlRow[] = []; + for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) { + const placeholders = chunk.map(() => "?").join(","); + dynamicTools.push(...selectRows( + db, + `SELECT * FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`, + chunk, + )); + } + out.dynamicTools = dynamicTools; + } + + if (tableExists(db, "thread_spawn_edges")) { + const spawnEdges: SqlRow[] = []; + for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK)) { + const placeholders = chunk.map(() => "?").join(","); + spawnEdges.push(...selectRows( + db, + `SELECT * FROM thread_spawn_edges + WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`, + [...chunk, ...chunk], + )); + } + out.spawnEdges = spawnEdges; + } + + return out; +} + +/** Remap serialized absolute DB paths onto the newest DBs under the current Codex home. */ +function remapSatelliteBackupPaths( + backup: SatelliteBackup, + paths: RuntimeDbPaths, +): { ok: true; backup: SatelliteBackup } | { ok: false } { + const next: SatelliteBackup = { + threadIds: backup.threadIds, + ...(backup.threads ? { threads: backup.threads } : {}), + ...(backup.dynamicTools ? { dynamicTools: backup.dynamicTools } : {}), + ...(backup.spawnEdges ? { spawnEdges: backup.spawnEdges } : {}), + }; + if (backup.logs) { + if (!paths.logs) return { ok: false }; + next.logs = { ...backup.logs, path: paths.logs }; + } + if (backup.memories) { + if (!paths.memories) return { ok: false }; + next.memories = { ...backup.memories, path: paths.memories }; + } + if (backup.goals) { + if (!paths.goals) return { ok: false }; + next.goals = { ...backup.goals, path: paths.goals }; + } + return { ok: true, backup: next }; +} + +/** + * Same-volume move that never replaces an existing destination. + * + * `existsSync` + `renameSync` is TOCTOU: a live file created between the check + * and rename can be overwritten (Windows rename replaces files). Hard-link then + * unlink fails with EEXIST if `to` appears, which is what trash → archived_sessions + * restore needs. Callers under the same `CODEX_HOME` volume should not hit EXDEV. + */ +function renameNoReplace(from: string, to: string): void { + try { + linkSync(from, to); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + // Hard links unavailable (rare FS) — refuse rather than clobber via rename. + if (code === "EXDEV" || code === "EPERM" || code === "ENOTSUP" || code === "EINVAL") { + throw Object.assign(new Error("rename_no_replace_unsupported"), { code, cause: error }); + } + throw error; + } + try { + unlinkSync(from); + } catch (error) { + // Roll back the hard link so we do not leave the file at both paths. + try { unlinkSync(to); } catch { /* best-effort */ } + throw error; + } +} + +function isExistError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST"; } function updateRowFromSnapshot( @@ -739,6 +994,7 @@ interface SatelliteWriteLocks { function beginSatelliteWriteLocks( paths: RuntimeDbPaths, busyTimeoutMs: number, + only?: Partial>, ): SatelliteWriteLocks { const locks: SatelliteWriteLocks = {}; const order: Array<{ key: "logs" | "memories" | "goals"; path: string | null }> = [ @@ -748,6 +1004,7 @@ function beginSatelliteWriteLocks( ]; try { for (const { key, path } of order) { + if (only && !only[key]) continue; if (!path || !existsSync(path)) continue; const db = openDbWritable(path, busyTimeoutMs); try { @@ -982,11 +1239,17 @@ function deleteAndCommitSatellites( if (locks.memories && backup.memories) { deleteMemoriesInTx(locks.memories.db, backup.memories); if (backup.memories.consolidateTouched) { + // Capture under the write lock, but persist only after COMMIT+close. + // Holding BEGIN IMMEDIATE across writeFileSync lets Windows CI disk/AV + // latency stall the lock long enough for concurrent reopen hooks (and + // bun's default 5s test timeout) to hang — see PR #558 windows-latest. backup.memories.consolidatePostImage = readConsolidateGlobalJob(locks.memories.db); - writeSatelliteBackup(stageDir, backup); } commitSatelliteLock(locks.memories); locks.memories = undefined; + if (backup.memories.consolidateTouched) { + writeSatelliteBackup(stageDir, backup); + } if (hooks?.failAfterMemoriesMutation) throw new Error("test_fail_after_memories"); } if (locks.goals && backup.goals) { @@ -1152,6 +1415,10 @@ function reconcileDeletedThreads( satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs); try { backup = snapshotSatelliteBackupInLocks(satelliteLocks, threadIds); + const stateDeps = snapshotStateDependents(stateDb, threadIds); + backup.threads = stateDeps.threads; + backup.dynamicTools = stateDeps.dynamicTools; + backup.spawnEdges = stateDeps.spawnEdges; try { if (hooks?.failSatelliteBackupWrite) { throw new Error("test_fail_satellite_backup_write"); @@ -1183,7 +1450,7 @@ function reconcileDeletedThreads( deleteThreadsAndDependents(stateDb, threadIds); if (hooks?.failBeforeStateCommit) throw new Error("test_fail_before_state_commit"); stateDb.exec("COMMIT"); - clearSatelliteBackup(stageDir); + // Keep satellite-backup.json for quarantine restore; permanent purge removes the stage. return { ok: true, threads }; } catch (error) { if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks); @@ -1197,8 +1464,6 @@ function reconcileDeletedThreads( } } -type StagedFile = { from: string; to: string; relPath: string }; - function absFromRel(codexHome: string, relPath: string): string { if (relPath.includes("..") || isAbsolute(relPath) || /^[A-Za-z]:[\\/]/.test(relPath)) { throw new Error("invalid_rel_path"); @@ -1413,18 +1678,37 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR } let preview: CleanupPreview; + let unfilteredSelected: ArchivedCandidate[]; if (options.candidateRelPaths !== undefined) { const selected = resolveExactArchivedCandidates(options.candidateRelPaths, codexHome); if (selected === null) { return fail(mode, percent, "stale_preview"); } + unfilteredSelected = selected; preview = previewExactArchivedCleanup(selected, codexHome); } else { + const all = listArchivedCandidates(codexHome); + unfilteredSelected = selectOldestPercent(all, percent); preview = previewArchivedCleanup(percent, codexHome); } if (preview.digest.toLowerCase() !== options.digest.toLowerCase()) { + const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome); + const blocked = unfilteredSelected.filter(c => candidateOverlapsPendingRestore(c, pendingDestRels)); + const unfilteredDigest = options.candidateRelPaths !== undefined + ? computeExactPreviewDigest(unfilteredSelected) + : computePreviewDigest(unfilteredSelected, percent); + if ( + unfilteredDigest.toLowerCase() === options.digest.toLowerCase() + && blocked.length > 0 + ) { + return fail(mode, percent, "restore_pending_overlap"); + } return fail(mode, percent, "stale_preview"); } + const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome); + if (preview.candidates.some(c => candidateOverlapsPendingRestore(c, pendingDestRels))) { + return fail(mode, percent, "restore_pending_overlap"); + } if (preview.candidates.length === 0) { return { @@ -1568,9 +1852,12 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR digest: preview.digest, purgeIncomplete: true, purgedRelPaths: purge.purged.map(item => item.relPath), - entries: manifestEntries.filter(entry => - entry.physicalRelPaths.some(rel => survivingRelPaths.has(rel)), - ), + entries: manifestEntries + .map(entry => ({ + ...entry, + physicalRelPaths: entry.physicalRelPaths.filter(rel => survivingRelPaths.has(rel)), + })) + .filter(entry => entry.physicalRelPaths.length > 0), }, null, 2), ); } catch { /* best-effort: the pre-commit manifest is still on disk */ } @@ -1599,3 +1886,1046 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR removedPaths, }; } + +// --------------------------------------------------------------------------- +// Phase 2.1 — quarantine list + restore +// --------------------------------------------------------------------------- + +export type RestoreErrorCode = + | "invalid_trash" + | "missing_trash" + | "codex_busy" + | "storage_mutation_busy" + | "fs_failed" + | "db_reconcile_failed" + | "dest_exists" + | "restore_failed" + | "restore_worker_timeout" + | "restore_worker_aborted" + | "restore_worker_failed"; + +export interface TrashEntrySummary { + /** CODEX_HOME-relative path, e.g. `.trash/1700000000000`. */ + id: string; + /** Epoch directory name (may include collision suffix, e.g. `1700-1`). */ + epoch: string; + fileCount: number; + bytes: number; + quarantinedAt?: number; + mode?: CleanupMode; +} + +export interface RestoreResult { + ok: boolean; + trashDir?: string; + count: number; + bytes: number; + restoredPaths: string[]; + error?: RestoreErrorCode; + /** Optional operator-facing detail when the error code alone is insufficient. */ + message?: string; +} + +interface TrashManifest { + quarantinedAt?: number; + mode?: CleanupMode; + entries?: CleanupManifestEntry[]; +} + +/** Epoch dir names: digits, optionally `-N` from createExclusiveStageDir collision. */ +const TRASH_EPOCH_DIR = /^(\d+)(-\d+)?$/; + +/** + * Parse a trash `manifest.json` atomically. + * + * Any missing `entries` array, or any malformed entry / `physicalRelPaths` value / + * required field, rejects the **entire** manifest (returns null). Individual bad + * entries are never filtered out so a partial parse cannot silently drop evidence. + */ +function parseTrashManifest(raw: string): TrashManifest | null { + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return null; + const o = parsed as Record; + if (!Array.isArray(o.entries)) return null; + + const entries: CleanupManifestEntry[] = []; + for (const e of o.entries) { + if (!e || typeof e !== "object" || Array.isArray(e)) return null; + const entry = e as Record; + if (typeof entry.relPath !== "string" || entry.relPath.length === 0) return null; + if (typeof entry.bytes !== "number" || !Number.isFinite(entry.bytes)) return null; + if (typeof entry.mtimeMs !== "number" || !Number.isFinite(entry.mtimeMs)) return null; + if (!Array.isArray(entry.physicalRelPaths) || entry.physicalRelPaths.length === 0) return null; + const physical: string[] = []; + for (const p of entry.physicalRelPaths) { + // Do not strip bad elements — one malformed path invalidates the whole manifest. + if (typeof p !== "string" || p.length === 0) return null; + physical.push(p); + } + if ("threadId" in entry && typeof entry.threadId !== "string") return null; + if ("rolloutPath" in entry && typeof entry.rolloutPath !== "string") return null; + if ( + "archived" in entry + && entry.archived !== null + && typeof entry.archived !== "number" + ) { + return null; + } + entries.push({ + relPath: entry.relPath, + bytes: entry.bytes, + mtimeMs: entry.mtimeMs, + physicalRelPaths: physical, + ...(typeof entry.threadId === "string" ? { threadId: entry.threadId } : {}), + ...(typeof entry.rolloutPath === "string" ? { rolloutPath: entry.rolloutPath } : {}), + ...(entry.archived === null || typeof entry.archived === "number" + ? { archived: entry.archived as number | null } + : {}), + }); + } + + const out: TrashManifest = { entries }; + if (typeof o.quarantinedAt === "number" && Number.isFinite(o.quarantinedAt)) { + out.quarantinedAt = o.quarantinedAt; + } + if (o.mode === "quarantine" || o.mode === "permanent") out.mode = o.mode; + return out; + } catch { + return null; + } +} + +/** + * Validate a trash entry id as a single `.trash/` segment under CODEX_HOME. + * Returns the absolute stage directory, or null when the id is unsafe / missing. + */ +export function resolveTrashStageDir( + trashId: string, + codexHome: string, +): { ok: true; stageDir: string; id: string } | { ok: false; error: RestoreErrorCode } { + const normalized = toForwardSlash(trashId.trim()).replace(/\/+$/, ""); + if (!normalized.startsWith(`${TRASH_DIR}/`)) return { ok: false, error: "invalid_trash" }; + const rest = normalized.slice(TRASH_DIR.length + 1); + if (!rest || rest.includes("/") || rest.includes("\\") || rest.includes("..")) { + return { ok: false, error: "invalid_trash" }; + } + if (!TRASH_EPOCH_DIR.test(rest)) return { ok: false, error: "invalid_trash" }; + let stageDir: string; + try { + stageDir = absFromRel(codexHome, `${TRASH_DIR}/${rest}`); + } catch { + return { ok: false, error: "invalid_trash" }; + } + if (!existsSync(stageDir)) return { ok: false, error: "missing_trash" }; + try { + if (!statSync(stageDir).isDirectory()) return { ok: false, error: "invalid_trash" }; + } catch { + return { ok: false, error: "missing_trash" }; + } + return { ok: true, stageDir, id: `${TRASH_DIR}/${rest}` }; +} + +function sumTrashEntryBytes(stageDir: string, manifest: TrashManifest | null): { + fileCount: number; + bytes: number; +} { + let fileCount = 0; + let bytes = 0; + let names: string[] = []; + try { + names = readdirSync(stageDir); + } catch { + return { fileCount: 0, bytes: 0 }; + } + for (const name of names) { + if ( + name === "manifest.json" + || name === SATELLITE_BACKUP_FILE + || name === RESTORE_PENDING_FILE + ) { + continue; + } + if (!isRolloutFileName(name)) continue; + try { + const st = statSync(join(stageDir, name)); + if (!st.isFile()) continue; + fileCount += 1; + bytes += st.size; + } catch { /* */ } + } + // Prefer live FS counts; fall back to manifest totals when the stage is empty of rollouts. + if (fileCount === 0 && manifest?.entries?.length) { + fileCount = manifest.entries.reduce((n, e) => n + Math.max(1, e.physicalRelPaths.length), 0); + bytes = manifest.entries.reduce((n, e) => n + (e.bytes || 0), 0); + } + return { fileCount, bytes }; +} + +/** List quarantine entries under `CODEX_HOME/.trash/` (relative ids only). */ +export function listTrashEntries( + codexHome: string = resolveCodexHomeDir(), +): TrashEntrySummary[] { + const trashRoot = join(codexHome, TRASH_DIR); + let names: string[] = []; + try { + names = readdirSync(trashRoot); + } catch { + return []; + } + const out: TrashEntrySummary[] = []; + for (const name of names) { + if (!TRASH_EPOCH_DIR.test(name)) continue; + const stageDir = join(trashRoot, name); + try { + if (!statSync(stageDir).isDirectory()) continue; + } catch { + continue; + } + let manifest: TrashManifest | null = null; + try { + manifest = parseTrashManifest(readFileSync(join(stageDir, "manifest.json"), "utf8")); + } catch { + manifest = null; + } + const { fileCount, bytes } = sumTrashEntryBytes(stageDir, manifest); + // Skip empty collision placeholders left behind without a manifest or rollouts. + if (fileCount === 0 && !manifest?.entries?.length) { + try { + if (!existsSync(join(stageDir, "manifest.json"))) continue; + } catch { + continue; + } + } + out.push({ + id: `${TRASH_DIR}/${name}`, + epoch: name, + fileCount, + bytes, + ...(manifest?.quarantinedAt !== undefined ? { quarantinedAt: manifest.quarantinedAt } : {}), + ...(manifest?.mode ? { mode: manifest.mode } : {}), + }); + } + out.sort((a, b) => { + const aq = a.quarantinedAt ?? (Number(a.epoch.split("-")[0]) || 0); + const bq = b.quarantinedAt ?? (Number(b.epoch.split("-")[0]) || 0); + return bq - aq || b.epoch.localeCompare(a.epoch); + }); + return out; +} + +function readSatelliteBackupFile(stageDir: string): SatelliteBackupRead { + const path = join(stageDir, SATELLITE_BACKUP_FILE); + if (!existsSync(path)) return { status: "missing" }; + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!raw || typeof raw !== "object") return { status: "invalid" }; + const o = raw as SatelliteBackup; + if (!Array.isArray(o.threadIds)) return { status: "invalid" }; + return { status: "ok", backup: o }; + } catch { + // File exists but is truncated / malformed — distinct from a missing backup. + return { status: "invalid" }; + } +} + +function isSqlRowArray(value: unknown): value is SqlRow[] { + return Array.isArray(value) && value.every(row => row && typeof row === "object" && !Array.isArray(row)); +} + +/** True when a snapshotted thread row covers every NOT NULL column on the live schema. */ +function threadSnapshotCoversRequiredColumns(row: SqlRow, requiredCols: string[]): boolean { + for (const col of requiredCols) { + if (!(col in row) || row[col] === undefined) return false; + } + return true; +} + +function requiredThreadColumnNames(db: Database): string[] { + if (!tableExists(db, "threads")) return []; + const rows = db.query<{ name: string; notnull: number }, []>( + `PRAGMA table_info("threads")`, + ).all(); + return rows.filter(r => r.notnull === 1).map(r => r.name); +} + +/** + * Build a production-shaped thread row for schemas that predate full satellite snapshots. + * Prefer `readThreadFieldsFromRollout` (canonical history/session_meta path); fall back to + * the sparse manifest fields only when the live schema does not require model/source/message. + */ +function reconstructThreadRowFromRollout( + entry: CleanupManifestEntry, + rolloutAbsPath: string, + allowedCols: Set, + requiredCols: string[], +): SqlRow | null { + if (typeof entry.threadId !== "string" || typeof entry.rolloutPath !== "string") return null; + + const fields = readThreadFieldsFromRollout(rolloutAbsPath); + const row: SqlRow = { + id: entry.threadId, + rollout_path: entry.rolloutPath, + }; + + if (fields) { + // Prefer manifest thread id (binding) but keep rollout-derived listing fields. + if (allowedCols.has("model_provider")) row.model_provider = fields.modelProvider; + if (allowedCols.has("source")) row.source = fields.source; + if (allowedCols.has("first_user_message")) row.first_user_message = fields.firstUserMessage; + if (allowedCols.has("has_user_event")) row.has_user_event = fields.hasUserEvent; + if (allowedCols.has("cwd") && fields.cwd !== undefined) row.cwd = fields.cwd; + if (allowedCols.has("history_mode") && fields.historyMode !== undefined) { + row.history_mode = fields.historyMode; + } + if (allowedCols.has("cli_version") && fields.cliVersion !== undefined) { + row.cli_version = fields.cliVersion; + } + } + + if (allowedCols.has("archived")) { + row.archived = entry.archived ?? 1; + } + if (allowedCols.has("archived_at")) { + row.archived_at = null; + } + + // Fill remaining NOT NULL columns with safe empties when the rollout lacked them + // (e.g. fixture rollouts without a user turn still need first_user_message = ''). + for (const col of requiredCols) { + if (row[col] !== undefined) continue; + if (col === "id" || col === "rollout_path") continue; + if (col === "model_provider") row[col] = "openai"; + else if (col === "source") row[col] = "cli"; + else if (col === "first_user_message") row[col] = ""; + else if (col === "has_user_event") row[col] = 0; + else if (col === "archived") row[col] = entry.archived ?? 1; + else return null; // unknown required column we cannot invent + } + + // If the schema requires listing fields, refuse when the rollout was unreadable. + const needsSessionMeta = requiredCols.some( + c => c === "model_provider" || c === "source" || c === "first_user_message", + ); + if (needsSessionMeta && !fields) return null; + + return row; +} + +type RestorePendingRead = + | { status: "missing" } + | { status: "valid"; state: RestorePendingState } + | { status: "invalid" }; + +let _restorePendingSeq = 0; + +function parseRestorePendingState(raw: unknown): RestorePendingState | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const o = raw as Record; + if (o.version !== 1 || o.filesRestored !== true) return null; + if (!Array.isArray(o.acceptedDestRels)) return null; + const acceptedDestRels = o.acceptedDestRels.filter((r): r is string => typeof r === "string"); + if (acceptedDestRels.length !== o.acceptedDestRels.length) return null; + const pendingRaw = o.pending; + if (!pendingRaw || typeof pendingRaw !== "object" || Array.isArray(pendingRaw)) return null; + const p = pendingRaw as Record; + if ( + typeof p.state !== "boolean" + || typeof p.logs !== "boolean" + || typeof p.memories !== "boolean" + || typeof p.goals !== "boolean" + ) { + return null; + } + return { + version: 1, + filesRestored: true, + acceptedDestRels, + pending: { + state: p.state, + logs: p.logs, + memories: p.memories, + goals: p.goals, + }, + }; +} + +/** + * Distinguish a missing marker from a present-but-malformed one. An invalid marker + * must never be treated as a fresh restore (that would ignore already-moved files). + */ +function readRestorePending(stageDir: string): RestorePendingRead { + const path = join(stageDir, RESTORE_PENDING_FILE); + if (!existsSync(path)) return { status: "missing" }; + try { + const state = parseRestorePendingState(JSON.parse(readFileSync(path, "utf8")) as unknown); + if (!state) return { status: "invalid" }; + return { status: "valid", state }; + } catch { + return { status: "invalid" }; + } +} + +/** + * Atomically replace restore-pending.json: private temp in the stage, fsync, then rename. + * An interrupted update leaves the previous valid marker intact. + */ +function writeRestorePending( + stageDir: string, + state: RestorePendingState, + options?: { failBeforeRename?: boolean; failWrite?: boolean }, +): void { + if (options?.failWrite) throw new Error("test_fail_pending_write"); + const dest = join(stageDir, RESTORE_PENDING_FILE); + const tmp = join(stageDir, `${RESTORE_PENDING_FILE}.${process.pid}.${++_restorePendingSeq}.tmp`); + const payload = JSON.stringify(state); + const fd = openSync(tmp, "w", 0o600); + try { + writeSync(fd, payload, null, "utf8"); + fsyncSync(fd); + } catch (error) { + try { closeSync(fd); } catch { /* */ } + try { unlinkSync(tmp); } catch { /* */ } + throw error; + } + closeSync(fd); + chmodPrivatePath(tmp, 0o600); + if (options?.failBeforeRename) { + try { unlinkSync(tmp); } catch { /* */ } + throw new Error("test_fail_pending_rename"); + } + try { + renameSync(tmp, dest); + } catch (error) { + try { unlinkSync(tmp); } catch { /* */ } + throw error; + } +} + +function restoreThreadsFromManifest( + stateDbPath: string | null, + entries: CleanupManifestEntry[], + backup: SatelliteBackup | null, + busyTimeoutMs: number, + codexHome: string, +): { ok: true } | ReconcileErr { + const manifestThreadIds = entries + .map(e => e.threadId) + .filter((id): id is string => typeof id === "string"); + const backupThreadIds = backup?.threadIds ?? []; + const needsThreads = manifestThreadIds.length > 0 + || backupThreadIds.length > 0 + || Boolean(backup?.threads?.length); + + if (needsThreads && (!stateDbPath || !existsSync(stateDbPath))) { + return { ok: false, error: "db_reconcile_failed" }; + } + if (!stateDbPath || !existsSync(stateDbPath)) { + return { ok: true }; + } + + const result = withWritableDb(stateDbPath, busyTimeoutMs, db => { + if (!tableExists(db, "threads")) throw new Error("missing_threads_table"); + + const requiredCols = requiredThreadColumnNames(db); + const allowedCols = tableColumnNames(db, "threads"); + const snapshotThreads = backup?.threads && isSqlRowArray(backup.threads) + ? backup.threads + : []; + const completeSnapshots = snapshotThreads.filter(row => + threadSnapshotCoversRequiredColumns(row, requiredCols), + ); + const coveredIds = new Set( + completeSnapshots + .map(r => r.id) + .filter((id): id is string => typeof id === "string"), + ); + + // Legacy Phase-2 quarantine (no / incomplete satellite thread snapshots): reconstruct + // every required column from the restored rollout via the history-provider session path. + const toReconstruct = entries.filter( + e => typeof e.threadId === "string" + && typeof e.rolloutPath === "string" + && !coveredIds.has(e.threadId!), + ); + const reconstructed: SqlRow[] = []; + for (const entry of toReconstruct) { + let abs: string; + try { + abs = absFromRel(codexHome, entry.rolloutPath!); + } catch { + // Prefer the first restored physical path under archived_sessions. + const rel = entry.physicalRelPaths[0]; + if (!rel) throw new Error("missing_rollout_for_thread"); + abs = absFromRel(codexHome, rel); + } + // .jsonl.zst cannot be parsed here — require a plain .jsonl sibling or path. + if (abs.endsWith(ZST_SUFFIX)) { + const plain = abs.slice(0, -".zst".length); + if (existsSync(plain)) abs = plain; + } + const row = reconstructThreadRowFromRollout(entry, abs, allowedCols, requiredCols); + if (!row) throw new Error("thread_reconstruct_failed"); + reconstructed.push(row); + } + + if (completeSnapshots.length > 0) { + insertRowsConflictIgnore(db, "threads", completeSnapshots); + } + if (reconstructed.length > 0) { + insertRowsConflictIgnore(db, "threads", reconstructed); + } + + if (backup?.dynamicTools && isSqlRowArray(backup.dynamicTools) && tableExists(db, "thread_dynamic_tools")) { + insertRowsConflictIgnore(db, "thread_dynamic_tools", backup.dynamicTools); + } + if (backup?.spawnEdges && isSqlRowArray(backup.spawnEdges) && tableExists(db, "thread_spawn_edges")) { + insertRowsConflictIgnore(db, "thread_spawn_edges", backup.spawnEdges); + } + }); + if (!result.ok) return result; + return { ok: true }; +} + +function isSafeArchivedPhysicalRel(rel: string): boolean { + const normalized = toForwardSlash(rel); + if (!normalized.startsWith(`${ARCHIVED_SESSIONS_DIR}/`)) return false; + if (normalized.includes("..")) return false; + const rest = normalized.slice(ARCHIVED_SESSIONS_DIR.length + 1); + if (!rest || rest.includes("/")) return false; + return isRolloutFileName(rest); +} + +/** Test-only failure injection for restore atomicity regressions. */ +export interface RestoreTestHooks { + /** After state threads/dependents commit, before satellite commits. */ + failAfterStateCommit?: boolean; + /** After the first satellite DB commit (logs → memories → goals). */ + failAfterFirstSatelliteCommit?: boolean; + /** When the leftover staged-rollout completeness gate runs. */ + failAtLeftoverStageGate?: boolean; + /** Fail the initial restore-pending.json write (before any file moves). */ + failInitialPendingWrite?: boolean; + /** Fail a later pending update after the temp is written but before rename. */ + failPendingWriteBeforeRename?: boolean; + /** Crash immediately after file moves (marker already durable). */ + failAfterFileMoves?: boolean; + /** + * After this many successful rollout moves in the current attempt, throw. + * Exercises mid-loop failure with some dests placed and others still staged. + */ + failAfterMoveCount?: number; + /** Fail renaming the completed stage to a non-listable tombstone dir. */ + failStageTombstoneRename?: boolean; + /** After tombstone rename, skip best-effort tombstone delete (orphan is OK). */ + failTombstoneDelete?: boolean; + /** + * Test-only: spin-wait this many ms after rollout file moves, before DB + * reconcile, so cleanup can race an in-flight restore. + */ + holdAfterFileMovesMs?: number; +} + +/** + * Resume must not clear owed satellite work when the matching backup section is + * absent — fail closed per section instead. + */ +function failClosedSatelliteResume( + priorPending: RestorePendingState, + satelliteBackup: SatelliteBackup | null, +): RestoreErrorCode | null { + const owed = priorPending.pending; + if (!owed.logs && !owed.memories && !owed.goals) return null; + if (!satelliteBackup) return "db_reconcile_failed"; + if (owed.logs && !satelliteBackup.logs) return "db_reconcile_failed"; + if (owed.memories && !satelliteBackup.memories) return "db_reconcile_failed"; + if (owed.goals && !satelliteBackup.goals) return "db_reconcile_failed"; + return null; +} + +/** + * Successful restore finalization: rename the stage to a tombstone name that + * `listTrashEntries` ignores, then delete the tombstone best-effort. A failed + * rename leaves the original stage (and all evidence) intact for retry. + */ +function finalizeRestoredStage( + stageDir: string, + codexHome: string, + hooks?: Pick, +): boolean { + const trashRoot = join(codexHome, TRASH_DIR); + const epoch = basename(stageDir); + const tombstoneName = `.tombstone-${epoch}-${randomUUID()}`; + const tombstonePath = join(trashRoot, tombstoneName); + try { + if (hooks?.failStageTombstoneRename) throw new Error("test_fail_stage_tombstone_rename"); + renameSync(stageDir, tombstonePath); + } catch { + return false; + } + if (!hooks?.failTombstoneDelete) { + try { rmSync(tombstonePath, { recursive: true, force: true }); } catch { /* best-effort */ } + } + return true; +} + +/** + * Restore one quarantine entry: move JSONL back, re-insert threads (+ satellites + * when satellite-backup.json is present), then remove the trash directory. + * + * Late failures after files have moved never compensate metadata or restage. + * Instead they persist `restore-pending.json` (accepted dest paths + which + * state/logs/memories/goals sections still need work) atomically *before* any + * rollout move, then update it after each section so a retry can accept existing + * destinations and resume only missing metadata. + */ +export function restoreTrashEntry( + trashId: string, + options?: { + codexHome?: string; + busyTimeoutMs?: number; + _test?: RestoreTestHooks; + }, +): RestoreResult { + const codexHome = options?.codexHome ?? resolveCodexHomeDir(); + const busyTimeoutMs = options?.busyTimeoutMs ?? 100; + const hooks = options?._test; + + const resolved = resolveTrashStageDir(trashId, codexHome); + if (!resolved.ok) { + return { ok: false, count: 0, bytes: 0, restoredPaths: [], error: resolved.error }; + } + const { stageDir, id } = resolved; + + let manifestRaw: string; + try { + manifestRaw = readFileSync(join(stageDir, "manifest.json"), "utf8"); + } catch { + return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" }; + } + const manifest = parseTrashManifest(manifestRaw); + if (!manifest?.entries?.length) { + return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" }; + } + + const pendingRead = readRestorePending(stageDir); + if (pendingRead.status === "invalid") { + // Malformed marker means an incomplete restore may already have moved files; + // never treat it as a fresh restore. + return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" }; + } + const priorPending = pendingRead.status === "valid" ? pendingRead.state : null; + const acceptedDest = new Set(priorPending?.acceptedDestRels ?? []); + + // Partial permanent purges may leave only a subset of physical files on disk — + // trim to survivors rather than failing the whole entry for a purged twin. + // Resume also treats already-restored accepted destinations as survivors. + const entries: CleanupManifestEntry[] = []; + for (const entry of manifest.entries) { + if (!entry.physicalRelPaths.every(isSafeArchivedPhysicalRel)) { + return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" }; + } + const surviving = entry.physicalRelPaths.filter(rel => { + if (existsSync(join(stageDir, basename(rel)))) return true; + if (!acceptedDest.has(rel)) return false; + try { + return existsSync(absFromRel(codexHome, rel)); + } catch { + return false; + } + }); + if (surviving.length === 0) { + return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" }; + } + entries.push({ ...entry, physicalRelPaths: surviving }); + } + + const paths = discoverRuntimeDbPaths(codexHome); + const backupRead = readSatelliteBackupFile(stageDir); + if (backupRead.status === "invalid") { + return { + ok: false, + trashDir: id, + count: 0, + bytes: 0, + restoredPaths: [], + error: "db_reconcile_failed", + }; + } + + let satelliteBackup: SatelliteBackup | null = null; + if (backupRead.status === "ok") { + const remapped = remapSatelliteBackupPaths(backupRead.backup, paths); + if (!remapped.ok) { + return { + ok: false, + trashDir: id, + count: 0, + bytes: 0, + restoredPaths: [], + error: "db_reconcile_failed", + }; + } + satelliteBackup = remapped.backup; + } + + if (priorPending) { + const resumeErr = failClosedSatelliteResume(priorPending, satelliteBackup); + if (resumeErr) { + return { + ok: false, + trashDir: id, + count: 0, + bytes: 0, + restoredPaths: [], + error: resumeErr, + }; + } + } + + const pendingSections: RestorePendingSections = { + state: priorPending ? priorPending.pending.state : true, + logs: priorPending ? priorPending.pending.logs : Boolean(satelliteBackup?.logs), + memories: priorPending ? priorPending.pending.memories : Boolean(satelliteBackup?.memories), + goals: priorPending ? priorPending.pending.goals : Boolean(satelliteBackup?.goals), + }; + + const needAnySatellite = pendingSections.logs || pendingSections.memories || pendingSections.goals; + if (pendingSections.state) { + const needsThreads = entries.some(e => typeof e.threadId === "string") + || Boolean(satelliteBackup?.threadIds?.length) + || Boolean(satelliteBackup?.threads?.length); + if (needsThreads && (!paths.state || !existsSync(paths.state))) { + return { + ok: false, + trashDir: id, + count: 0, + bytes: 0, + restoredPaths: [], + error: "db_reconcile_failed", + }; + } + const probe = probeStateDbWritable(codexHome, busyTimeoutMs); + if (!probe.ok) { + return { + ok: false, + trashDir: id, + count: 0, + bytes: 0, + restoredPaths: [], + error: probe.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed", + }; + } + } + + // Acquire only the satellite locks still needed so a busy DB for an already- + // finished section cannot block resume. Locks happen before moves on a fresh + // attempt so failure stays retryable (nothing has left the stage yet). + let satelliteLocks: SatelliteWriteLocks | undefined; + if (needAnySatellite) { + try { + satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs, { + logs: pendingSections.logs, + memories: pendingSections.memories, + goals: pendingSections.goals, + }); + } catch (error) { + return { + ok: false, + trashDir: id, + count: 0, + bytes: 0, + restoredPaths: [], + error: mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed", + }; + } + } + + const failBeforeMoves = (error: RestoreErrorCode): RestoreResult => { + if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks); + return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error }; + }; + + // Plan renames: staged basename → original archived_sessions path. + // Resume accepts destinations already restored by this incomplete attempt. + const alreadyMoved: StagedFile[] = []; + const toMove: StagedFile[] = []; + for (const entry of entries) { + for (const rel of entry.physicalRelPaths) { + const base = basename(rel); + const from = join(stageDir, base); + let to: string; + try { + to = absFromRel(codexHome, rel); + } catch { + return failBeforeMoves("invalid_trash"); + } + const fromExists = existsSync(from); + const toExists = existsSync(to); + if (toExists && acceptedDest.has(rel) && !fromExists) { + alreadyMoved.push({ from, to, relPath: rel }); + continue; + } + if (toExists) { + return failBeforeMoves("dest_exists"); + } + if (!fromExists) { + return failBeforeMoves("fs_failed"); + } + toMove.push({ from, to, relPath: rel }); + } + } + + const planned = [...alreadyMoved, ...toMove]; + const restoredPaths = [...new Set(entries.map(e => e.relPath))]; + const bytes = entries.reduce((sum, e) => sum + (e.bytes || 0), 0); + const partialCounts = { count: restoredPaths.length, bytes, restoredPaths }; + + let pendingWriteCount = 0; + const persistPending = (): void => { + pendingWriteCount += 1; + const isInitial = pendingWriteCount === 1; + writeRestorePending( + stageDir, + { + version: 1, + filesRestored: true, + acceptedDestRels: planned.map(m => m.relPath), + pending: { ...pendingSections }, + }, + { + failWrite: Boolean(isInitial && hooks?.failInitialPendingWrite), + failBeforeRename: Boolean(!isInitial && hooks?.failPendingWriteBeforeRename), + }, + ); + }; + + // Durable resume marker before any rollout leaves the stage. Crash after a + // later move can still accept destinations from this marker. + try { + persistPending(); + } catch { + return failBeforeMoves("fs_failed"); + } + + const newlyMoved: StagedFile[] = []; + try { + mkdirSync(join(codexHome, ARCHIVED_SESSIONS_DIR), { recursive: true }); + for (const item of toMove) { + // Atomic no-replace (.trash ↔ archived_sessions). Mid-loop failure keeps + // already-placed dests and the durable planned acceptedDestRels marker. + renameNoReplace(item.from, item.to); + newlyMoved.push(item); + if ( + hooks?.failAfterMoveCount !== undefined + && newlyMoved.length >= hooks.failAfterMoveCount + ) { + throw new Error("test_fail_after_move_count"); + } + } + } catch (error) { + // Marker was written before any move. Never reverse successful renames or + // drop/narrow acceptedDestRels — resume must accept placed dests and finish + // the remaining staged files. + if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks); + const placed = [...alreadyMoved, ...newlyMoved]; + const placedPhysical = new Set(placed.map(m => m.relPath)); + const partialEntries = entries.filter(e => + e.physicalRelPaths.every(rel => placedPhysical.has(rel)), + ); + const midMoveRestored = [...new Set(partialEntries.map(e => e.relPath))]; + return { + ok: false, + trashDir: id, + count: midMoveRestored.length, + bytes: partialEntries.reduce((sum, e) => sum + (e.bytes || 0), 0), + restoredPaths: midMoveRestored, + error: isExistError(error) ? "dest_exists" : "fs_failed", + }; + } + + const moved = [...alreadyMoved, ...newlyMoved]; + + /** + * Never compensate DBs or restage files after moves. Keep restored files, + * persist which sections remain, and return accurate partial counts. + */ + const abortAfterMoves = (error: RestoreErrorCode): RestoreResult => { + if (satelliteLocks) { + rollbackAllSatelliteLocks(satelliteLocks); + satelliteLocks = undefined; + } + try { + persistPending(); + } catch { + /* best-effort — files already restored; prior atomic marker remains */ + } + return { ok: false, trashDir: id, ...partialCounts, error }; + }; + + if (hooks?.holdAfterFileMovesMs !== undefined) { + const holdMs = Math.max(0, Math.floor(hooks.holdAfterFileMovesMs)); + if (holdMs > 0) { + const deadline = Date.now() + holdMs; + while (Date.now() < deadline) { /* test-only spin wait */ } + } + } + + if (hooks?.failAfterFileMoves) { + return abortAfterMoves("fs_failed"); + } + + if (pendingSections.state) { + const threadsRestored = restoreThreadsFromManifest( + paths.state, + entries, + satelliteBackup, + busyTimeoutMs, + codexHome, + ); + if (!threadsRestored.ok) { + return abortAfterMoves( + threadsRestored.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed", + ); + } + pendingSections.state = false; + try { + persistPending(); + } catch { + return abortAfterMoves("fs_failed"); + } + } + + if (hooks?.failAfterStateCommit) { + return abortAfterMoves("db_reconcile_failed"); + } + + if (satelliteLocks && satelliteBackup) { + const locks = satelliteLocks; + try { + // Commit one satellite DB at a time; uncommitted txs roll back via + // rollbackAllSatelliteLocks. Completed sections are cleared in pending. + if (pendingSections.logs && satelliteBackup.logs) { + if (!locks.logs) throw new Error("missing_logs_lock"); + if (!tableExists(locks.logs.db, "logs")) throw new Error("missing_logs_table"); + insertRowsConflictIgnore(locks.logs.db, "logs", satelliteBackup.logs.rows); + commitSatelliteLock(locks.logs); + locks.logs = undefined; + pendingSections.logs = false; + persistPending(); + if (hooks?.failAfterFirstSatelliteCommit) { + throw new Error("test_fail_after_first_satellite"); + } + } + if (pendingSections.memories && satelliteBackup.memories) { + if (!locks.memories) throw new Error("missing_memories_lock"); + const mem = satelliteBackup.memories; + if (!tableExists(locks.memories.db, "stage1_outputs")) { + throw new Error("missing_stage1_outputs_table"); + } + insertRowsConflictIgnore(locks.memories.db, "stage1_outputs", mem.stage1); + if (tableExists(locks.memories.db, "jobs")) { + insertRowsConflictIgnore(locks.memories.db, "jobs", mem.stage1Jobs); + if (mem.consolidateTouched) { + restoreConsolidateGlobalJob( + locks.memories.db, + mem.consolidateJob, + mem.consolidatePostImage, + ); + } + } + commitSatelliteLock(locks.memories); + locks.memories = undefined; + pendingSections.memories = false; + persistPending(); + if (hooks?.failAfterFirstSatelliteCommit && !satelliteBackup.logs) { + throw new Error("test_fail_after_first_satellite"); + } + } + if (pendingSections.goals && satelliteBackup.goals) { + if (!locks.goals) throw new Error("missing_goals_lock"); + const g = satelliteBackup.goals; + if (!tableExists(locks.goals.db, "thread_goals")) { + throw new Error("missing_thread_goals_table"); + } + insertRowsConflictIgnore(locks.goals.db, "thread_goals", g.goals); + if (tableExists(locks.goals.db, "thread_goal_continuation_deferrals")) { + insertRowsConflictIgnore( + locks.goals.db, + "thread_goal_continuation_deferrals", + g.deferrals, + ); + } + commitSatelliteLock(locks.goals); + locks.goals = undefined; + pendingSections.goals = false; + persistPending(); + if ( + hooks?.failAfterFirstSatelliteCommit + && !satelliteBackup.logs + && !satelliteBackup.memories + ) { + throw new Error("test_fail_after_first_satellite"); + } + } + // Close any locks acquired for DBs that had no pending work / backup rows. + rollbackAllSatelliteLocks(locks); + satelliteLocks = undefined; + } catch (error) { + return abortAfterMoves( + mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed", + ); + } + } + + if ( + pendingSections.state + || pendingSections.logs + || pendingSections.memories + || pendingSections.goals + ) { + return abortAfterMoves("db_reconcile_failed"); + } + + // Completeness gate: every planned file must sit at its restored path, and the stage + // must hold no leftover rollout files, before we destroy the quarantine evidence. + for (const item of moved) { + if (!existsSync(item.to) || existsSync(item.from)) { + return abortAfterMoves("fs_failed"); + } + } + try { + if (hooks?.failAtLeftoverStageGate) { + return abortAfterMoves("fs_failed"); + } + for (const name of readdirSync(stageDir)) { + if ( + name === "manifest.json" + || name === SATELLITE_BACKUP_FILE + || name === RESTORE_PENDING_FILE + ) { + continue; + } + if (!isRolloutFileName(name)) continue; + return abortAfterMoves("fs_failed"); + } + } catch { + return abortAfterMoves("fs_failed"); + } + + if (!finalizeRestoredStage(stageDir, codexHome, hooks)) { + return { + ok: false, + trashDir: id, + ...partialCounts, + error: "fs_failed", + }; + } + removeEmptyTrashRoot(codexHome); + + return { + ok: true, + trashDir: id, + ...partialCounts, + }; +} diff --git a/src/storage/policy-job.ts b/src/storage/policy-job.ts index e647cd66d..6000bebe5 100644 --- a/src/storage/policy-job.ts +++ b/src/storage/policy-job.ts @@ -6,6 +6,11 @@ * proxy event loop stays responsive. */ import type { CleanupMode, CleanupResult } from "./cleanup"; +import { resolveCodexHomeDir } from "../codex/home"; +import { + endStorageMutation, + tryBeginStorageMutation, +} from "./storage-mutation-coordinator"; import { isPolicyDue, readStorageCleanupPolicyFromConfig, @@ -20,7 +25,7 @@ export interface PolicyJobOutcome { ok: boolean; skipped?: PolicySkipReason; deferred?: "codex_busy"; - error?: CleanupResult["error"] | "evaluation_failed" | "worker_failed"; + error?: CleanupResult["error"] | "evaluation_failed" | "worker_failed" | "storage_mutation_busy"; mode?: CleanupMode; freedBytes?: number; removed?: number; @@ -71,6 +76,14 @@ let livePolicyApply: ((policy: PolicyRunResult["policy"]) => void) | undefined; let cancelActiveRun: (() => void) | null = null; /** Bumped on abort/reset so a late worker completion cannot clobber newer job state. */ let runGeneration = 0; +/** CODEX_HOME whose mutation slot this job holds (parent thread only). */ +let heldMutationHome: string | undefined; + +function releaseHeldMutationSlot(): void { + if (heldMutationHome === undefined) return; + endStorageMutation(heldMutationHome); + heldMutationHome = undefined; +} export function setStorageCleanupPolicyJobLiveApply( apply: ((policy: PolicyRunResult["policy"]) => void) | null, @@ -119,6 +132,7 @@ export function resetStorageCleanupPolicyJobForTests(): void { } inflight = null; testHooks = null; + releaseHeldMutationSlot(); state = { status: "idle" }; } @@ -129,6 +143,7 @@ export function abortStorageCleanupPolicyJob(): void { try { activeWorker.terminate(); } catch { /* */ } activeWorker = null; } + releaseHeldMutationSlot(); if (state.status === "running") { state = { ...state, @@ -187,6 +202,17 @@ function applyFailed(message: string): void { }; } +function applyMutationBusy(): void { + state = { + status: "idle", + reason: state.reason, + startedAt: state.startedAt, + finishedAt: Date.now(), + lastError: "storage_mutation_busy", + lastOutcome: { ok: false, error: "storage_mutation_busy" }, + }; +} + function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Promise { return new Promise((resolve, reject) => { const requestId = crypto.randomUUID(); @@ -254,6 +280,15 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom async function executeJob(opts: RequestPolicyRunOptions): Promise { const generation = ++runGeneration; + const codexHome = opts.codexHome ?? resolveCodexHomeDir(); + const gate = tryBeginStorageMutation("policy", codexHome); + if (!gate.acquired) { + if (generation === runGeneration) { + applyMutationBusy(); + } + return; + } + heldMutationHome = codexHome; try { const blockMs = testHooks?.blockMs; let result: PolicyRunResult; @@ -264,13 +299,14 @@ async function executeJob(opts: RequestPolicyRunOptions): Promise { result = runStorageCleanupPolicy({ reason: opts.reason, force: opts.force === true, - ...(opts.codexHome ? { codexHome: opts.codexHome } : {}), + codexHome, ...(opts.busyTimeoutMs !== undefined ? { busyTimeoutMs: opts.busyTimeoutMs } : {}), ...(typeof blockMs === "number" && blockMs > 0 ? { holdAfterLoadMs: blockMs } : {}), }); } else { result = await runInWorker({ ...opts, + codexHome, ...(typeof blockMs === "number" && blockMs > 0 ? { blockMs } : {}), }); } @@ -281,7 +317,7 @@ async function executeJob(opts: RequestPolicyRunOptions): Promise { if (generation !== runGeneration) return; applyFailed(err instanceof Error ? err.message : "worker_failed"); } finally { - if (generation === runGeneration) inflight = null; + releaseHeldMutationSlot(); } } @@ -303,7 +339,11 @@ export function requestStorageCleanupPolicyRun( ...(state.lastOutcome ? { lastOutcome: state.lastOutcome } : {}), }; - inflight = executeJob(opts); + const job = executeJob(opts); + inflight = job; + void job.finally(() => { + if (inflight === job) inflight = null; + }); return { accepted: true, state: getStorageCleanupPolicyJobState() }; } diff --git a/src/storage/policy.ts b/src/storage/policy.ts index 51813fa8c..f716376c5 100644 --- a/src/storage/policy.ts +++ b/src/storage/policy.ts @@ -15,7 +15,8 @@ import { executeArchivedCleanup, listArchivedCandidates, previewExactArchivedCleanup, - selectOldestPercent, + selectOldestPercentSkippingPendingRestore, + selectReduceToBytesSkippingPendingRestore, type CleanupMode, type CleanupResult, type ExecuteCleanupOptions, @@ -350,7 +351,7 @@ export function selectPolicyPreview( if (reduceTo !== undefined) { // Exact candidate set — do not approximate via percent (would over-delete). - const desired = selectReduceToBytes(all, reduceTo); + const desired = selectReduceToBytesSkippingPendingRestore(all, reduceTo, codexHome); const preview = previewExactArchivedCleanup(desired, codexHome); return { archivedBytes, @@ -358,13 +359,13 @@ export function selectPolicyPreview( count: preview.count, bytes: preview.bytes, digest: preview.digest, - candidateRelPaths: desired.map(c => c.relPath), + candidateRelPaths: preview.candidates.map(c => c.relPath), }; } // Reuse the already-listed candidates — avoid a second archive directory walk. const percent = Math.min(100, Math.max(0, Math.floor(removePct ?? 0))); - const selected = selectOldestPercent(all, percent); + const selected = selectOldestPercentSkippingPendingRestore(all, percent, codexHome); return { archivedBytes, percent, diff --git a/src/storage/restore-job.ts b/src/storage/restore-job.ts new file mode 100644 index 000000000..31181a388 --- /dev/null +++ b/src/storage/restore-job.ts @@ -0,0 +1,242 @@ +/** + * Single-flight controller for trash restore runs. + * + * Heavy work (file moves, SQLite reconcile) runs in a Bun Worker so the proxy + * event loop stays responsive while the management API awaits the outcome. + * + * Restore shares the CODEX_HOME storage-mutation coordinator with manual cleanup + * and (Phase 3) policy-driven cleanup. Concurrent callers receive + * `storage_mutation_busy` (409) instead of queueing. + */ +import { resolveCodexHomeDir } from "../codex/home"; +import { restoreTrashEntry, type RestoreResult, type RestoreTestHooks } from "./cleanup"; +import { + resetStorageMutationCoordinatorForTests, + setStorageMutationCoordinatorTestHooks, + withStorageMutationSlot, + type StorageMutationCoordinatorTestHooks, +} from "./storage-mutation-coordinator"; + +export interface RestoreJobTestHooks extends StorageMutationCoordinatorTestHooks { + /** + * When true, run on the main thread via dynamic import + optional sleep. + * Responsiveness tests must leave this unset so work stays in a Worker. + */ + runInProcess?: boolean; + /** Expose GET /api/storage/trash/restore/test-stream for responsiveness tests. */ + enableTestStream?: boolean; + /** Forwarded to restoreTrashEntry _test hooks. */ + restoreTest?: RestoreTestHooks; +} + +const WORKER_TIMEOUT_MS = 10 * 60 * 1000; + +const WORKER_REJECTION_CODES: Record = { + restore_worker_timeout: "restore_worker_timeout", + aborted: "restore_worker_aborted", + worker_failed: "restore_worker_failed", +}; + +/** Map a rejected worker promise to a precise restore outcome (exported for tests). */ +export function restoreResultFromWorkerRejection(err: unknown, trashId: string): RestoreResult { + const raw = err instanceof Error ? err.message : String(err); + const error = WORKER_REJECTION_CODES[raw] ?? "restore_worker_failed"; + const detail = + error === "restore_worker_failed" && raw !== "worker_failed" && raw.trim().length > 0 + ? raw + : undefined; + console.error( + `[storage] trash restore worker failed (${error}) trashId=${trashId}${detail ? `: ${detail}` : ""}`, + ); + return { + ok: false, + count: 0, + bytes: 0, + restoredPaths: [], + error, + ...(detail ? { message: detail } : {}), + }; +} + +let activeWorker: Worker | null = null; +let testHooks: RestoreJobTestHooks | null = null; +let cancelActiveRun: (() => void) | null = null; + +export function setRestoreTrashJobTestHooks(hooks: RestoreJobTestHooks | null): void { + testHooks = hooks; + setStorageMutationCoordinatorTestHooks(hooks); +} + +export function resetRestoreTrashJobForTests(): void { + if (activeWorker) { + try { activeWorker.terminate(); } catch { /* */ } + activeWorker = null; + } + cancelActiveRun?.(); + cancelActiveRun = null; + testHooks = null; + resetStorageMutationCoordinatorForTests(); +} + +/** Terminate an in-flight worker during process shutdown. */ +export function abortRestoreTrashJob(): void { + if (activeWorker) { + try { activeWorker.terminate(); } catch { /* */ } + activeWorker = null; + } + cancelActiveRun?.(); + cancelActiveRun = null; +} + +/** Test-only SSE/text stream served from the proxy while a worker is blocked. */ +export function getRestoreTrashTestStreamResponse(): Response | null { + if (!testHooks?.enableTestStream) return null; + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + async start(controller) { + for (let i = 0; i < 8; i++) { + controller.enqueue(encoder.encode(`chunk-${i}\n`)); + await Bun.sleep(50); + } + controller.close(); + }, + }), + { headers: { "content-type": "text/plain; charset=utf-8" } }, + ); +} + +function runInWorker(opts: { + trashId: string; + codexHome?: string; + busyTimeoutMs?: number; + blockMs?: number; + restoreTest?: RestoreTestHooks; +}): Promise { + return new Promise((resolve, reject) => { + const requestId = crypto.randomUUID(); + let settled = false; + const worker = new Worker(new URL("./restore-worker.ts", import.meta.url).href); + activeWorker = worker; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + cancelActiveRun = null; + try { worker.terminate(); } catch { /* */ } + if (activeWorker === worker) activeWorker = null; + reject(new Error("restore_worker_timeout")); + }, WORKER_TIMEOUT_MS); + + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + cancelActiveRun = null; + clearTimeout(timer); + if (activeWorker === worker) activeWorker = null; + try { worker.terminate(); } catch { /* */ } + fn(); + }; + + cancelActiveRun = () => { + finish(() => reject(new Error("aborted"))); + }; + + worker.onmessage = (event: MessageEvent) => { + const data = event.data; + if (!data || typeof data !== "object") return; + const msg = data as Record; + if (msg.requestId !== requestId) return; + if (msg.type === "done" && msg.result && typeof msg.result === "object") { + finish(() => resolve(msg.result as RestoreResult)); + return; + } + if (msg.type === "error") { + const message = typeof msg.message === "string" ? msg.message : "worker_failed"; + finish(() => reject(new Error(message))); + } + }; + + worker.onerror = (err: ErrorEvent) => { + finish(() => reject(err.error instanceof Error ? err.error : new Error(err.message || "worker_failed"))); + }; + + worker.postMessage({ + type: "run", + requestId, + trashId: opts.trashId, + ...(opts.codexHome ? { codexHome: opts.codexHome } : {}), + ...(opts.busyTimeoutMs !== undefined ? { busyTimeoutMs: opts.busyTimeoutMs } : {}), + ...(opts.blockMs !== undefined ? { blockMs: opts.blockMs } : {}), + ...(opts.restoreTest ? { restoreTest: opts.restoreTest } : {}), + env: { + ...(process.env.CODEX_HOME ? { CODEX_HOME: process.env.CODEX_HOME } : {}), + ...(process.env.OPENCODEX_HOME ? { OPENCODEX_HOME: process.env.OPENCODEX_HOME } : {}), + }, + }); + }); +} + +async function executeRestore(opts: { + trashId: string; + codexHome?: string; + busyTimeoutMs?: number; + _test?: RestoreTestHooks; +}): Promise { + const blockMs = testHooks?.blockMs; + const restoreTest = opts._test ?? testHooks?.restoreTest; + + if (testHooks?.runInProcess) { + if (typeof blockMs === "number" && blockMs > 0) await Bun.sleep(blockMs); + return restoreTrashEntry(opts.trashId, { + ...(opts.codexHome ? { codexHome: opts.codexHome } : {}), + ...(opts.busyTimeoutMs !== undefined ? { busyTimeoutMs: opts.busyTimeoutMs } : {}), + ...(restoreTest ? { _test: restoreTest } : {}), + }); + } + + try { + return await runInWorker({ + trashId: opts.trashId, + ...(opts.codexHome ? { codexHome: opts.codexHome } : {}), + ...(opts.busyTimeoutMs !== undefined ? { busyTimeoutMs: opts.busyTimeoutMs } : {}), + ...(typeof blockMs === "number" && blockMs > 0 ? { blockMs } : {}), + ...(restoreTest ? { restoreTest } : {}), + }); + } catch (err) { + return restoreResultFromWorkerRejection(err, opts.trashId); + } +} + +function busyRestoreResult(): RestoreResult { + return { + ok: false, + count: 0, + bytes: 0, + restoredPaths: [], + error: "storage_mutation_busy", + }; +} + +/** + * Run trash restore off the event loop under the shared storage-mutation gate. + * Returns `storage_mutation_busy` when cleanup or another restore is in flight. + */ +export async function runRestoreTrashEntryJob( + trashId: string, + options?: { + codexHome?: string; + busyTimeoutMs?: number; + _test?: RestoreTestHooks; + }, +): Promise { + const codexHome = options?.codexHome ?? resolveCodexHomeDir(); + const result = await withStorageMutationSlot("restore", codexHome, () => + executeRestore({ trashId, ...options }), + ); + if (result && typeof result === "object" && "ok" in result && result.ok === false + && "error" in result && result.error === "storage_mutation_busy") { + return busyRestoreResult(); + } + return result as RestoreResult; +} diff --git a/src/storage/restore-worker.ts b/src/storage/restore-worker.ts new file mode 100644 index 000000000..6d527977b --- /dev/null +++ b/src/storage/restore-worker.ts @@ -0,0 +1,52 @@ +/** + * Worker-thread entry for trash restore runs. + * Keeps file moves and SQLite reconcile off the proxy event loop. + */ +import { restoreTrashEntry, type RestoreResult, type RestoreTestHooks } from "./cleanup"; + +interface RunMessage { + type: "run"; + requestId: string; + trashId: string; + codexHome?: string; + busyTimeoutMs?: number; + /** Test-only: block before restore so responsiveness tests can probe /healthz. */ + blockMs?: number; + restoreTest?: RestoreTestHooks; + /** Env snapshot — Workers may not see parent mutations on all platforms. */ + env?: { CODEX_HOME?: string; OPENCODEX_HOME?: string }; +} + +function isRunMessage(data: unknown): data is RunMessage { + if (!data || typeof data !== "object" || Array.isArray(data)) return false; + const o = data as Record; + return o.type === "run" && typeof o.requestId === "string" && typeof o.trashId === "string"; +} + +declare const self: Worker; + +self.onmessage = async (event: MessageEvent) => { + if (!isRunMessage(event.data)) return; + const { requestId, trashId, codexHome, busyTimeoutMs, blockMs, restoreTest, env } = event.data; + try { + if (env?.CODEX_HOME) process.env.CODEX_HOME = env.CODEX_HOME; + if (env?.OPENCODEX_HOME) process.env.OPENCODEX_HOME = env.OPENCODEX_HOME; + if (typeof blockMs === "number" && Number.isFinite(blockMs) && blockMs > 0) { + await Bun.sleep(Math.floor(blockMs)); + } + const result = restoreTrashEntry(trashId, { + ...(codexHome ? { codexHome } : {}), + ...(busyTimeoutMs !== undefined ? { busyTimeoutMs } : {}), + ...(restoreTest ? { _test: restoreTest } : {}), + }); + self.postMessage({ type: "done", requestId, result }); + } catch (err) { + self.postMessage({ + type: "error", + requestId, + message: err instanceof Error ? err.message : "worker_failed", + }); + } +}; + +export type { RestoreResult }; diff --git a/src/storage/storage-mutation-coordinator.ts b/src/storage/storage-mutation-coordinator.ts new file mode 100644 index 000000000..05d7965df --- /dev/null +++ b/src/storage/storage-mutation-coordinator.ts @@ -0,0 +1,109 @@ +/** + * Single-flight gate for CODEX_HOME storage mutations (cleanup, restore, policy). + * + * Manual cleanup, trash restore, and (Phase 3) policy-driven cleanup share one + * in-flight slot per resolved CODEX_HOME. A second caller receives + * `storage_mutation_busy` immediately — no per-operation queues. + */ +import { resolve } from "node:path"; +import { resolveCodexHomeDir } from "../codex/home"; + +export type StorageMutationKind = "cleanup" | "restore" | "policy"; + +export type StorageMutationBusyError = "storage_mutation_busy"; + +export interface StorageMutationCoordinatorTestHooks { + /** Block after acquiring the slot, before mutation work (race tests). */ + blockMs?: number; +} + +interface ActiveSlot { + kind: StorageMutationKind; + startedAt: number; +} + +const slots = new Map(); +let testHooks: StorageMutationCoordinatorTestHooks | null = null; + +function slotKey(codexHome?: string): string { + return resolve(codexHome ?? resolveCodexHomeDir()); +} + +export function setStorageMutationCoordinatorTestHooks( + hooks: StorageMutationCoordinatorTestHooks | null, +): void { + testHooks = hooks; +} + +export function resetStorageMutationCoordinatorForTests(): void { + testHooks = null; + slots.clear(); +} + +export function getActiveStorageMutation( + codexHome?: string, +): { kind: StorageMutationKind; startedAt: number } | null { + const active = slots.get(slotKey(codexHome)); + return active ? { kind: active.kind, startedAt: active.startedAt } : null; +} + +export function tryBeginStorageMutation( + kind: StorageMutationKind, + codexHome?: string, +): { acquired: true } | { acquired: false; error: StorageMutationBusyError } { + const key = slotKey(codexHome); + if (slots.has(key)) { + return { acquired: false, error: "storage_mutation_busy" }; + } + slots.set(key, { kind, startedAt: Date.now() }); + return { acquired: true }; +} + +export function endStorageMutation(codexHome?: string): void { + slots.delete(slotKey(codexHome)); +} + +async function applyCoordinatorBlock(): Promise { + const blockMs = testHooks?.blockMs; + if (typeof blockMs === "number" && Number.isFinite(blockMs) && blockMs > 0) { + await Bun.sleep(Math.floor(blockMs)); + } +} + +/** + * Phase 3 policy worker integration — wrap policy-driven cleanup FS/DB work. + * Returns `{ ok: false, error: 'storage_mutation_busy' }` when another mutation + * holds the CODEX_HOME slot. + */ +export async function runPolicyStorageMutation( + codexHome: string | undefined, + work: () => T | Promise, +): Promise { + const gate = tryBeginStorageMutation("policy", codexHome); + if (!gate.acquired) { + return { ok: false, error: "storage_mutation_busy" }; + } + try { + await applyCoordinatorBlock(); + return await work(); + } finally { + endStorageMutation(codexHome); + } +} + +export async function withStorageMutationSlot( + kind: StorageMutationKind, + codexHome: string | undefined, + work: () => T | Promise, +): Promise { + const gate = tryBeginStorageMutation(kind, codexHome); + if (!gate.acquired) { + return { ok: false, error: "storage_mutation_busy" }; + } + try { + await applyCoordinatorBlock(); + return await work(); + } finally { + endStorageMutation(codexHome); + } +} diff --git a/tests/api-storage-cleanup.test.ts b/tests/api-storage-cleanup.test.ts index 9b88427f7..b52a30e3a 100644 --- a/tests/api-storage-cleanup.test.ts +++ b/tests/api-storage-cleanup.test.ts @@ -250,3 +250,146 @@ describe("POST /api/storage/cleanup", () => { } }); }); + +describe("GET /api/storage/trash + POST restore", () => { + test("lists relative trash entries and restores without host paths", async () => { + seedArchived(isolatedCodexHome!.path); + const server = startServer(0); + try { + const previewRes = await fetch(new URL("/api/storage/cleanup/preview", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50 }), + }); + const preview = await previewRes.json(); + const cleanupRes = await fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), + }); + expect(cleanupRes.status).toBe(200); + const cleanup = await cleanupRes.json(); + expect(cleanup.trashDir).toMatch(/^\.trash\//); + + const listRes = await fetch(new URL("/api/storage/trash", server.url)); + expect(listRes.status).toBe(200); + const listed = await listRes.json(); + expect(listed.entries).toHaveLength(1); + expect(listed.entries[0].id).toBe(cleanup.trashDir); + expect(listed.entries[0].fileCount).toBe(1); + expect(JSON.stringify(listed)).not.toContain(isolatedCodexHome!.path.replaceAll("\\", "\\\\")); + + const restoreRes = await fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: cleanup.trashDir }), + }); + expect(restoreRes.status).toBe(200); + const restored = await restoreRes.json(); + expect(restored.ok).toBe(true); + expect(restored.count).toBe(1); + expect(restored.restoredPaths).toEqual(["archived_sessions/rollout-old.jsonl"]); + expect(JSON.stringify(restored)).not.toContain(isolatedCodexHome!.path.replaceAll("\\", "\\\\")); + expect(existsSync(join(isolatedCodexHome!.path, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + + const listAfter = await (await fetch(new URL("/api/storage/trash", server.url))).json(); + expect(listAfter.entries).toEqual([]); + } finally { + await server.stop(true); + } + }); + + test("restore returns 409 when Codex DB is busy", async () => { + seedArchived(isolatedCodexHome!.path); + const server = startServer(0); + try { + const previewRes = await fetch(new URL("/api/storage/cleanup/preview", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50 }), + }); + const preview = await previewRes.json(); + const cleanupRes = await fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), + }); + const cleanup = await cleanupRes.json(); + + const locker = new Database(join(isolatedCodexHome!.path, "state_5.sqlite")); + locker.exec("BEGIN EXCLUSIVE"); + try { + const restoreRes = await fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: cleanup.trashDir }), + }); + expect(restoreRes.status).toBe(409); + const body = await restoreRes.json(); + expect(body.ok).toBe(false); + expect(body.error).toBe("codex_busy"); + } finally { + locker.exec("ROLLBACK"); + locker.close(); + } + } finally { + await server.stop(true); + } + }); + + test("rejects invalid and missing trash ids", async () => { + const server = startServer(0); + try { + const invalid = await fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "../etc/passwd" }), + }); + expect(invalid.status).toBe(400); + expect((await invalid.json()).error).toBe("invalid_trash"); + + const missing = await fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: ".trash/999999999" }), + }); + expect(missing.status).toBe(404); + expect((await missing.json()).error).toBe("missing_trash"); + } finally { + await server.stop(true); + } + }); + + test("restore returns 409 when destination archived file already exists", async () => { + seedArchived(isolatedCodexHome!.path); + const server = startServer(0); + try { + const previewRes = await fetch(new URL("/api/storage/cleanup/preview", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50 }), + }); + const preview = await previewRes.json(); + const cleanupRes = await fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), + }); + const cleanup = await cleanupRes.json(); + expect(cleanup.ok).toBe(true); + + writeFileSync(join(isolatedCodexHome!.path, "archived_sessions", "rollout-old.jsonl"), "COLLISION"); + const restoreRes = await fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: cleanup.trashDir }), + }); + expect(restoreRes.status).toBe(409); + const body = await restoreRes.json(); + expect(body.ok).toBe(false); + expect(body.error).toBe("dest_exists"); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/api-storage-policy.test.ts b/tests/api-storage-policy.test.ts index ef8724766..4dead3680 100644 --- a/tests/api-storage-policy.test.ts +++ b/tests/api-storage-policy.test.ts @@ -7,6 +7,10 @@ import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { + resetArchivedCleanupJobForTests, + setArchivedCleanupJobTestHooks, +} from "../src/storage/cleanup-job"; import { resetStorageCleanupPolicyJobForTests, setStorageCleanupPolicyJobTestHooks, @@ -106,12 +110,15 @@ beforeEach(() => { saveConfig(baseConfig()); stopStorageCleanupScheduler(); resetStorageCleanupPolicyJobForTests(); + resetArchivedCleanupJobForTests(); }); afterEach(() => { stopStorageCleanupScheduler(); resetStorageCleanupPolicyJobForTests(); setStorageCleanupPolicyJobTestHooks(null); + resetArchivedCleanupJobForTests(); + setArchivedCleanupJobTestHooks(null); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); @@ -283,7 +290,10 @@ describe("storage cleanup policy API", () => { }, { timeout: 30_000 }); test("blocked worker completion preserves concurrent policy PUT edits", async () => { - setStorageCleanupPolicyJobTestHooks({ blockMs: 1_200 }); + // Long hold so a slow Windows CI worker spawn can load the enabled snapshot + // before the concurrent PUT lands inside holdAfterLoadMs. + const blockMs = 1_500; + setStorageCleanupPolicyJobTestHooks({ blockMs }); seedArchived(isolatedCodexHome!.path); const server = startServer(0); try { @@ -307,17 +317,21 @@ describe("storage cleanup policy API", () => { expect(runStart.started).toBe(true); expect(runStart.job?.status).toBe("running"); - // Wait until the job is visibly running, then edit policy while the worker holds. - const editDeadline = Date.now() + 2_000; + // Status flips to running before the worker loads policy — wait for that marker, + // then allow spawn+load margin before editing during the hold window. + const editDeadline = Date.now() + 5_000; + let sawRunning = false; while (Date.now() < editDeadline) { const peek = await fetch(new URL("/api/storage/cleanup-policy", server.url)); const peekBody = await peek.json() as { job?: { status?: string } }; - if (peekBody.job?.status === "running") break; + if (peekBody.job?.status === "running") { + sawRunning = true; + break; + } await Bun.sleep(20); } - - // Let the worker load the start-of-job snapshot, then edit during the hold window. - await Bun.sleep(450); + expect(sawRunning).toBe(true); + await Bun.sleep(800); const put = await fetch(new URL("/api/storage/cleanup-policy", server.url), { method: "PUT", @@ -337,6 +351,7 @@ describe("storage cleanup policy API", () => { const done = await waitForJobIdle(server.url, runStart.job!.startedAt); expect(done.job.lastOutcome?.ok).toBe(true); + expect(done.job.lastOutcome?.skipped).toBeUndefined(); expect(done.job.lastOutcome?.removed).toBe(1); expect(done.enabled).toBe(false); expect(done.lastRun?.removed).toBe(1); @@ -366,4 +381,62 @@ describe("storage cleanup policy API", () => { resetStorageCleanupPolicyJobForTests(); } }, { timeout: 30_000 }); + + test("storage_mutation_busy clears inflight so a later policy run can start", async () => { + setArchivedCleanupJobTestHooks({ blockMs: 600 }); + seedArchived(isolatedCodexHome!.path); + const server = startServer(0); + try { + await fetch(new URL("/api/storage/cleanup-policy", server.url), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled: true, + trigger: { archivedBytesOver: 50 }, + target: { removeOldestPercent: 50 }, + schedule: "manual", + mode: "quarantine", + }), + }); + + const previewRes = await fetch(new URL("/api/storage/cleanup/preview", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50 }), + }); + const preview = await previewRes.json() as { digest: string }; + const cleanupPromise = fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), + }); + await Bun.sleep(50); + + const blockedRun = await fetch(new URL("/api/storage/cleanup-policy/run", server.url), { + method: "POST", + }); + expect(blockedRun.status).toBe(200); + const blockedStart = await blockedRun.json() as { job: { startedAt: number } }; + const blockedDone = await waitForJobIdle(server.url, blockedStart.job.startedAt); + expect(blockedDone.job.lastOutcome?.ok).toBe(false); + expect(blockedDone.job.lastOutcome?.error).toBe("storage_mutation_busy"); + + await cleanupPromise; + + const retryRun = await fetch(new URL("/api/storage/cleanup-policy/run", server.url), { + method: "POST", + }); + expect(retryRun.status).toBe(200); + const retryStart = await retryRun.json() as { started?: boolean; job: { startedAt: number } }; + expect(retryStart.started).toBe(true); + const retryDone = await waitForJobIdle(server.url, retryStart.job.startedAt); + expect(retryDone.job.lastOutcome?.ok).toBe(true); + expect(retryDone.job.lastOutcome?.skipped).toBeUndefined(); + expect(retryDone.job.lastOutcome?.removed).toBe(1); + } finally { + await server.stop(true); + stopStorageCleanupScheduler(); + resetStorageCleanupPolicyJobForTests(); + } + }, { timeout: 30_000 }); }); diff --git a/tests/storage-cleanup.test.ts b/tests/storage-cleanup.test.ts index 85bbc0d6e..a5b030480 100644 --- a/tests/storage-cleanup.test.ts +++ b/tests/storage-cleanup.test.ts @@ -4,8 +4,11 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, + renameSync, rmSync, + unlinkSync, utimesSync, writeFileSync, } from "node:fs"; @@ -15,8 +18,10 @@ import { computePreviewDigest, executeArchivedCleanup, listArchivedCandidates, + listTrashEntries, normalizeArchivedRolloutPath, previewArchivedCleanup, + restoreTrashEntry, selectOldestPercent, type ExecuteCleanupOptions, } from "../src/storage/cleanup"; @@ -369,6 +374,7 @@ describe("executeArchivedCleanup", () => { } }); + // Windows CI: SQLite lock contention across satellite DBs can exceed the default 5s. test("busy final satellite lock rolls back earlier satellite write locks", () => { home = buildHome({ withSatelliteStores: true }); const goalsLocker = new Database(join(home, "goals_1.sqlite")); @@ -412,7 +418,7 @@ describe("executeArchivedCleanup", () => { try { logsRead?.close(); } catch { /* */ } try { memoriesRead?.close(); } catch { /* */ } } - }); + }, { timeout: 20_000 }); test("rolls back staged renames when a later rename fails", () => { home = buildHome(); @@ -588,6 +594,7 @@ describe("executeArchivedCleanup", () => { expect(ids).toEqual(["active"]); }); + // Windows CI: multi-satellite permanent cleanup can exceed the default 5s under lock/IO load. test("permanent cleanup removes logs, goals, and memory rows for deleted threads", () => { home = buildHome({ withSatelliteStores: true }); const result = runWithDigest(100, "permanent", home); @@ -632,7 +639,7 @@ describe("executeArchivedCleanup", () => { const ids = state.query<{ id: string }, []>("SELECT id FROM threads").all().map(r => r.id); state.close(); expect(ids).toEqual(["active"]); - }); + }, { timeout: 20_000 }); test("threads read failure leaves every file and database unchanged", () => { home = buildHome({ withSatelliteStores: true }); @@ -714,6 +721,7 @@ describe("executeArchivedCleanup", () => { expect(stateAfter.query("SELECT id, rollout_path, archived FROM threads ORDER BY id").all()).toEqual(threads); stateAfter.close(); }, + { timeout: 30_000 }, ); test("satellite restore failure keeps recovery trashDir and manifest", () => { @@ -871,6 +879,7 @@ describe("executeArchivedCleanup", () => { state.close(); }, { timeout: 30_000 }); + // Windows CI: same multi-satellite restore profile as above (timed out at 5s on PR #558). test("concurrent consolidate enqueue watermark change is preserved on restore", () => { home = buildHome({ withSatelliteStores: true }); const result = runWithDigest(100, "permanent", home, { @@ -908,3 +917,882 @@ describe("executeArchivedCleanup", () => { state.close(); }, { timeout: 30_000 }); }); + +describe("listTrashEntries + restoreTrashEntry", () => { + test("round-trip quarantine → restore returns files and threads", () => { + home = buildHome(); + const original = readFileSync(join(home, "archived_sessions", "rollout-old.jsonl"), "utf8"); + const quarantined = runWithDigest(50, "quarantine", home, { now: 1_700_000_000_100 }); + expect(quarantined.ok).toBe(true); + expect(quarantined.trashDir).toBe(".trash/1700000000100"); + + const listed = listTrashEntries(home); + expect(listed).toHaveLength(1); + expect(listed[0]!.id).toBe(".trash/1700000000100"); + expect(listed[0]!.fileCount).toBe(1); + expect(listed[0]!.mode).toBe("quarantine"); + expect(listed[0]!.quarantinedAt).toBe(1_700_000_000_100); + expect(JSON.stringify(listed)).not.toContain(home.replaceAll("\\", "\\\\")); + + const restored = restoreTrashEntry(".trash/1700000000100", { codexHome: home }); + expect(restored.ok).toBe(true); + expect(restored.count).toBe(1); + expect(restored.restoredPaths).toEqual(["archived_sessions/rollout-old.jsonl"]); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(readFileSync(join(home, "archived_sessions", "rollout-old.jsonl"), "utf8")).toBe(original); + expect(existsSync(join(home, ".trash", "1700000000100"))).toBe(false); + expect(listTrashEntries(home)).toEqual([]); + + const db = new Database(join(home, "state_5.sqlite"), { readonly: true }); + const row = db.query<{ id: string; rollout_path: string; archived: number | null }, []>( + "SELECT id, rollout_path, archived FROM threads WHERE id='told'", + ).get(); + db.close(); + expect(row).toEqual({ + id: "told", + rollout_path: "archived_sessions/rollout-old.jsonl", + archived: 1, + }); + expect(existsSync(join(home, "sessions", "2026", "05", "27", "rollout-active.jsonl"))).toBe(true); + }); + + test("restore refuses when Codex DB is busy", () => { + home = buildHome(); + const quarantined = runWithDigest(50, "quarantine", home, { now: 1_700_000_000_200 }); + expect(quarantined.ok).toBe(true); + + const locker = new Database(join(home, "state_5.sqlite")); + locker.exec("BEGIN EXCLUSIVE"); + try { + const restored = restoreTrashEntry(".trash/1700000000200", { + codexHome: home, + busyTimeoutMs: 1, + }); + expect(restored.ok).toBe(false); + expect(restored.error).toBe("codex_busy"); + expect(existsSync(join(home, ".trash", "1700000000200", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(false); + } finally { + locker.exec("ROLLBACK"); + locker.close(); + } + }); + + test("rejects missing, invalid, and path-escaping trash ids", () => { + home = buildHome(); + expect(restoreTrashEntry(".trash/999", { codexHome: home }).error).toBe("missing_trash"); + expect(restoreTrashEntry("../etc/passwd", { codexHome: home }).error).toBe("invalid_trash"); + expect(restoreTrashEntry(".trash/../sessions", { codexHome: home }).error).toBe("invalid_trash"); + expect(restoreTrashEntry(".trash/not-an-epoch", { codexHome: home }).error).toBe("invalid_trash"); + expect(restoreTrashEntry("", { codexHome: home }).error).toBe("invalid_trash"); + }); + + test("refuses restore when destination archived file already exists", () => { + home = buildHome(); + const quarantined = runWithDigest(50, "quarantine", home, { now: 1_700_000_000_300 }); + expect(quarantined.ok).toBe(true); + writeFileSync(join(home, "archived_sessions", "rollout-old.jsonl"), "COLLISION"); + const restored = restoreTrashEntry(".trash/1700000000300", { codexHome: home }); + expect(restored.ok).toBe(false); + expect(restored.error).toBe("dest_exists"); + expect(existsSync(join(home, ".trash", "1700000000300", "rollout-old.jsonl"))).toBe(true); + }); + + test("quarantine retains satellite-backup and restores satellite + state dependents", () => { + home = buildHome({ withSatelliteStores: true, withDynamicTools: true, withSpawnEdges: true }); + // 100%: spawn edge told→tmid stays inside the delete set (cross-boundary edges refuse cleanup). + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_000_400 }); + expect(quarantined.ok).toBe(true); + const backupPath = join(home, ".trash", "1700000000400", "satellite-backup.json"); + expect(existsSync(backupPath)).toBe(true); + const backup = JSON.parse(readFileSync(backupPath, "utf8")) as { + threadIds: string[]; + threads?: Array>; + dynamicTools?: Array>; + spawnEdges?: Array>; + logs?: { path: string; rows: unknown[] }; + }; + expect(backup.threadIds).toContain("told"); + expect(backup.threads?.some(r => r.id === "told")).toBe(true); + expect(backup.dynamicTools?.length).toBeGreaterThan(0); + expect(backup.spawnEdges?.length).toBeGreaterThan(0); + expect(backup.logs?.rows.length).toBeGreaterThan(0); + + // Simulate Codex rotating to a newer logs DB — restore must remap to current home. + renameSync(join(home, "logs_2.sqlite"), join(home, "logs_3.sqlite")); + + const restored = restoreTrashEntry(".trash/1700000000400", { codexHome: home }); + expect(restored.ok).toBe(true); + expect(existsSync(backupPath)).toBe(false); + + const state = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(state.query("SELECT id FROM threads WHERE id='told'").get()).toEqual({ id: "told" }); + expect(state.query("SELECT COUNT(*) AS n FROM thread_dynamic_tools WHERE thread_id='told'").get()) + .toEqual({ n: 1 }); + expect(state.query("SELECT COUNT(*) AS n FROM thread_spawn_edges WHERE parent_thread_id='told' OR child_thread_id='told'").get()) + .toEqual({ n: 1 }); + state.close(); + + const logs = new Database(join(home, "logs_3.sqlite"), { readonly: true }); + expect(logs.query("SELECT COUNT(*) AS n FROM logs WHERE thread_id='told'").get()).toEqual({ n: 1 }); + logs.close(); + }); + + test("rejects malformed satellite-backup.json without destroying trash", () => { + home = buildHome(); + const quarantined = runWithDigest(50, "quarantine", home, { now: 1_700_000_000_500 }); + expect(quarantined.ok).toBe(true); + writeFileSync(join(home, ".trash", "1700000000500", "satellite-backup.json"), "{truncated"); + const restored = restoreTrashEntry(".trash/1700000000500", { codexHome: home }); + expect(restored.ok).toBe(false); + expect(restored.error).toBe("db_reconcile_failed"); + expect(existsSync(join(home, ".trash", "1700000000500", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(false); + }); + + test("refuses restore when manifest has threads but state DB is absent", () => { + home = buildHome(); + const quarantined = runWithDigest(50, "quarantine", home, { now: 1_700_000_000_600 }); + expect(quarantined.ok).toBe(true); + unlinkSync(join(home, "state_5.sqlite")); + const restored = restoreTrashEntry(".trash/1700000000600", { codexHome: home }); + expect(restored.ok).toBe(false); + expect(restored.error).toBe("db_reconcile_failed"); + expect(existsSync(join(home, ".trash", "1700000000600", "rollout-old.jsonl"))).toBe(true); + }); + + test("partial purge survivors restore only remaining physical files", () => { + home = buildHome(); + writeFileSync(join(home, "archived_sessions", "rollout-old.jsonl.zst"), "ZST"); + utimesSync(join(home, "archived_sessions", "rollout-old.jsonl.zst"), OLD, OLD); + const preview = previewArchivedCleanup(50, home); + const result = executeArchivedCleanup({ + percent: 50, + mode: "permanent", + digest: preview.digest, + codexHome: home, + now: 1_700_000_000_700, + _test: { failPurgeBasenames: ["rollout-old.jsonl"] }, + }); + expect(result.ok).toBe(false); + expect(result.trashDir).toBe(".trash/1700000000700"); + const manifest = JSON.parse( + readFileSync(join(home, ".trash", "1700000000700", "manifest.json"), "utf8"), + ) as { entries: Array<{ physicalRelPaths: string[] }> }; + expect(manifest.entries[0]!.physicalRelPaths).toEqual(["archived_sessions/rollout-old.jsonl"]); + // Twin was purged; stage only has the survivor. + expect(existsSync(join(home, ".trash", "1700000000700", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(home, ".trash", "1700000000700", "rollout-old.jsonl.zst"))).toBe(false); + + const restored = restoreTrashEntry(".trash/1700000000700", { codexHome: home }); + expect(restored.ok).toBe(true); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + }); + + test("rejects mixed valid+malformed manifest entries as invalid_trash without touching staged files", () => { + home = buildHome(); + const stage = join(home, ".trash", "1700000000800"); + mkdirSync(stage, { recursive: true }); + writeFileSync(join(stage, "rollout-old.jsonl"), "OLD-STAGE"); + writeFileSync(join(stage, "rollout-mid.jsonl"), "MID-STAGE"); + writeFileSync(join(stage, "manifest.json"), JSON.stringify({ + quarantinedAt: 1_700_000_000_800, + mode: "quarantine", + entries: [ + { + relPath: "archived_sessions/rollout-old.jsonl", + bytes: 9, + mtimeMs: OLD.getTime(), + physicalRelPaths: ["archived_sessions/rollout-old.jsonl"], + threadId: "told", + rolloutPath: "archived_sessions/rollout-old.jsonl", + archived: 1, + }, + { + relPath: "archived_sessions/rollout-mid.jsonl", + bytes: 9, + mtimeMs: MID.getTime(), + // Malformed: non-string path must reject the entire manifest (no per-entry filter). + physicalRelPaths: ["archived_sessions/rollout-mid.jsonl", null], + threadId: "tmid", + rolloutPath: "archived_sessions/rollout-mid.jsonl", + archived: 1, + }, + ], + })); + + const restored = restoreTrashEntry(".trash/1700000000800", { codexHome: home }); + expect(restored.ok).toBe(false); + expect(restored.error).toBe("invalid_trash"); + expect(readFileSync(join(stage, "rollout-old.jsonl"), "utf8")).toBe("OLD-STAGE"); + expect(readFileSync(join(stage, "rollout-mid.jsonl"), "utf8")).toBe("MID-STAGE"); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(readFileSync(join(home, "archived_sessions", "rollout-old.jsonl"), "utf8")).toBe("OLD".repeat(10)); + expect(existsSync(join(home, "archived_sessions", "rollout-mid.jsonl"))).toBe(true); + expect(readFileSync(join(home, "archived_sessions", "rollout-mid.jsonl"), "utf8")).toBe("MID".repeat(20)); + }); + + test("legacy quarantine without satellite-backup reconstructs production-shaped thread from rollout", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-cleanup-legacy-")); + home = dir; + mkdirSync(join(dir, "archived_sessions"), { recursive: true }); + + const rolloutBody = [ + JSON.stringify({ + type: "session_meta", + timestamp: "2026-01-01T00:00:00.000Z", + payload: { + id: "told", + model_provider: "openai", + source: "cli", + cwd: "/tmp/project", + }, + }), + JSON.stringify({ + type: "event_msg", + timestamp: "2026-01-01T00:00:01.000Z", + payload: { type: "user_message", message: "restore me please" }, + }), + ].join("\n") + "\n"; + + const stage = join(dir, ".trash", "1700000000900"); + mkdirSync(stage, { recursive: true }); + writeFileSync(join(stage, "rollout-old.jsonl"), rolloutBody); + writeFileSync(join(stage, "manifest.json"), JSON.stringify({ + quarantinedAt: 1_700_000_000_900, + mode: "quarantine", + entries: [ + { + relPath: "archived_sessions/rollout-old.jsonl", + bytes: Buffer.byteLength(rolloutBody), + mtimeMs: OLD.getTime(), + physicalRelPaths: ["archived_sessions/rollout-old.jsonl"], + threadId: "told", + rolloutPath: "archived_sessions/rollout-old.jsonl", + archived: 1, + }, + ], + })); + // Intentionally no satellite-backup.json — Phase-2 legacy quarantine shape. + + const db = new Database(join(dir, "state_5.sqlite")); + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + model_provider TEXT NOT NULL, + source TEXT NOT NULL, + first_user_message TEXT NOT NULL, + has_user_event INTEGER NOT NULL DEFAULT 0, + archived INTEGER, + archived_at INTEGER + )`); + db.close(); + + const restored = restoreTrashEntry(".trash/1700000000900", { codexHome: home }); + expect(restored.ok).toBe(true); + expect(existsSync(join(dir, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(stage)).toBe(false); + + const state = new Database(join(dir, "state_5.sqlite"), { readonly: true }); + const row = state.query<{ + id: string; + rollout_path: string; + model_provider: string; + source: string; + first_user_message: string; + has_user_event: number; + archived: number | null; + }, []>( + `SELECT id, rollout_path, model_provider, source, first_user_message, has_user_event, archived + FROM threads WHERE id='told'`, + ).get(); + state.close(); + expect(row).toEqual({ + id: "told", + rollout_path: "archived_sessions/rollout-old.jsonl", + model_provider: "openai", + source: "cli", + first_user_message: "restore me please", + has_user_event: 1, + archived: 1, + }); + }); + + test.each([ + ["failAfterStateCommit", { failAfterStateCommit: true }, "db_reconcile_failed"], + ["failAfterFirstSatelliteCommit", { failAfterFirstSatelliteCommit: true }, "db_reconcile_failed"], + ["failAtLeftoverStageGate", { failAtLeftoverStageGate: true }, "fs_failed"], + ] as const)( + "injected %s leaves partial restore with pending marker and retry succeeds", + (_name, hook, error) => { + home = buildHome({ + withSatelliteStores: true, + withDynamicTools: true, + withSpawnEdges: true, + }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_000 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const failed = restoreTrashEntry(trashId, { codexHome: home, _test: { ...hook } }); + expect(failed.ok).toBe(false); + expect(failed.error).toBe(error); + // Files stay restored — accurate partial counts, no restage. + expect(failed.count).toBe(3); + expect(failed.restoredPaths).toEqual([ + "archived_sessions/rollout-old.jsonl", + "archived_sessions/rollout-mid.jsonl", + "archived_sessions/rollout-new.jsonl", + ]); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(stage, "rollout-old.jsonl"))).toBe(false); + expect(existsSync(join(stage, "manifest.json"))).toBe(true); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(true); + + const pending = JSON.parse(readFileSync(join(stage, "restore-pending.json"), "utf8")) as { + filesRestored: boolean; + acceptedDestRels: string[]; + pending: { state: boolean; logs: boolean; memories: boolean; goals: boolean }; + }; + expect(pending.filesRestored).toBe(true); + expect(pending.acceptedDestRels).toContain("archived_sessions/rollout-old.jsonl"); + + const retried = restoreTrashEntry(trashId, { codexHome: home }); + expect(retried.ok).toBe(true); + expect(retried.count).toBe(3); + expect(existsSync(stage)).toBe(false); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + + const stateAfter = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(stateAfter.query("SELECT id FROM threads WHERE id='told'").get()).toEqual({ id: "told" }); + expect(stateAfter.query("SELECT COUNT(*) AS n FROM thread_dynamic_tools WHERE thread_id='told'").get()) + .toEqual({ n: 1 }); + stateAfter.close(); + const logsAfter = new Database(join(home, "logs_2.sqlite"), { readonly: true }); + expect(logsAfter.query("SELECT COUNT(*) AS n FROM logs WHERE thread_id='told'").get()).toEqual({ n: 1 }); + logsAfter.close(); + }, + { timeout: 20_000 }, + ); + + test("late failure after logs commit keeps metadata, persists pending sections, and resume preserves pre-existing rows", () => { + // Regression for the old non-atomic path: compensate logs then hit busy on + // state — which deleted logs while leaving state+files. Prefer partial+resume. + home = buildHome({ + withSatelliteStores: true, + withDynamicTools: true, + withSpawnEdges: true, + }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_100 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const stateSeed = new Database(join(home, "state_5.sqlite")); + stateSeed.exec(`INSERT INTO threads VALUES ( + 'told','archived_sessions/rollout-old.jsonl',1,1,'legacy' + )`); + stateSeed.exec(`INSERT INTO thread_dynamic_tools VALUES ('told',0,'pre','d','{}')`); + stateSeed.close(); + const logsSeed = new Database(join(home, "logs_2.sqlite")); + logsSeed.exec( + `INSERT INTO logs (id, ts, level, target, thread_id, estimated_bytes) VALUES (1,1,'INFO','pre','told',10)`, + ); + logsSeed.close(); + const memSeed = new Database(join(home, "memories_1.sqlite")); + memSeed.exec(`INSERT INTO stage1_outputs VALUES ('told',1,'pre-m','pre-s',1,0)`); + memSeed.close(); + const goalsSeed = new Database(join(home, "goals_1.sqlite")); + goalsSeed.exec(`INSERT INTO thread_goals VALUES ('told','g1','pre','complete',0,0,1,1)`); + goalsSeed.close(); + + const failed = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failAfterFirstSatelliteCommit: true }, + }); + expect(failed.ok).toBe(false); + expect(failed.error).toBe("db_reconcile_failed"); + expect(failed.count).toBe(3); + + // Files stay; no restage; no metadata compensation. + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(stage, "rollout-old.jsonl"))).toBe(false); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(true); + + const pending = JSON.parse(readFileSync(join(stage, "restore-pending.json"), "utf8")) as { + pending: { state: boolean; logs: boolean; memories: boolean; goals: boolean }; + }; + expect(pending.pending).toEqual({ + state: false, + logs: false, + memories: true, + goals: true, + }); + + // Pre-existing conflict-ignored rows stay; newly restored mid/new rows stay. + const state = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(state.query("SELECT COUNT(*) AS n FROM threads WHERE id IN ('told','tmid','tnew')").get()) + .toEqual({ n: 3 }); + expect( + state.query("SELECT name FROM thread_dynamic_tools WHERE thread_id='told' AND position=0").get(), + ).toEqual({ name: "pre" }); + state.close(); + const logs = new Database(join(home, "logs_2.sqlite"), { readonly: true }); + expect( + logs.query("SELECT ts, target, estimated_bytes FROM logs WHERE id=1").get(), + ).toEqual({ ts: 1, target: "pre", estimated_bytes: 10 }); + expect(logs.query("SELECT COUNT(*) AS n FROM logs WHERE thread_id IN ('tmid','tnew')").get()) + .toEqual({ n: 2 }); + logs.close(); + + // State locked after logs would have been compensated under the old design — + // resume must still finish without dest_exists and without deleting pre rows. + let locker: Database | undefined; + let retried: ReturnType; + try { + locker = new Database(join(home, "state_5.sqlite")); + locker.exec("BEGIN EXCLUSIVE"); + // State already done in pending — busy state must not block satellite resume. + retried = restoreTrashEntry(trashId, { codexHome: home, busyTimeoutMs: 1 }); + } finally { + try { locker?.exec("ROLLBACK"); } catch { /* */ } + try { locker?.close(); } catch { /* */ } + } + expect(retried!.ok).toBe(true); + expect(retried!.count).toBe(3); + expect(existsSync(stage)).toBe(false); + + const mem = new Database(join(home, "memories_1.sqlite"), { readonly: true }); + expect(mem.query("SELECT raw_memory FROM stage1_outputs WHERE thread_id='told'").get()) + .toEqual({ raw_memory: "pre-m" }); + expect( + mem.query("SELECT COUNT(*) AS n FROM stage1_outputs WHERE thread_id IN ('told','tmid','tnew')").get(), + ).toEqual({ n: 2 }); // fixture seeds told+tmid only + mem.close(); + const goals = new Database(join(home, "goals_1.sqlite"), { readonly: true }); + expect(goals.query("SELECT objective FROM thread_goals WHERE thread_id='told'").get()) + .toEqual({ objective: "pre" }); + expect( + goals.query("SELECT COUNT(*) AS n FROM thread_goals WHERE thread_id IN ('told','tmid','tnew')").get(), + ).toEqual({ n: 2 }); // fixture seeds told+tmid only + goals.close(); + }, { timeout: 20_000 }); + + test("leftover-stage failure never restages files and retry accepts destinations", () => { + // Regression for reverse-move failure after metadata compensation: restage + // could leave metadata deleted while files remained at dest. We never restage. + home = buildHome({ withSatelliteStores: true, withDynamicTools: true }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_200 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const logsSeed = new Database(join(home, "logs_2.sqlite")); + logsSeed.exec( + `INSERT INTO logs (id, ts, level, target, thread_id, estimated_bytes) VALUES (1,42,'INFO','pre','told',99)`, + ); + logsSeed.close(); + + const failed = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failAtLeftoverStageGate: true }, + }); + expect(failed.ok).toBe(false); + expect(failed.error).toBe("fs_failed"); + expect(failed.count).toBe(3); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(stage, "rollout-old.jsonl"))).toBe(false); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(true); + + const pending = JSON.parse(readFileSync(join(stage, "restore-pending.json"), "utf8")) as { + pending: { state: boolean; logs: boolean; memories: boolean; goals: boolean }; + }; + expect(pending.pending).toEqual({ + state: false, + logs: false, + memories: false, + goals: false, + }); + + // Pre-existing log preserved; satellite rows from this restore remain. + const logs = new Database(join(home, "logs_2.sqlite"), { readonly: true }); + expect( + logs.query("SELECT ts, target, estimated_bytes FROM logs WHERE id=1").get(), + ).toEqual({ ts: 42, target: "pre", estimated_bytes: 99 }); + expect(logs.query("SELECT COUNT(*) AS n FROM logs WHERE thread_id IN ('told','tmid','tnew')").get()) + .toEqual({ n: 3 }); + logs.close(); + + const retried = restoreTrashEntry(trashId, { codexHome: home }); + expect(retried.ok).toBe(true); + expect(retried.count).toBe(3); + expect(existsSync(stage)).toBe(false); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + + const logsAfter = new Database(join(home, "logs_2.sqlite"), { readonly: true }); + expect( + logsAfter.query("SELECT ts, target FROM logs WHERE id=1").get(), + ).toEqual({ ts: 42, target: "pre" }); + logsAfter.close(); + }, { timeout: 20_000 }); + + test("initial restore-pending write failure moves no files", () => { + home = buildHome({ withSatelliteStores: true }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_300 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const failed = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failInitialPendingWrite: true }, + }); + expect(failed.ok).toBe(false); + expect(failed.error).toBe("fs_failed"); + expect(failed.count).toBe(0); + expect(existsSync(join(stage, "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(false); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(false); + + const retried = restoreTrashEntry(trashId, { codexHome: home }); + expect(retried.ok).toBe(true); + expect(retried.count).toBe(3); + expect(existsSync(stage)).toBe(false); + }, { timeout: 20_000 }); + + test("interrupted pending update preserves the previous valid marker", () => { + home = buildHome({ withSatelliteStores: true }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_400 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const failed = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failPendingWriteBeforeRename: true }, + }); + expect(failed.ok).toBe(false); + expect(failed.error).toBe("fs_failed"); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(true); + + const pending = JSON.parse(readFileSync(join(stage, "restore-pending.json"), "utf8")) as { + filesRestored: boolean; + acceptedDestRels: string[]; + pending: { state: boolean; logs: boolean; memories: boolean; goals: boolean }; + }; + // Atomic rename never landed the post-state update — prior marker remains. + expect(pending.filesRestored).toBe(true); + expect(pending.pending).toEqual({ + state: true, + logs: true, + memories: true, + goals: true, + }); + expect(pending.acceptedDestRels).toContain("archived_sessions/rollout-old.jsonl"); + + const retried = restoreTrashEntry(trashId, { codexHome: home }); + expect(retried.ok).toBe(true); + expect(retried.error).toBeUndefined(); + expect(existsSync(stage)).toBe(false); + }, { timeout: 20_000 }); + + test("crash after file move retries without dest_exists or fs_failed", () => { + home = buildHome({ withSatelliteStores: true }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_500 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const crashed = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failAfterFileMoves: true }, + }); + expect(crashed.ok).toBe(false); + expect(crashed.error).toBe("fs_failed"); + expect(crashed.count).toBe(3); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(stage, "rollout-old.jsonl"))).toBe(false); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(true); + + const retried = restoreTrashEntry(trashId, { codexHome: home }); + expect(retried.ok).toBe(true); + expect(retried.error).not.toBe("dest_exists"); + expect(retried.error).not.toBe("fs_failed"); + expect(retried.count).toBe(3); + expect(existsSync(stage)).toBe(false); + }, { timeout: 20_000 }); + + test("mid-move failure keeps placed dest, marker, and resumes without dest_exists", () => { + // First rename succeeds, second throws. Do not reverse the first file or drop + // the durable planned acceptedDestRels — retry must finish both states. + home = buildHome({ withSatelliteStores: true }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_550 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const failed = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failAfterMoveCount: 1 }, + }); + expect(failed.ok).toBe(false); + expect(failed.error).toBe("fs_failed"); + expect(failed.count).toBe(1); + expect(failed.restoredPaths).toEqual(["archived_sessions/rollout-old.jsonl"]); + + // First dest stays; remaining rollouts stay staged; marker keeps full plan. + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(stage, "rollout-old.jsonl"))).toBe(false); + expect(existsSync(join(stage, "rollout-mid.jsonl"))).toBe(true); + expect(existsSync(join(stage, "rollout-new.jsonl"))).toBe(true); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(true); + + const pending = JSON.parse(readFileSync(join(stage, "restore-pending.json"), "utf8")) as { + filesRestored: boolean; + acceptedDestRels: string[]; + pending: { state: boolean; logs: boolean; memories: boolean; goals: boolean }; + }; + expect(pending.filesRestored).toBe(true); + expect(pending.acceptedDestRels).toEqual([ + "archived_sessions/rollout-old.jsonl", + "archived_sessions/rollout-mid.jsonl", + "archived_sessions/rollout-new.jsonl", + ]); + expect(pending.pending).toEqual({ + state: true, + logs: true, + memories: true, + goals: true, + }); + + const retried = restoreTrashEntry(trashId, { codexHome: home }); + expect(retried.ok).toBe(true); + expect(retried.error).not.toBe("dest_exists"); + expect(retried.error).not.toBe("fs_failed"); + expect(retried.count).toBe(3); + expect(existsSync(stage)).toBe(false); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(home, "archived_sessions", "rollout-mid.jsonl"))).toBe(true); + expect(existsSync(join(home, "archived_sessions", "rollout-new.jsonl"))).toBe(true); + }, { timeout: 20_000 }); + + test("malformed restore-pending.json is not treated as a fresh restore", () => { + home = buildHome({ withSatelliteStores: true }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_600 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + writeFileSync(join(stage, "restore-pending.json"), "{not-valid-json", "utf8"); + const failed = restoreTrashEntry(trashId, { codexHome: home }); + expect(failed.ok).toBe(false); + expect(failed.error).toBe("fs_failed"); + expect(failed.count).toBe(0); + // Stage intact — no silent fresh restore that would move files under a corrupt marker. + expect(existsSync(join(stage, "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(false); + expect(readFileSync(join(stage, "restore-pending.json"), "utf8")).toBe("{not-valid-json"); + }, { timeout: 20_000 }); + + test("resume with owed satellite sections and missing backup fails closed", () => { + home = buildHome({ withSatelliteStores: true }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_700 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const partial = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failAfterFirstSatelliteCommit: true }, + }); + expect(partial.ok).toBe(false); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(true); + expect(existsSync(join(stage, "satellite-backup.json"))).toBe(true); + const pendingAfterPartial = JSON.parse(readFileSync(join(stage, "restore-pending.json"), "utf8")) as { + pending: { logs: boolean; memories: boolean; goals: boolean }; + }; + expect( + pendingAfterPartial.pending.logs + || pendingAfterPartial.pending.memories + || pendingAfterPartial.pending.goals, + ).toBe(true); + + unlinkSync(join(stage, "satellite-backup.json")); + const failed = restoreTrashEntry(trashId, { codexHome: home }); + expect(failed.ok).toBe(false); + expect(failed.error).toBe("db_reconcile_failed"); + expect(existsSync(stage)).toBe(true); + expect(existsSync(join(stage, "manifest.json"))).toBe(true); + }, { timeout: 20_000 }); + + test("resume with owed logs section but missing logs in backup fails closed", () => { + home = buildHome({ withSatelliteStores: true }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_800 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const partial = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failAfterStateCommit: true }, + }); + expect(partial.ok).toBe(false); + + const backupPath = join(stage, "satellite-backup.json"); + const backup = JSON.parse(readFileSync(backupPath, "utf8")) as Record; + delete backup.logs; + writeFileSync(backupPath, JSON.stringify(backup)); + + const pending = JSON.parse(readFileSync(join(stage, "restore-pending.json"), "utf8")) as { + pending: { logs: boolean; memories: boolean; goals: boolean }; + }; + expect(pending.pending.logs).toBe(true); + + const failed = restoreTrashEntry(trashId, { codexHome: home }); + expect(failed.ok).toBe(false); + expect(failed.error).toBe("db_reconcile_failed"); + expect(existsSync(stage)).toBe(true); + expect(existsSync(join(stage, "manifest.json"))).toBe(true); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(true); + }, { timeout: 20_000 }); + + test("failed tombstone rename keeps stage recoverable and listed", () => { + home = buildHome(); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_001_900 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const failed = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failStageTombstoneRename: true }, + }); + expect(failed.ok).toBe(false); + expect(failed.error).toBe("fs_failed"); + expect(failed.count).toBe(3); + expect(existsSync(stage)).toBe(true); + expect(existsSync(join(stage, "manifest.json"))).toBe(true); + expect(listTrashEntries(home).some(e => e.id === trashId)).toBe(true); + + const retried = restoreTrashEntry(trashId, { codexHome: home }); + expect(retried.ok).toBe(true); + expect(existsSync(stage)).toBe(false); + expect(listTrashEntries(home)).toEqual([]); + }, { timeout: 20_000 }); + + test("tombstone delete failure reports success without phantom trash entry", () => { + home = buildHome(); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_002_000 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const restored = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failTombstoneDelete: true }, + }); + expect(restored.ok).toBe(true); + expect(existsSync(stage)).toBe(false); + expect(listTrashEntries(home)).toEqual([]); + + const trashRoot = join(home, ".trash"); + const tombstones = readdirSync(trashRoot).filter(n => n.startsWith(".tombstone-")); + expect(tombstones.length).toBe(1); + }, { timeout: 20_000 }); + + test("cleanup rejects overlap with accepted restore-pending destinations after state-commit failure", () => { + home = buildHome({ withSatelliteStores: true }); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_002_000 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + const restoredRel = "archived_sessions/rollout-old.jsonl"; + + const partial = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failAfterStateCommit: true }, + }); + expect(partial.ok).toBe(false); + expect(existsSync(join(home, restoredRel))).toBe(true); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(true); + + const overlapDigest = computePreviewDigest( + selectOldestPercent(listArchivedCandidates(home), 100), + 100, + ); + const blocked = executeArchivedCleanup({ + percent: 100, + mode: "quarantine", + digest: overlapDigest, + codexHome: home, + }); + expect(blocked.ok).toBe(false); + expect(blocked.error).toBe("restore_pending_overlap"); + expect(existsSync(join(home, restoredRel))).toBe(true); + expect(existsSync(stage)).toBe(true); + + const retry = restoreTrashEntry(trashId, { codexHome: home }); + expect(retry.ok).toBe(true); + expect(existsSync(join(home, restoredRel))).toBe(true); + }, { timeout: 20_000 }); + + test("cleanup rejects overlap with accepted restore-pending destinations after file-move failure", () => { + home = buildHome(); + const quarantined = runWithDigest(100, "quarantine", home, { now: 1_700_000_002_100 }); + expect(quarantined.ok).toBe(true); + const trashId = quarantined.trashDir!; + const stage = join(home, ...trashId.split("/")); + + const partial = restoreTrashEntry(trashId, { + codexHome: home, + _test: { failAfterMoveCount: 1 }, + }); + expect(partial.ok).toBe(false); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(stage, "rollout-mid.jsonl"))).toBe(true); + expect(existsSync(join(stage, "restore-pending.json"))).toBe(true); + + const overlapDigest = computePreviewDigest( + selectOldestPercent(listArchivedCandidates(home), 100), + 100, + ); + const blocked = executeArchivedCleanup({ + percent: 100, + mode: "permanent", + digest: overlapDigest, + codexHome: home, + }); + expect(blocked.ok).toBe(false); + expect(blocked.error).toBe("restore_pending_overlap"); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(stage)).toBe(true); + + const retry = restoreTrashEntry(trashId, { codexHome: home }); + expect(retry.ok).toBe(true); + }, { timeout: 20_000 }); + + test("percent preview backfills past pending oldest archive for manual cleanup", () => { + home = buildHome(); + const stage = join(home, ".trash", "1700000"); + mkdirSync(stage, { recursive: true }); + writeFileSync(join(stage, "restore-pending.json"), JSON.stringify({ + version: 1, + filesRestored: true, + acceptedDestRels: ["archived_sessions/rollout-old.jsonl"], + pending: { state: true, logs: false, memories: false, goals: false }, + })); + + const preview = previewArchivedCleanup(34, home); + expect(preview.count).toBe(1); + expect(preview.candidates.map(c => c.relPath)).toEqual(["archived_sessions/rollout-mid.jsonl"]); + + const result = runWithDigest(34, "quarantine", home, { now: 1_700_000_001_000 }); + expect(result.ok).toBe(true); + expect(result.count).toBe(1); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(home, "archived_sessions", "rollout-mid.jsonl"))).toBe(false); + expect(existsSync(join(home, "archived_sessions", "rollout-new.jsonl"))).toBe(true); + }); +}); diff --git a/tests/storage-mutation-race.test.ts b/tests/storage-mutation-race.test.ts new file mode 100644 index 000000000..bb841cd58 --- /dev/null +++ b/tests/storage-mutation-race.test.ts @@ -0,0 +1,413 @@ +/** + * Regression: cleanup and restore must not mutate CODEX_HOME concurrently. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { + resetArchivedCleanupJobForTests, + setArchivedCleanupJobTestHooks, +} from "../src/storage/cleanup-job"; +import { + resetStorageCleanupPolicyJobForTests, + setStorageCleanupPolicyJobTestHooks, +} from "../src/storage/policy-job"; +import { + resetRestoreTrashJobForTests, + runRestoreTrashEntryJob, + setRestoreTrashJobTestHooks, +} from "../src/storage/restore-job"; +import { + resetStorageMutationCoordinatorForTests, +} from "../src/storage/storage-mutation-coordinator"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function baseConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "forward", + }, + }, + } as OcxConfig; +} + +function seedArchivedPair(codexHome: string): void { + mkdirSync(join(codexHome, "archived_sessions")); + writeFileSync(join(codexHome, "archived_sessions", "rollout-old.jsonl"), "o".repeat(100)); + writeFileSync(join(codexHome, "archived_sessions", "rollout-new.jsonl"), "n".repeat(200)); + utimesSync(join(codexHome, "archived_sessions", "rollout-old.jsonl"), new Date("2026-01-01"), new Date("2026-01-01")); + utimesSync(join(codexHome, "archived_sessions", "rollout-new.jsonl"), new Date("2026-06-01"), new Date("2026-06-01")); + const db = new Database(join(codexHome, "state_5.sqlite")); + db.exec(`CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER)`); + db.exec(`INSERT INTO threads VALUES + ('told','archived_sessions/rollout-old.jsonl',1), + ('tnew','archived_sessions/rollout-new.jsonl',1) + `); + db.close(); +} + +function threadCount(codexHome: string): number { + const db = new Database(join(codexHome, "state_5.sqlite")); + const row = db.query("SELECT COUNT(*) AS c FROM threads").get() as { c: number }; + db.close(); + return row.c; +} + +function trashStageCount(codexHome: string): number { + const trashRoot = join(codexHome, ".trash"); + if (!existsSync(trashRoot)) return 0; + return readdirSync(trashRoot).filter(name => !name.startsWith(".")).length; +} + +async function previewDigest(serverUrl: string, percent: number): Promise<{ digest: string; count: number }> { + const res = await fetch(new URL("/api/storage/cleanup/preview", serverUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + return { digest: body.digest, count: body.count }; +} + +async function enablePolicyAndRun(serverUrl: string): Promise<{ startedAt: number }> { + await fetch(new URL("/api/storage/cleanup-policy", serverUrl), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + enabled: true, + trigger: { archivedBytesOver: 50 }, + target: { removeOldestPercent: 50 }, + schedule: "manual", + mode: "quarantine", + }), + }); + const run = await fetch(new URL("/api/storage/cleanup-policy/run", serverUrl), { method: "POST" }); + expect(run.status).toBe(200); + const body = await run.json(); + expect(body.started).toBe(true); + return { startedAt: body.job.startedAt as number }; +} + +async function waitForPolicyJob( + serverUrl: string, + startedAt: number, + timeoutMs = 20_000, +): Promise<{ job: { lastOutcome?: { ok?: boolean; error?: string; removed?: number } } }> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const res = await fetch(new URL("/api/storage/cleanup-policy", serverUrl)); + const body = await res.json() as { + job: { + status: string; + startedAt?: number; + lastOutcome?: { ok?: boolean; error?: string; removed?: number }; + }; + }; + if (body.job.status === "idle" && body.job.lastOutcome && body.job.startedAt === startedAt) { + return body; + } + await Bun.sleep(50); + } + throw new Error("policy job did not finish"); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-storage-mutation-race-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-storage-mutation-race-")); + process.env.OPENCODEX_HOME = testDir; + saveConfig(baseConfig()); + resetRestoreTrashJobForTests(); + resetArchivedCleanupJobForTests(); + resetStorageCleanupPolicyJobForTests(); + resetStorageMutationCoordinatorForTests(); +}); + +afterEach(() => { + resetRestoreTrashJobForTests(); + resetArchivedCleanupJobForTests(); + resetStorageCleanupPolicyJobForTests(); + resetStorageMutationCoordinatorForTests(); + setRestoreTrashJobTestHooks(null); + setArchivedCleanupJobTestHooks(null); + setStorageCleanupPolicyJobTestHooks(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +describe("storage mutation coordinator", () => { + test("policy run is rejected while restore holds the shared mutation slot", async () => { + const home = isolatedCodexHome!.path; + setRestoreTrashJobTestHooks({ blockMs: 400, runInProcess: true }); + seedArchivedPair(home); + + const server = startServer(0); + try { + const preview = await previewDigest(server.url, 50); + const cleanupRes = await fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), + }); + const cleanup = await cleanupRes.json(); + const trashId = cleanup.trashDir as string; + + const restorePromise = runRestoreTrashEntryJob(trashId, { codexHome: home }); + await Bun.sleep(50); + + const { startedAt } = await enablePolicyAndRun(server.url); + const done = await waitForPolicyJob(server.url, startedAt); + expect(done.job.lastOutcome?.ok).toBe(false); + expect(done.job.lastOutcome?.error).toBe("storage_mutation_busy"); + + const restoreResult = await restorePromise; + expect(restoreResult.ok).toBe(true); + } finally { + await server.stop(true); + } + }, { timeout: 30_000 }); + + test("policy run is rejected while manual cleanup holds the shared mutation slot", async () => { + const home = isolatedCodexHome!.path; + setArchivedCleanupJobTestHooks({ blockMs: 1200 }); + seedArchivedPair(home); + + const server = startServer(0); + try { + const preview = await previewDigest(server.url, 50); + const cleanupPromise = fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), + }); + await Bun.sleep(80); + + const { startedAt } = await enablePolicyAndRun(server.url); + const done = await waitForPolicyJob(server.url, startedAt); + expect(done.job.lastOutcome?.ok).toBe(false); + expect(done.job.lastOutcome?.error).toBe("storage_mutation_busy"); + + const cleanupRes = await cleanupPromise; + expect(cleanupRes.status).toBe(200); + } finally { + await server.stop(true); + } + }, { timeout: 30_000 }); + + test("manual cleanup and restore are rejected while policy job holds the shared mutation slot", async () => { + const home = isolatedCodexHome!.path; + setStorageCleanupPolicyJobTestHooks({ blockMs: 1200 }); + seedArchivedPair(home); + + const server = startServer(0); + try { + await enablePolicyAndRun(server.url); + await Bun.sleep(80); + + const preview = await previewDigest(server.url, 50); + const cleanupRes = await fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), + }); + expect(cleanupRes.status).toBe(409); + expect((await cleanupRes.json()).error).toBe("storage_mutation_busy"); + + const restoreRes = await fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: ".trash/missing" }), + }); + expect(restoreRes.status).toBe(409); + expect((await restoreRes.json()).error).toBe("storage_mutation_busy"); + } finally { + await server.stop(true); + } + }, { timeout: 30_000 }); + + test("cleanup quarantine and permanent are rejected while restore holds slot after file moves", async () => { + const home = isolatedCodexHome!.path; + const holdMs = 2500; + setRestoreTrashJobTestHooks({ + restoreTest: { holdAfterFileMovesMs: holdMs }, + }); + seedArchivedPair(home); + + const server = startServer(0); + try { + const preview = await previewDigest(server.url, 50); + const cleanupRes = await fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), + }); + expect(cleanupRes.status).toBe(200); + const cleanup = await cleanupRes.json(); + const trashId = cleanup.trashDir as string; + const trashStage = join(home, trashId); + expect(existsSync(trashStage)).toBe(true); + + const remainingPreview = await previewDigest(server.url, 50); + expect(remainingPreview.count).toBe(1); + + const restorePromise = fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: trashId }), + }); + + const restoredPath = join(home, "archived_sessions", "rollout-old.jsonl"); + const movedDeadline = Date.now() + 8000; + while (!existsSync(restoredPath) && Date.now() < movedDeadline) { + await Bun.sleep(20); + } + expect(existsSync(restoredPath)).toBe(true); + expect(existsSync(join(trashStage, "rollout-old.jsonl"))).toBe(false); + expect(existsSync(join(trashStage, "restore-pending.json"))).toBe(true); + + const quarantineDuring = await fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + percent: 50, + mode: "quarantine", + digest: remainingPreview.digest, + }), + }); + expect(quarantineDuring.status).toBe(409); + expect((await quarantineDuring.json()).error).toBe("storage_mutation_busy"); + expect(existsSync(join(home, "archived_sessions", "rollout-new.jsonl"))).toBe(true); + expect(trashStageCount(home)).toBe(1); + + const previewPermanent = await previewDigest(server.url, 100); + const permanentDuring = await fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + percent: 100, + mode: "permanent", + digest: previewPermanent.digest, + }), + }); + expect(permanentDuring.status).toBe(409); + expect((await permanentDuring.json()).error).toBe("storage_mutation_busy"); + + const restoreRes = await restorePromise; + expect(restoreRes.status).toBe(200); + const restored = await restoreRes.json(); + expect(restored.ok).toBe(true); + expect(existsSync(restoredPath)).toBe(true); + expect(threadCount(home)).toBe(2); + expect(readFileSync(restoredPath, "utf8")).toBe("o".repeat(100)); + } finally { + await server.stop(true); + } + }, { timeout: 45_000 }); + + test("restore is rejected while cleanup holds the shared mutation slot", async () => { + const home = isolatedCodexHome!.path; + const blockMs = 1200; + setArchivedCleanupJobTestHooks({ blockMs }); + seedArchivedPair(home); + + const server = startServer(0); + try { + const preview = await previewDigest(server.url, 50); + const cleanupPromise = fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), + }); + + await Bun.sleep(80); + + const restoreAttempt = await fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: ".trash/never" }), + }); + expect(restoreAttempt.status).toBe(409); + expect((await restoreAttempt.json()).error).toBe("storage_mutation_busy"); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + expect(existsSync(join(home, "archived_sessions", "rollout-new.jsonl"))).toBe(true); + expect(trashStageCount(home)).toBe(0); + + const cleanupRes = await cleanupPromise; + expect(cleanupRes.status).toBe(200); + const cleanup = await cleanupRes.json(); + expect(cleanup.ok).toBe(true); + expect(cleanup.trashDir).toMatch(/^\.trash\//); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(false); + expect(existsSync(join(home, "archived_sessions", "rollout-new.jsonl"))).toBe(true); + expect(threadCount(home)).toBe(1); + } finally { + await server.stop(true); + } + }, { timeout: 30_000 }); + + test("second restore while first is in flight returns storage_mutation_busy", async () => { + const home = isolatedCodexHome!.path; + setRestoreTrashJobTestHooks({ blockMs: 800, runInProcess: true }); + seedArchivedPair(home); + + const server = startServer(0); + try { + const preview = await previewDigest(server.url, 50); + const cleanupRes = await fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 50, mode: "quarantine", digest: preview.digest }), + }); + const cleanup = await cleanupRes.json(); + const trashId = cleanup.trashDir as string; + + const first = fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: trashId }), + }); + await Bun.sleep(50); + const second = await fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: trashId }), + }); + expect(second.status).toBe(409); + expect((await second.json()).error).toBe("storage_mutation_busy"); + + const firstRes = await first; + expect(firstRes.status).toBe(200); + } finally { + await server.stop(true); + } + }, { timeout: 30_000 }); +}); diff --git a/tests/storage-policy.test.ts b/tests/storage-policy.test.ts index e0d36222a..318959b27 100644 --- a/tests/storage-policy.test.ts +++ b/tests/storage-policy.test.ts @@ -156,6 +156,67 @@ describe("selection helpers", () => { expect(percentForAtLeastCount(4, 2)).toBe(50); }); + test("selectPolicyPreview backfills percent past pending oldest archive", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + { name: "rollout-mid.jsonl", bytes: 100, when: MID }, + { name: "rollout-new.jsonl", bytes: 100, when: NEW }, + ]); + const stage = join(dir, ".trash", "99000"); + mkdirSync(stage, { recursive: true }); + writeFileSync(join(stage, "restore-pending.json"), JSON.stringify({ + version: 1, + filesRestored: true, + acceptedDestRels: ["archived_sessions/rollout-old.jsonl"], + pending: { state: true, logs: false, memories: false, goals: false }, + })); + + const preview = selectPolicyPreview( + policy({ + enabled: true, + trigger: { archivedBytesOver: 1 }, + target: { removeOldestPercent: 34 }, + schedule: "manual", + mode: "quarantine", + }), + dir, + ); + expect(preview.count).toBe(1); + expect(preview.bytes).toBe(100); + }); + + test("selectPolicyPreview reduceToBytes skips pending oldest and keeps backfilling", () => { + const dir = seedHome([ + { name: "rollout-old.jsonl", bytes: 100, when: OLD }, + { name: "rollout-mid.jsonl", bytes: 100, when: MID }, + { name: "rollout-new.jsonl", bytes: 100, when: NEW }, + ]); + const stage = join(dir, ".trash", "99001"); + mkdirSync(stage, { recursive: true }); + writeFileSync(join(stage, "restore-pending.json"), JSON.stringify({ + version: 1, + filesRestored: true, + acceptedDestRels: ["archived_sessions/rollout-old.jsonl"], + pending: { state: true, logs: false, memories: false, goals: false }, + })); + + const preview = selectPolicyPreview( + policy({ + enabled: true, + trigger: { archivedBytesOver: 1 }, + target: { reduceToBytes: 100 }, + schedule: "manual", + mode: "quarantine", + }), + dir, + ); + expect(preview.count).toBe(2); + expect(preview.candidateRelPaths).toEqual([ + "archived_sessions/rollout-mid.jsonl", + "archived_sessions/rollout-new.jsonl", + ]); + }); + test("selectPolicyPreview honors removeOldestPercent", () => { const dir = seedHome([ { name: "rollout-old.jsonl", bytes: 50, when: OLD }, diff --git a/tests/storage-restore-job-errors.test.ts b/tests/storage-restore-job-errors.test.ts new file mode 100644 index 000000000..eb45d7b99 --- /dev/null +++ b/tests/storage-restore-job-errors.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { restoreResultFromWorkerRejection } from "../src/storage/restore-job"; + +describe("restoreResultFromWorkerRejection", () => { + test("maps worker timeout to restore_worker_timeout", () => { + const result = restoreResultFromWorkerRejection(new Error("restore_worker_timeout"), ".trash/1"); + expect(result).toMatchObject({ + ok: false, + error: "restore_worker_timeout", + count: 0, + bytes: 0, + restoredPaths: [], + }); + expect(result.message).toBeUndefined(); + }); + + test("maps cancel to restore_worker_aborted", () => { + const result = restoreResultFromWorkerRejection(new Error("aborted"), ".trash/1"); + expect(result.error).toBe("restore_worker_aborted"); + }); + + test("maps generic worker failure", () => { + const result = restoreResultFromWorkerRejection(new Error("worker_failed"), ".trash/1"); + expect(result.error).toBe("restore_worker_failed"); + expect(result.message).toBeUndefined(); + }); + + test("preserves unexpected worker crash detail", () => { + const result = restoreResultFromWorkerRejection( + new Error("sqlite disk I/O error"), + ".trash/1", + ); + expect(result.error).toBe("restore_worker_failed"); + expect(result.message).toBe("sqlite disk I/O error"); + }); +}); diff --git a/tests/storage-restore-job-responsive.test.ts b/tests/storage-restore-job-responsive.test.ts new file mode 100644 index 000000000..fb59e7d2f --- /dev/null +++ b/tests/storage-restore-job-responsive.test.ts @@ -0,0 +1,151 @@ +/** + * Regression: a blocked restore Worker must not stall /healthz or an active + * streaming response on the proxy event loop. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { + resetRestoreTrashJobForTests, + setRestoreTrashJobTestHooks, +} from "../src/storage/restore-job"; + +let testDir = ""; +let previousHome: string | undefined; +let previousCleanupTestHooks: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function baseConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "forward", + }, + }, + } as OcxConfig; +} + +function seedArchived(codexHome: string): void { + mkdirSync(join(codexHome, "archived_sessions")); + writeFileSync(join(codexHome, "archived_sessions", "rollout-old.jsonl"), "o".repeat(100)); + utimesSync(join(codexHome, "archived_sessions", "rollout-old.jsonl"), new Date("2026-01-01"), new Date("2026-01-01")); + const db = new Database(join(codexHome, "state_5.sqlite")); + db.exec(`CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, archived INTEGER)`); + db.exec(`INSERT INTO threads VALUES ('told','archived_sessions/rollout-old.jsonl',1)`); + db.close(); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCleanupTestHooks = process.env.OPENCODEX_CLEANUP_TEST_HOOKS; + process.env.OPENCODEX_CLEANUP_TEST_HOOKS = "1"; + isolatedCodexHome = installIsolatedCodexHome("ocx-restore-job-responsive-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-restore-job-responsive-")); + process.env.OPENCODEX_HOME = testDir; + saveConfig(baseConfig()); + resetRestoreTrashJobForTests(); +}); + +afterEach(() => { + resetRestoreTrashJobForTests(); + setRestoreTrashJobTestHooks(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCleanupTestHooks === undefined) delete process.env.OPENCODEX_CLEANUP_TEST_HOOKS; + else process.env.OPENCODEX_CLEANUP_TEST_HOOKS = previousCleanupTestHooks; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +describe("storage trash restore job responsiveness", () => { + test("test-stream route is absent without OPENCODEX_CLEANUP_TEST_HOOKS", async () => { + delete process.env.OPENCODEX_CLEANUP_TEST_HOOKS; + setRestoreTrashJobTestHooks({ enableTestStream: true }); + const server = startServer(0); + try { + const res = await fetch(new URL("/api/storage/trash/restore/test-stream", server.url)); + expect(res.status).toBe(404); + } finally { + await server.stop(true); + } + }); + + test("blocked worker keeps /healthz and streaming response responsive", async () => { + const blockMs = 1200; + setRestoreTrashJobTestHooks({ blockMs, enableTestStream: true }); + seedArchived(isolatedCodexHome!.path); + + const server = startServer(0); + try { + const previewRes = await fetch(new URL("/api/storage/cleanup/preview", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 100 }), + }); + expect(previewRes.status).toBe(200); + const preview = await previewRes.json(); + const cleanupRes = await fetch(new URL("/api/storage/cleanup", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ percent: 100, mode: "quarantine", digest: preview.digest }), + }); + expect(cleanupRes.status).toBe(200); + const cleanup = await cleanupRes.json(); + expect(cleanup.trashDir).toMatch(/^\.trash\//); + + const restoreStarted = Date.now(); + const restorePromise = fetch(new URL("/api/storage/trash/restore", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: cleanup.trashDir }), + }); + + const streamPromise = (async () => { + const res = await fetch(new URL("/api/storage/trash/restore/test-stream", server.url)); + expect(res.ok).toBe(true); + return await res.text(); + })(); + + const healthSamples: number[] = []; + for (let i = 0; i < 6; i++) { + const t0 = Date.now(); + const health = await fetch(new URL("/healthz", server.url)); + expect(health.status).toBe(200); + const elapsed = Date.now() - t0; + if (i > 0) healthSamples.push(elapsed); + await Bun.sleep(40); + } + + const streamText = await streamPromise; + expect(streamText.split("\n").filter(Boolean).length).toBe(8); + + const maxHealthMs = Math.floor(blockMs / 3); + for (const sample of healthSamples) { + expect(sample).toBeLessThan(maxHealthMs); + } + + const restoreRes = await restorePromise; + expect(restoreRes.status).toBe(200); + const restored = await restoreRes.json(); + expect(restored.ok).toBe(true); + expect(Date.now() - restoreStarted).toBeGreaterThanOrEqual(blockMs - 100); + expect(existsSync(join(isolatedCodexHome!.path, "archived_sessions", "rollout-old.jsonl"))).toBe(true); + } finally { + await server.stop(true); + resetRestoreTrashJobForTests(); + } + }, { timeout: 30_000 }); +}); From c0b902f7dd75a3515d7c1f268b8d9e32e808ceae Mon Sep 17 00:00:00 2001 From: Aciredy Date: Mon, 27 Jul 2026 15:00:05 -0600 Subject: [PATCH 012/129] fix(cursor): stop shell-alias user mutation; reject empty bridge calls Based on the original report and patch from @Aciredy. Removes Cursor user/developer prompt mutation for shell-alias hints (guidance stays in the system note) and rejects empty/invalid shell_command and exec_command payloads before they reach the bridge. Co-authored-by: Aciredy <254986922+Aciredy@users.noreply.github.com> --- src/adapters/cursor/protobuf-events.ts | 26 ++++- src/adapters/cursor/protobuf-request.ts | 3 +- src/adapters/cursor/tool-definitions.ts | 89 +++++++++++------ tests/cursor-blob.test.ts | 9 +- tests/cursor-tool-arg-decoding.test.ts | 121 ++++++++++++++++++++++++ tests/cursor-tool-definitions.test.ts | 28 ++++++ tests/server-auth.test.ts | 8 +- 7 files changed, 247 insertions(+), 37 deletions(-) diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index 8c1fc7cd0..a682d0017 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -3,6 +3,10 @@ import type { AgentServerMessage, McpArgs, ToolCall } from "./gen/agent_pb"; import { decodeCursorArgsMap } from "./arg-codec"; import { normalizeArgKeys } from "./arg-normalize"; import { + cursorShellBridgeArgsValid, + cursorShellBridgeDropError, + defaultShellBridgeArgNormalizeSchema, + isCodexShellBridgeToolName, normalizeCursorWireName, OCX_RESPONSES_TOOL_PROVIDER, resolveShellBridgeAliasKey, @@ -324,10 +328,18 @@ export function mapSyntheticMcpExecToToolEvents( out.push(...commitToolCall(options.state, callId, finalArgs)); return out; } + const responsesName = responsesToolNameFromCursorWire(cursorWireName); + const normSchema = defaultShellBridgeArgNormalizeSchema(responsesName); + const normalizedArgs = JSON.stringify(normalizeArgKeys(decodeCursorArgsMap(args?.args), normSchema)); + if (!cursorShellBridgeArgsValid(normalizedArgs, responsesName, normSchema)) { + if (isCodexShellBridgeToolName(responsesName)) { + return [{ type: "error", message: cursorShellBridgeDropError(responsesName) }]; + } + } // Stateless fallback (no shared event state): emit a complete, self-contained tool call. return [ - { type: "tool_call_start", id: callId, name: responsesToolNameFromCursorWire(cursorWireName) }, - { type: "tool_call_delta", arguments: decodeMcpArgs(args) }, + { type: "tool_call_start", id: callId, name: responsesName }, + ...(normalizedArgs.length > 2 ? [{ type: "tool_call_delta" as const, arguments: normalizedArgs }] : []), { type: "tool_call_end", id: callId }, ]; } @@ -361,9 +373,19 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW * recorded in `openToolCalls`. Because each completion emits a whole non-interleaved unit, the bridge * (which tracks a single current tool call) serializes parallel Cursor calls correctly. */ +function dropShellBridgeCall(state: CursorProtobufEventState, callId: string, toolName: string): CursorServerMessage[] { + state.openToolCalls.delete(callId); + state.completedToolCalls.add(callId); + return [{ type: "error", message: cursorShellBridgeDropError(toolName) }]; +} + function commitToolCall(state: CursorProtobufEventState, callId: string, finalArgs: string): CursorServerMessage[] { const open = state.openToolCalls.get(callId); if (!open) return []; + const schema = toolSchemaForWireName(state, open.name); + if (!cursorShellBridgeArgsValid(finalArgs, open.name, schema)) { + if (isCodexShellBridgeToolName(open.name)) return dropShellBridgeCall(state, callId, open.name); + } const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: open.name }]; if (finalArgs.length > 0) out.push({ type: "tool_call_delta", arguments: finalArgs }); out.push(...endToolCall(state, callId)); diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index cd856b984..c7030bf5b 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -38,7 +38,6 @@ import { } from "./gen/agent_pb"; import { appendCursorGenericToolUseHint, - appendCursorShellAliasHint, cursorToolsForActivePrompt, buildCursorToolGuidanceSystemNote, buildCursorToolDefinitions, @@ -560,7 +559,7 @@ export function prepareCursorRunRequest( const rawText = activePromptText(request); const lastRole = request.messages.at(-1)?.role; const text = lastRole === "user" || lastRole === "developer" - ? appendCursorShellAliasHint(request.tools, appendCursorGenericToolUseHint(request.tools, rawText)) + ? appendCursorGenericToolUseHint(request.tools, rawText) : rawText; // Tool-result-only turns resume the remembered Cursor conversation with results in history. const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult"; diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 02e029ffa..274cda914 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -12,8 +12,6 @@ export const CURSOR_EXEC_COMMAND_TOOL = CODEX_EXEC_COMMAND_TOOL; export const CODEX_SHELL_BRIDGE_TOOL_NAMES = [CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL] as const; export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE = 'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell; never say native shell is blocked.'; -export const CURSOR_SHELL_ALIAS_USER_HINT = - "Use the Codex shell bridge tool listed this turn (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` name). Prefer that bridge over Cursor-native Shell/Read. Never say phrases like \"Native shell access is blocked\" or \"Shell access is blocked\" — silently use the bridge unless that bridge tool itself fails."; const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS"] as const; export const CURSOR_GENERIC_TOOL_USE_USER_HINT = [ @@ -213,17 +211,6 @@ function shellBridgeArgNormalizeSchema(tool: OcxTool): unknown { }; } -function activeTextMentionsExecCommand(text: string): boolean { - return /\b(?:exec_command|shell_command)\b/i.test(text); -} - -function looksLikeShellCommandRequest(text: string): boolean { - const hasKnownCommand = /(?:^|[\s`$])(?:echo|pwd|ls|cat|grep|rg|find|python3?|node|bun|npm|pnpm|yarn|git|curl|wget|chmod|mkdir|rm|cp|mv|touch|docker|kubectl|make|cargo|go|pytest)(?=\s|$|[`:;|&])/i.test(text); - const hasRunIntent = /\b(?:run|execute|exec)\b/i.test(text) || /\b(?:stdout|stderr|exit\s+code)\b/i.test(text); - const hasShellTarget = /\b(?:shell|terminal|command|cmd)\b/i.test(text); - return /\b(?:run|execute|exec)\s*:/i.test(text) || hasKnownCommand || (hasRunIntent && hasShellTarget); -} - export function isGenericToolUseCountDemoPrompt(text: string): boolean { const trimmed = text.trim(); if (trimmed.length === 0) return false; @@ -316,23 +303,69 @@ export function cursorToolsForActivePrompt 0 ? execTools : tools; } -export function shouldAppendCursorShellAliasHint( - tools: readonly Pick[] | undefined, - text: string, -): boolean { - const trimmed = text.trim(); - return trimmed.length > 0 - && cursorRequestHasShellAlias(tools) - && !activeTextMentionsExecCommand(trimmed) - && looksLikeShellCommandRequest(trimmed); +/** + * Required command payload keys for a shell bridge tool, derived from the advertised schema when present. + */ +export function shellBridgeRequiredCommandKeys( + toolName: string, + schema?: unknown, +): readonly ("cmd" | "command")[] { + if (schema && typeof schema === "object") { + const required = (schema as Record).required; + if (Array.isArray(required)) { + const keys = required.filter((key): key is "cmd" | "command" => key === "cmd" || key === "command"); + if (keys.length > 0) return keys; + } + } + return toolName === CODEX_SHELL_COMMAND_TOOL ? ["command"] : ["cmd"]; } -export function appendCursorShellAliasHint( - tools: readonly Pick[] | undefined, - text: string, -): string { - if (!shouldAppendCursorShellAliasHint(tools, text)) return text; - return `${text}${text.endsWith("\n") ? "\n" : "\n\n"}${CURSOR_SHELL_ALIAS_USER_HINT}`; +/** Normalize-schema defaults used when validating stateless synthetic shell-bridge calls. */ +export function defaultShellBridgeArgNormalizeSchema(toolName: string): unknown { + return toolName === CODEX_SHELL_COMMAND_TOOL + ? CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA + : { + type: "object", + properties: CURSOR_EXEC_COMMAND_INPUT_SCHEMA.properties, + required: ["cmd"], + }; +} + +export function cursorShellBridgeDropError(toolName: string): string { + return `Cursor emitted ${toolName} without a non-empty command; the tool call was dropped.`; +} + +/** + * Extract a non-empty shell command from completed Cursor bridge args using the schema's required + * command key (`cmd` for bare exec_command, `command` for shell_command). + */ +export function nonEmptyShellBridgeCommandFromArgs( + finalArgs: string, + toolName: string, + schema?: unknown, +): string | undefined { + let parsed: unknown; + try { + parsed = finalArgs.length > 0 ? JSON.parse(finalArgs) : {}; + } catch { + return undefined; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + const record = parsed as Record; + for (const key of shellBridgeRequiredCommandKeys(toolName, schema)) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + } + return undefined; +} + +export function cursorShellBridgeArgsValid( + finalArgs: string, + toolName: string, + schema?: unknown, +): boolean { + return !isCodexShellBridgeToolName(toolName) + || nonEmptyShellBridgeCommandFromArgs(finalArgs, toolName, schema) !== undefined; } export function cursorToolAllowedByChoice( diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 3e1b6caea..e25e0c925 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -310,12 +310,13 @@ describe("Cursor blob handshake", () => { }); - test("adds exec_command prompt hints for active shell requests when native exec is available", () => { + test("keeps exec_command guidance in the system prompt without mutating the user request", () => { + const prompt = "Run: echo OCX via your shell tool, report stdout."; const bytes = encodeCursorRunRequest({ modelId: "claude-4.6-sonnet", conversationId: "c1", system: ["You are helpful."], - messages: [{ role: "user", content: "Run: echo OCX via your shell tool, report stdout." }], + messages: [{ role: "user", content: prompt }], tools: [{ name: "exec_command", description: "Run a command", @@ -326,8 +327,8 @@ describe("Cursor blob handshake", () => { const roots = decodeRootMessages(bytes); expect(JSON.stringify(roots)).toContain("Shell commands use"); expect(JSON.stringify(roots)).toContain("exec_command"); - expect(actionText(bytes)).toContain("Run: echo OCX via your shell tool, report stdout."); - expect(actionText(bytes)).toContain("Use the Codex shell bridge tool listed this turn"); + expect(actionText(bytes)).toBe(prompt); + expect(actionText(bytes)).not.toContain("Use the Codex shell bridge tool listed this turn"); }); test("adds generic exec_command guidance for active tool-count demo prompts", () => { diff --git a/tests/cursor-tool-arg-decoding.test.ts b/tests/cursor-tool-arg-decoding.test.ts index 94ddef400..d3cf141a8 100644 --- a/tests/cursor-tool-arg-decoding.test.ts +++ b/tests/cursor-tool-arg-decoding.test.ts @@ -241,6 +241,127 @@ describe("Cursor Responses tool argument decoding", () => { ]); }); + describe("shell bridge rejects empty or malformed command args", () => { + const dropped = (tool: string) => + `Cursor emitted ${tool} without a non-empty command; the tool call was dropped.`; + + function shellBridgeArgs( + toolName: "exec_command" | "shell_command", + args: Record, + callId: string, + ) { + return create(McpArgsSchema, { + name: toolName, + toolName: toolName, + toolCallId: callId, + providerIdentifier: "opencodex-responses", + args, + }); + } + + function expectDropped(state: CursorProtobufEventState, callId: string, toolName: string) { + expect(state.openToolCalls.has(callId)).toBe(false); + expect(state.completedToolCalls.has(callId)).toBe(true); + expect(dropped(toolName)).toContain(toolName); + } + + test.each([ + ["exec_command", "cmd", "echo hi"] as const, + ["shell_command", "command", "echo hi"] as const, + ])("%s accepts a valid %s payload", (toolName, field, command) => { + const state = createCursorProtobufEventState({ clientToolNames: [toolName] }); + const args = shellBridgeArgs(toolName, { [field]: jsonBytes(command) }, `toolu_valid_${toolName}`); + expect(mapSyntheticMcpExecToToolEvents(args, "fallback", { allowEmptyArgs: true, state })).toEqual([ + { type: "tool_call_start", id: `toolu_valid_${toolName}`, name: toolName }, + { type: "tool_call_delta", arguments: expect.stringContaining(command) }, + { type: "tool_call_end", id: `toolu_valid_${toolName}` }, + ]); + }); + + test.each([ + ["exec_command", "cmd"] as const, + ["shell_command", "command"] as const, + ])("%s rejects missing args", (toolName, field) => { + void field; + const callId = `toolu_empty_${toolName}`; + const state = createCursorProtobufEventState({ clientToolNames: [toolName] }); + const args = shellBridgeArgs(toolName, {}, callId); + expect(mapSyntheticMcpExecToToolEvents(args, "fallback", { allowEmptyArgs: true, state })).toEqual([ + { type: "error", message: dropped(toolName) }, + ]); + expectDropped(state, callId, toolName); + }); + + test.each([ + ["exec_command", "cmd"] as const, + ["shell_command", "command"] as const, + ])("%s rejects whitespace-only %s", (toolName, field) => { + const callId = `toolu_blank_${toolName}`; + const state = createCursorProtobufEventState({ clientToolNames: [toolName] }); + const args = shellBridgeArgs(toolName, { [field]: jsonBytes(" ") }, callId); + expect(mapSyntheticMcpExecToToolEvents(args, "fallback", { allowEmptyArgs: true, state })).toEqual([ + { type: "error", message: dropped(toolName) }, + ]); + expectDropped(state, callId, toolName); + }); + + test.each([ + ["exec_command", "cmd", 42] as const, + ["exec_command", "cmd", {}] as const, + ["exec_command", "cmd", ""] as const, + ["shell_command", "command", 42] as const, + ["shell_command", "command", {}] as const, + ["shell_command", "command", ""] as const, + ])("%s rejects invalid %s=%s", (toolName, field, value) => { + const callId = `toolu_bad_${toolName}_${field}_${String(value)}`; + const state = createCursorProtobufEventState({ clientToolNames: [toolName] }); + const args = shellBridgeArgs(toolName, { [field]: jsonBytes(value) }, callId); + expect(mapSyntheticMcpExecToToolEvents(args, "fallback", { allowEmptyArgs: true, state })).toEqual([ + { type: "error", message: dropped(toolName) }, + ]); + expectDropped(state, callId, toolName); + }); + + test("exec_command rejects command-only payload when cmd is required", () => { + const callId = "toolu_command_only"; + const state = createCursorProtobufEventState({ clientToolNames: ["exec_command"] }); + const args = shellBridgeArgs("exec_command", { command: jsonBytes("echo hi") }, callId); + expect(mapSyntheticMcpExecToToolEvents(args, "fallback", { allowEmptyArgs: true, state })).toEqual([ + { type: "error", message: dropped("exec_command") }, + ]); + expectDropped(state, callId, "exec_command"); + }); + + test("exec_command rejects blank cmd even when command is present", () => { + const callId = "toolu_blank_cmd_with_command"; + const state = createCursorProtobufEventState({ clientToolNames: ["exec_command"] }); + const args = shellBridgeArgs("exec_command", { + cmd: jsonBytes(""), + command: jsonBytes("echo hi"), + }, callId); + expect(mapSyntheticMcpExecToToolEvents(args, "fallback", { allowEmptyArgs: true, state })).toEqual([ + { type: "error", message: dropped("exec_command") }, + ]); + expectDropped(state, callId, "exec_command"); + }); + + test("stateless exec_command rejects empty args when allowEmptyArgs is enabled", () => { + const args = shellBridgeArgs("exec_command", {}, "toolu_stateless_empty"); + expect(mapSyntheticMcpExecToToolEvents(args, "fallback", { allowEmptyArgs: true })).toEqual([ + { type: "error", message: dropped("exec_command") }, + ]); + }); + + test("stateless shell_command accepts cmd that normalizes to command", () => { + const args = shellBridgeArgs("shell_command", { cmd: jsonBytes("echo hi") }, "toolu_stateless_cmd"); + expect(mapSyntheticMcpExecToToolEvents(args, "fallback", { allowEmptyArgs: true })).toEqual([ + { type: "tool_call_start", id: "toolu_stateless_cmd", name: "shell_command" }, + { type: "tool_call_delta", arguments: "{\"command\":\"echo hi\"}" }, + { type: "tool_call_end", id: "toolu_stateless_cmd" }, + ]); + }); + }); + test("synthetic native mcp exec emits a self-contained atomic tool call", () => { // Native-exec delivers the whole call at once. With deferred-start emission the synthetic mapper // always emits its own start -> delta -> end unit (the old suppressStart option is gone). diff --git a/tests/cursor-tool-definitions.test.ts b/tests/cursor-tool-definitions.test.ts index 7af6bb8db..652a93515 100644 --- a/tests/cursor-tool-definitions.test.ts +++ b/tests/cursor-tool-definitions.test.ts @@ -13,6 +13,7 @@ import { cursorToolInputSchema, cursorToolWireName, isGenericToolUseCountDemoPrompt, + nonEmptyShellBridgeCommandFromArgs, } from "../src/adapters/cursor/tool-definitions"; import type { OcxTool } from "../src/types"; @@ -143,6 +144,33 @@ describe("Cursor tool definitions", () => { }); }); + test("shell bridge command validation honors the schema-required command key", () => { + const execSchema = cursorToolArgNormalizeSchema({ + name: "exec_command", + description: "Run a command", + parameters: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }, + } as OcxTool); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: "echo hi" }), "exec_command", execSchema)).toBe("echo hi"); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ command: "echo hi" }), "exec_command", execSchema)).toBeUndefined(); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: "", command: "echo hi" }), "exec_command", execSchema)).toBeUndefined(); + + const shellSchema = cursorToolArgNormalizeSchema({ + name: "shell_command", + description: "Run a command", + parameters: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + } as OcxTool); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ command: "echo hi" }), "shell_command", shellSchema)).toBe("echo hi"); + expect(nonEmptyShellBridgeCommandFromArgs(JSON.stringify({ cmd: "echo hi" }), "shell_command", shellSchema)).toBeUndefined(); + }); + test("does not alias namespaced exec_command tools", () => { const tool: OcxTool = { name: "exec_command", diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index ff379711e..5a409f1e1 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -124,6 +124,7 @@ type PoolRetryHarness = { config: OcxConfig; dispatches: string[]; request: (init?: { stream?: boolean; signal?: AbortSignal }) => Promise; + restoreFetch: () => void; server: ReturnType; upstream: ReturnType; }; @@ -151,6 +152,7 @@ async function startPoolRetryHarness( }, }); redirectCanonicalCodexTo(upstream.url.toString()); + const redirectedFetch = globalThis.fetch; const secondAccount = options.secondAccount ?? true; const config = { @@ -190,6 +192,9 @@ async function startPoolRetryHarness( return { config, dispatches, + restoreFetch: () => { + if (globalThis.fetch === redirectedFetch) globalThis.fetch = originalGlobalFetch; + }, server, upstream, request: ({ stream = false, signal } = {}) => originalGlobalFetch( @@ -205,6 +210,7 @@ async function startPoolRetryHarness( } async function stopPoolRetryHarness(harness: PoolRetryHarness): Promise { + harness.restoreFetch(); await harness.server.stop(true); await harness.upstream.stop(true); } @@ -1892,7 +1898,7 @@ describe("server local API auth", () => { } finally { await stopPoolRetryHarness(positive); } - }); + }, 12_000); test("valid JSON wrong top-level shape never authorizes a pool retry", async () => { for (const body of ['"string"', "42", "true", "null", '["detail"]']) { From e2da6f6df3ae4e2987985ae04deec6370eb4d11c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 28 Jul 2026 06:06:48 +0900 Subject: [PATCH 013/129] fix(server): treat forwarded loopback ports as loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isLoopbackRequestHost() required the Host header's port to equal the proxy's own port, so a proxy reached through `ssh -L 20100:localhost:10100` arrived as `Host: localhost:20100` and was refused. That gate sits in front of the whole data plane — /v1/models, /v1/responses, /v1/messages, /v1/chat/completions, /v1/live and the Responses WebSocket upgrade — not just CORS, and it fires with no Origin header at all, so Codex CLI, Claude Code and curl all saw a proxy that looked completely dead rather than one with a CORS problem. Loopback is a trust boundary by hostname, not by port. The sibling isLoopbackOriginValue() dropped its own port check for exactly this reason in e4e06125b. Port equality was never the rebinding defense either: a rebinding browser connects to the real port and sends it verbatim, so the hostname check is what rejected it before and still does. Verified against 40 Host forms — localhost.attacker.com, 127.0.0.1.nip.io, localtest.me, 0.0.0.0, 127.0.0.2, [::ffff:127.0.0.1] and a Cyrillic homograph all stay refused. Also accepts the FQDN form `localhost.`, which curl sends verbatim and which was refused for the same class of reason. The predicate had no test coverage at all. The new file pins both directions, including a characterization test for the pre-existing fail-open on an unparseable Host so tightening it later needs a deliberate failing test. Docs: forwarding is now documented, with the caveats that a forwarded loopback is unauthenticated (ssh -g / container publishing expose it), that the client base URL must be set by hand, and that provider OAuth login needs its own forward. --- .../content/docs/reference/configuration.md | 33 +++++++ src/server/auth-cors.ts | 19 +++- tests/server-loopback-host-gate.test.ts | 98 +++++++++++++++++++ 3 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 tests/server-loopback-host-gate.test.ts diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index b56ac6fce..c3a1c3a9e 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -167,6 +167,39 @@ Binding to `0.0.0.0` exposes your proxy — and all configured provider credenti network. Only do this on trusted networks, and always set a strong `OPENCODEX_API_AUTH_TOKEN`. ::: +### SSH port forwarding + +You do not need a non-loopback bind to use a proxy on another machine. Forward the port +over SSH and leave `hostname` at its `127.0.0.1` default: + +```bash +ssh -L 20100:localhost:10100 you@remote +``` + +The local port does not have to match the remote one. opencodex treats any request whose +`Host` resolves to `localhost`, `127.0.0.1`, or `::1` as loopback regardless of port, so +`http://localhost:20100/v1` works for Codex CLI, Claude Code, the dashboard, and `curl`. + +Point the client at the forwarded port yourself — `ocx` only ever writes `127.0.0.1` with the +local default port into client config, so a forwarded setup needs the base URL set by hand. + +Provider OAuth login is the one flow a single forward does not cover: the login callback +listens on a fixed port on the *remote* machine. Either run `ocx login ` there, or +forward that port too: + +```bash +ssh -L 20100:localhost:10100 -L 1455:localhost:1455 you@remote +``` + +:::caution[Forwarded loopback is unauthenticated] +A loopback bind has no token authentication — that is what makes the default setup usable +without configuration. A plain `ssh -L` keeps the listener on your own loopback interface, so +nothing else can reach it. But `ssh -g -L`, container port publishing, and some devcontainer +or Codespaces forwarding modes bind the *client* side to `0.0.0.0`, which exposes both the +management API and the data plane to that network with no credential. Use `-L` without `-g`, +or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10100`). +::: + ## Providers (`OcxProviderConfig`) | Field | Type | Meaning | diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index a5a8aaacf..b8b62fc00 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -18,6 +18,7 @@ import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } +/** The proxy's own listening port. No admission check uses it: both loopback predicates key on hostname alone. */ export function configuredPort(): string { try { return new URL(_corsOrigin).port; } catch { return "10100"; } } @@ -35,8 +36,18 @@ export function parseHttpHost(value: string | null): { hostname: string; port: s export function isLoopbackRequestHost(value: string | null): boolean { const parsed = parseHttpHost(value); if (!parsed) return true; - if (!isLoopbackHostname(parsed.hostname)) return false; - return parsed.port === "" || parsed.port === configuredPort(); + // Loopback is a trust boundary by hostname, not by port. `ssh -L 20100:localhost:10100` + // legitimately arrives as `Host: localhost:20100`, and refusing it took the whole /v1/* + // data plane down with it, not just CORS. The sibling isLoopbackOriginValue() dropped its + // own port check for the same reason in e4e06125b ("same-trust-boundary"). Port equality + // was never the rebinding defense: a rebinding browser connects to the real port and sends + // it verbatim, so the hostname check below is what rejected it then and now. + // + // Scope of that guarantee: it holds for Hosts `parseHttpHost` can parse. An unparseable + // Host still returns true above — pre-existing behavior, not browser-reachable (a browser + // composes Host from its own connection), and pinned by a characterization test in + // tests/server-loopback-host-gate.test.ts. Tightening it is separate work. + return isLoopbackHostname(parsed.hostname); } export function isLoopbackOriginValue(value: string): boolean { @@ -115,7 +126,9 @@ export function configuredApiAuthToken(_config: OcxConfig): string | undefined { } export function isLoopbackHostname(hostname: string | undefined): boolean { - const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase(); + // A fully-qualified "localhost." is the same host as "localhost": curl and some clients + // send the trailing dot verbatim, and refusing it 403s a legitimate loopback caller. + const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase().replace(/\.$/, ""); return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; } diff --git a/tests/server-loopback-host-gate.test.ts b/tests/server-loopback-host-gate.test.ts new file mode 100644 index 000000000..8c8a617fe --- /dev/null +++ b/tests/server-loopback-host-gate.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { isAllowedRequestOrigin, isLoopbackRequestHost } from "../src/server/auth-cors"; +import type { OcxConfig } from "../src/types"; + +// A loopback bind: isApiAuthRequired() is false, so admission runs through the +// Host/Origin branch these tests exercise. +const loopbackConfig = { hostname: "127.0.0.1" } as OcxConfig; + +function request(host: string, origin?: string): Request { + const headers: Record = { Host: host }; + if (origin) headers.Origin = origin; + return new Request("http://x/v1/models", { headers }); +} + +describe("isLoopbackRequestHost", () => { + test("a forwarded loopback port is still loopback (ssh -L 20100:localhost:10100)", () => { + // Regression: coupling loopback identity to port equality 403'd the entire /v1/* + // data plane whenever the client reached the proxy through a forwarded port. + expect(isLoopbackRequestHost("localhost:20100")).toBe(true); + expect(isLoopbackRequestHost("127.0.0.1:20100")).toBe(true); + expect(isLoopbackRequestHost("[::1]:20100")).toBe(true); + }); + + test("the proxy's own port, a bare host, and a missing Host stay allowed", () => { + expect(isLoopbackRequestHost("localhost:10100")).toBe(true); + expect(isLoopbackRequestHost("localhost")).toBe(true); + expect(isLoopbackRequestHost("127.0.0.1")).toBe(true); + expect(isLoopbackRequestHost(null)).toBe(true); + }); + + test("a non-loopback hostname is refused on every port", () => { + // The hostname check is the real DNS-rebinding boundary; it must not depend on + // which port the attacker names. + expect(isLoopbackRequestHost("attacker.test:10100")).toBe(false); + expect(isLoopbackRequestHost("attacker.test:20100")).toBe(false); + expect(isLoopbackRequestHost("192.168.1.5:10100")).toBe(false); + expect(isLoopbackRequestHost("example.com")).toBe(false); + }); + + test("names that merely look loopback are refused", () => { + // These are the DNS-rebinding shapes that matter: a hostname the attacker controls + // which either embeds "localhost"/"127.0.0.1" as a label or resolves to loopback. + expect(isLoopbackRequestHost("localhost.attacker.com")).toBe(false); + expect(isLoopbackRequestHost("127.0.0.1.attacker.com")).toBe(false); + expect(isLoopbackRequestHost("127.0.0.1.nip.io")).toBe(false); + expect(isLoopbackRequestHost("localtest.me")).toBe(false); + // Cyrillic "о" in "lоcalhost" — the URL parser punycodes it, so it must not match. + expect(isLoopbackRequestHost("l\u043Ecalhost:20100")).toBe(false); + // Not loopback despite the shape. + expect(isLoopbackRequestHost("0.0.0.0:20100")).toBe(false); + expect(isLoopbackRequestHost("127.0.0.2:20100")).toBe(false); + }); + + test("alternative spellings of real loopback are accepted (URL normalization)", () => { + expect(isLoopbackRequestHost("127.1:20100")).toBe(true); + expect(isLoopbackRequestHost("2130706433:20100")).toBe(true); + expect(isLoopbackRequestHost("LOCALHOST:20100")).toBe(true); + // `curl http://localhost.:20100/` sends the FQDN form; it is the same host. + expect(isLoopbackRequestHost("localhost.:20100")).toBe(true); + expect(isLoopbackRequestHost("localhost.")).toBe(true); + }); + + test("characterization: an unparseable Host still fails open", () => { + // Pre-existing behavior of `if (!parsed) return true`, unchanged by the port-check + // removal and not browser-reachable (a browser composes Host from its own connection). + // Pinned here so tightening it is a deliberate change with a failing test, not a + // silent drift. See the scope note in isLoopbackRequestHost. + expect(isLoopbackRequestHost("attacker.test:99999")).toBe(true); + expect(isLoopbackRequestHost("attacker test:80")).toBe(true); + }); +}); + +describe("isAllowedRequestOrigin over a forwarded port", () => { + test("a CLI with no Origin reaches the data plane through the forward", () => { + // Codex CLI, Claude Code and curl send no Origin at all, so this path — not CORS — + // is what made a forwarded proxy look completely dead. + expect(isAllowedRequestOrigin(request("localhost:20100"), loopbackConfig)).toBe(true); + }); + + test("a browser Origin on the forwarded port is allowed", () => { + expect( + isAllowedRequestOrigin(request("localhost:20100", "http://localhost:20100"), loopbackConfig), + ).toBe(true); + }); + + test("a non-loopback Host is still refused with and without an Origin", () => { + expect(isAllowedRequestOrigin(request("attacker.test:20100"), loopbackConfig)).toBe(false); + expect( + isAllowedRequestOrigin(request("attacker.test:20100", "http://attacker.test:20100"), loopbackConfig), + ).toBe(false); + }); + + test("a loopback Host with a non-loopback Origin is still refused", () => { + expect( + isAllowedRequestOrigin(request("localhost:20100", "http://attacker.test"), loopbackConfig), + ).toBe(false); + }); +}); From c573bd9de377c8a8e12d5d717609a81b1fc5f710 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:26:26 +0200 Subject: [PATCH 014/129] fix(storage): harden #558 satellite backup writes and zst restore (#571) * fix(storage): harden #558 satellite backup writes and zst restore Make every satellite-backup.json update crash-safe (tmp + fsync + rename + best-effort dir fsync) so a failed post-memories rewrite cannot truncate the last valid snapshot. Restore legacy compressed-only quarantine entries by decompressing a lone .jsonl.zst in memory and reconstructing the thread row. * fix(storage): harden satellite-backup write retries for #558 follow-up Loop short writes to completion and rename via renameAtomicFile so Windows sharing violations cannot replace a durable temp with a truncated backup. --- src/codex/history-provider.ts | 34 ++- src/server/management/logs-usage-routes.ts | 13 +- src/storage/cleanup.ts | 115 ++++++++-- tests/storage-cleanup.test.ts | 223 +++++++++++++++++++ tests/storage-restore-job-responsive.test.ts | 2 + 5 files changed, 363 insertions(+), 24 deletions(-) diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index b333e0dbb..4297b1b94 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -1,10 +1,18 @@ import { createHash } from "node:crypto"; import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { zstdDecompressSync } from "node:zlib"; import { Database } from "bun:sqlite"; import { CODEX_HOME } from "./paths"; import { atomicWriteFile, getConfigDir } from "../config"; +/** + * Cap for decompressing a lone `.jsonl.zst` rollout during quarantine restore. + * Bounds peak memory while reconstructing thread rows; never write the decoded + * JSONL to disk. + */ +export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024; + const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite"); function historyBackupPathFor(stateDbPath: string): string { const normalized = process.platform === "win32" ? resolve(stateDbPath).toLowerCase() : resolve(stateDbPath); @@ -332,15 +340,39 @@ function extractUserMessagePreview(line: string): string | null { /** * Reconstruct thread identity + listing fields from a staged/restored rollout JSONL. * Returns null when the file is missing or has no parseable `session_meta`. + * + * Accepts plain `.jsonl` or a lone `.jsonl.zst` (legacy Phase-2 quarantine). Compressed + * rollouts are decompressed in memory with {@link MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES}; + * no decompressed copy is written to disk. */ export function readThreadFieldsFromRollout(path: string): RolloutThreadFields | null { if (!path || !existsSync(path)) return null; let raw: string; try { - raw = readFileSync(path, "utf8"); + raw = path.endsWith(".zst") + ? decompressRolloutZstUtf8(path) + : readFileSync(path, "utf8"); } catch { return null; } + return parseThreadFieldsFromRolloutText(raw); +} + +function decompressRolloutZstUtf8( + path: string, + maxBytes: number = MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES, +): string { + const compressed = readFileSync(path); + const decoded = zstdDecompressSync(compressed as Uint8Array, { + maxOutputLength: maxBytes, + }); + if (decoded.byteLength > maxBytes) { + throw new Error("rollout_zst_too_large"); + } + return new TextDecoder().decode(decoded); +} + +function parseThreadFieldsFromRolloutText(raw: string): RolloutThreadFields | null { const lines = raw.split("\n"); let latest: ParsedSessionMeta | null = null; let firstUserMessage = ""; diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index e5270fc7d..718005fb8 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -362,13 +362,12 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise void; } @@ -746,6 +753,7 @@ interface ReconcileTestHooks { const SATELLITE_BACKUP_FILE = "satellite-backup.json"; /** Marks an incomplete restore so retries can accept dest files and resume metadata. */ const RESTORE_PENDING_FILE = "restore-pending.json"; +let _satelliteBackupSeq = 0; type StagedFile = { from: string; to: string; relPath: string }; @@ -971,8 +979,66 @@ function restoreConsolidateGlobalJob( } } -function writeSatelliteBackup(stageDir: string, backup: SatelliteBackup): void { - writePrivateFile(join(stageDir, SATELLITE_BACKUP_FILE), JSON.stringify(backup)); +/** + * Best-effort directory fsync so a preceding rename is durable on crash. + * Unsupported on some Windows setups — never treat failure as fatal. + */ +function fsyncDirectoryBestEffort(dirPath: string): void { + let fd: number | undefined; + try { + fd = openSync(dirPath, "r"); + fsyncSync(fd); + } catch { + /* best-effort */ + } finally { + if (fd !== undefined) { + try { closeSync(fd); } catch { /* */ } + } + } +} + +/** + * Atomically replace satellite-backup.json: private temp in the stage, full write + fsync, + * rename (with Windows sharing-violation retries), then best-effort directory fsync. + * An interrupted update never truncates the last valid backup that was written before a + * satellite DB commit. + */ +function writeSatelliteBackup( + stageDir: string, + backup: SatelliteBackup, + options?: { failWrite?: boolean; failReplaceBeforeRename?: boolean }, +): void { + if (options?.failWrite) throw new Error("test_fail_satellite_backup_write"); + const dest = join(stageDir, SATELLITE_BACKUP_FILE); + const replacing = existsSync(dest); + const tmp = join(stageDir, `${SATELLITE_BACKUP_FILE}.${process.pid}.${++_satelliteBackupSeq}.tmp`); + const payload = Buffer.from(JSON.stringify(backup), "utf8"); + const fd = openSync(tmp, "w", 0o600); + try { + let offset = 0; + while (offset < payload.length) { + offset += writeSync(fd, payload, offset, payload.length - offset, null); + } + fsyncSync(fd); + } catch (error) { + try { closeSync(fd); } catch { /* */ } + try { unlinkSync(tmp); } catch { /* */ } + throw error; + } + closeSync(fd); + chmodPrivatePath(tmp, 0o600); + if (options?.failReplaceBeforeRename && replacing) { + try { unlinkSync(tmp); } catch { /* */ } + throw new Error("test_fail_satellite_backup_replace"); + } + try { + renameAtomicFile(tmp, dest); + } catch (error) { + try { unlinkSync(tmp); } catch { /* */ } + throw error; + } + chmodPrivatePath(dest, 0o600); + fsyncDirectoryBestEffort(stageDir); } function clearSatelliteBackup(stageDir: string): void { @@ -1240,15 +1306,17 @@ function deleteAndCommitSatellites( deleteMemoriesInTx(locks.memories.db, backup.memories); if (backup.memories.consolidateTouched) { // Capture under the write lock, but persist only after COMMIT+close. - // Holding BEGIN IMMEDIATE across writeFileSync lets Windows CI disk/AV - // latency stall the lock long enough for concurrent reopen hooks (and - // bun's default 5s test timeout) to hang — see PR #558 windows-latest. + // Holding BEGIN IMMEDIATE across a durable backup rewrite lets Windows CI + // disk/AV latency stall the lock long enough for concurrent reopen hooks + // (and bun's default 5s test timeout) to hang — see PR #558 windows-latest. backup.memories.consolidatePostImage = readConsolidateGlobalJob(locks.memories.db); } commitSatelliteLock(locks.memories); locks.memories = undefined; if (backup.memories.consolidateTouched) { - writeSatelliteBackup(stageDir, backup); + writeSatelliteBackup(stageDir, backup, { + failReplaceBeforeRename: hooks?.failSatelliteBackupReplace, + }); } if (hooks?.failAfterMemoriesMutation) throw new Error("test_fail_after_memories"); } @@ -1420,10 +1488,9 @@ function reconcileDeletedThreads( backup.dynamicTools = stateDeps.dynamicTools; backup.spawnEdges = stateDeps.spawnEdges; try { - if (hooks?.failSatelliteBackupWrite) { - throw new Error("test_fail_satellite_backup_write"); - } - writeSatelliteBackup(stageDir, backup); + writeSatelliteBackup(stageDir, backup, { + failWrite: hooks?.failSatelliteBackupWrite, + }); } catch { rollbackAllSatelliteLocks(satelliteLocks); stateDb.exec("ROLLBACK"); @@ -1606,6 +1673,7 @@ export interface ExecuteCleanupOptions { failBeforeStateCommit?: boolean; failSatelliteRestore?: boolean; failSatelliteBackupWrite?: boolean; + failSatelliteBackupReplace?: boolean; afterSatelliteMutations?: () => void; }; } @@ -1635,6 +1703,7 @@ export function pickWireCleanupTestHooks(raw: unknown): CleanupWireTestHooks | u if (typeof o.failBeforeStateCommit === "boolean") out.failBeforeStateCommit = o.failBeforeStateCommit; if (typeof o.failSatelliteRestore === "boolean") out.failSatelliteRestore = o.failSatelliteRestore; if (typeof o.failSatelliteBackupWrite === "boolean") out.failSatelliteBackupWrite = o.failSatelliteBackupWrite; + if (typeof o.failSatelliteBackupReplace === "boolean") out.failSatelliteBackupReplace = o.failSatelliteBackupReplace; return Object.keys(out).length > 0 ? out : undefined; } @@ -2350,16 +2419,30 @@ function restoreThreadsFromManifest( ); const reconstructed: SqlRow[] = []; for (const entry of toReconstruct) { - let abs: string; + let abs: string | undefined; try { abs = absFromRel(codexHome, entry.rolloutPath!); } catch { - // Prefer the first restored physical path under archived_sessions. - const rel = entry.physicalRelPaths[0]; - if (!rel) throw new Error("missing_rollout_for_thread"); - abs = absFromRel(codexHome, rel); + abs = undefined; + } + // Legacy compressed-only quarantine: manifest rolloutPath is often the logical + // `.jsonl` name while the only restored physical file is `.jsonl.zst`. + if (!abs || !existsSync(abs)) { + for (const rel of entry.physicalRelPaths) { + try { + const candidate = absFromRel(codexHome, rel); + if (existsSync(candidate)) { + abs = candidate; + break; + } + } catch { + /* try next physical path */ + } + } } - // .jsonl.zst cannot be parsed here — require a plain .jsonl sibling or path. + if (!abs) throw new Error("missing_rollout_for_thread"); + // Prefer a plain .jsonl sibling when present; otherwise readThreadFieldsFromRollout + // decompresses a lone .jsonl.zst in memory (bounded) for legacy quarantine restores. if (abs.endsWith(ZST_SUFFIX)) { const plain = abs.slice(0, -".zst".length); if (existsSync(plain)) abs = plain; diff --git a/tests/storage-cleanup.test.ts b/tests/storage-cleanup.test.ts index a5b030480..dd1f8bb3f 100644 --- a/tests/storage-cleanup.test.ts +++ b/tests/storage-cleanup.test.ts @@ -789,6 +789,139 @@ describe("executeArchivedCleanup", () => { stateAfter.close(); }); + test("failed post-memories satellite-backup replace keeps prior backup parseable and restorable", () => { + home = buildHome({ withSatelliteStores: true }); + const logsBefore = new Database(join(home, "logs_2.sqlite"), { readonly: true }); + const logs = logsBefore.query( + "SELECT id, ts, level, target, thread_id, estimated_bytes FROM logs ORDER BY id", + ).all(); + logsBefore.close(); + const goalsBefore = new Database(join(home, "goals_1.sqlite"), { readonly: true }); + const goals = goalsBefore.query( + "SELECT thread_id, goal_id, objective, status FROM thread_goals ORDER BY thread_id", + ).all(); + const deferrals = goalsBefore.query( + "SELECT thread_id FROM thread_goal_continuation_deferrals ORDER BY thread_id", + ).all(); + goalsBefore.close(); + const memBefore = new Database(join(home, "memories_1.sqlite"), { readonly: true }); + const stage1 = memBefore.query( + "SELECT thread_id, raw_memory, rollout_summary, selected_for_phase2 FROM stage1_outputs ORDER BY thread_id", + ).all(); + const jobs = memBefore.query( + "SELECT kind, job_key, status FROM jobs ORDER BY kind, job_key", + ).all(); + memBefore.close(); + + // tmid has selected_for_phase2=1 → consolidateTouched forces a post-commit backup rewrite. + // failSatelliteRestore keeps the prior on-disk snapshot so we can prove it was never truncated. + const result = runWithDigest(100, "permanent", home, { + now: 96, + _test: { failSatelliteBackupReplace: true, failSatelliteRestore: true }, + }); + expect(result.ok).toBe(false); + expect(result.error).toBe("db_reconcile_failed"); + expect(result.trashDir).toBe(".trash/96"); + + const backupPath = join(home, ".trash", "96", "satellite-backup.json"); + expect(existsSync(backupPath)).toBe(true); + const backup = JSON.parse(readFileSync(backupPath, "utf8")) as { + threadIds: string[]; + logs?: { rows: Array> }; + memories?: { + stage1: Array>; + stage1Jobs: Array>; + consolidateTouched?: boolean; + consolidateJob?: Record | null; + consolidatePostImage?: unknown; + }; + goals?: { + goals: Array>; + deferrals: Array>; + }; + }; + expect(backup.threadIds).toEqual(expect.arrayContaining(["told", "tmid", "tnew"])); + expect(backup.logs?.rows.length).toBeGreaterThan(0); + expect(backup.memories?.stage1.length).toBeGreaterThan(0); + expect(backup.memories?.consolidateTouched).toBe(true); + // Replacement never landed — prior snapshot must not carry the post-commit image. + expect(backup.memories?.consolidatePostImage).toBeUndefined(); + expect(backup.goals?.goals.length).toBeGreaterThan(0); + + // Injected restore failure left satellites deleted; re-apply the kept prior backup. + const logsDb = new Database(join(home, "logs_2.sqlite")); + logsDb.exec("BEGIN IMMEDIATE"); + for (const row of backup.logs?.rows ?? []) { + const cols = Object.keys(row); + logsDb.run( + `INSERT INTO logs (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`, + cols.map(c => row[c] as string | number | null), + ); + } + logsDb.exec("COMMIT"); + logsDb.close(); + + const memDb = new Database(join(home, "memories_1.sqlite")); + memDb.exec("BEGIN IMMEDIATE"); + for (const row of backup.memories?.stage1 ?? []) { + const cols = Object.keys(row); + memDb.run( + `INSERT INTO stage1_outputs (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`, + cols.map(c => row[c] as string | number | null), + ); + } + for (const row of backup.memories?.stage1Jobs ?? []) { + const cols = Object.keys(row); + memDb.run( + `INSERT INTO jobs (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`, + cols.map(c => row[c] as string | number | null), + ); + } + memDb.exec("COMMIT"); + memDb.close(); + + const goalsDb = new Database(join(home, "goals_1.sqlite")); + goalsDb.exec("BEGIN IMMEDIATE"); + for (const row of backup.goals?.goals ?? []) { + const cols = Object.keys(row); + goalsDb.run( + `INSERT INTO thread_goals (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`, + cols.map(c => row[c] as string | number | null), + ); + } + for (const row of backup.goals?.deferrals ?? []) { + const cols = Object.keys(row); + goalsDb.run( + `INSERT INTO thread_goal_continuation_deferrals (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`, + cols.map(c => row[c] as string | number | null), + ); + } + goalsDb.exec("COMMIT"); + goalsDb.close(); + + const logsAfter = new Database(join(home, "logs_2.sqlite"), { readonly: true }); + expect(logsAfter.query("SELECT id, ts, level, target, thread_id, estimated_bytes FROM logs ORDER BY id").all()).toEqual(logs); + logsAfter.close(); + const goalsAfter = new Database(join(home, "goals_1.sqlite"), { readonly: true }); + expect(goalsAfter.query("SELECT thread_id, goal_id, objective, status FROM thread_goals ORDER BY thread_id").all()).toEqual(goals); + expect(goalsAfter.query("SELECT thread_id FROM thread_goal_continuation_deferrals ORDER BY thread_id").all()).toEqual(deferrals); + goalsAfter.close(); + const memAfter = new Database(join(home, "memories_1.sqlite"), { readonly: true }); + expect(memAfter.query("SELECT thread_id, raw_memory, rollout_summary, selected_for_phase2 FROM stage1_outputs ORDER BY thread_id").all()).toEqual(stage1); + // stage1 jobs restored; consolidate-global may have been mutated before the failed rewrite — + // only assert stage1 keys are present (every deleted satellite row from the prior backup). + const jobKeys = memAfter.query<{ kind: string; job_key: string }, []>( + "SELECT kind, job_key FROM jobs ORDER BY kind, job_key", + ).all(); + memAfter.close(); + for (const expected of jobs.filter(j => (j as { kind: string }).kind === "memory_stage1")) { + expect(jobKeys).toContainEqual({ + kind: (expected as { kind: string }).kind, + job_key: (expected as { job_key: string }).job_key, + }); + } + }, { timeout: 30_000 }); + test("permanent cleanup works with logs-only satellite store", () => { home = buildHome({ satellites: "logs" }); const result = runWithDigest(100, "permanent", home); @@ -1218,6 +1351,96 @@ describe("listTrashEntries + restoreTrashEntry", () => { }); }); + test("legacy compressed-only quarantine restores .jsonl.zst and reconstructs the thread row", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-cleanup-legacy-zst-")); + home = dir; + mkdirSync(join(dir, "archived_sessions"), { recursive: true }); + + const rolloutBody = [ + JSON.stringify({ + type: "session_meta", + timestamp: "2026-01-01T00:00:00.000Z", + payload: { + id: "told", + model_provider: "openai", + source: "cli", + cwd: "/tmp/project", + }, + }), + JSON.stringify({ + type: "event_msg", + timestamp: "2026-01-01T00:00:01.000Z", + payload: { type: "user_message", message: "compressed restore please" }, + }), + ].join("\n") + "\n"; + const compressed = Bun.zstdCompressSync(Buffer.from(rolloutBody, "utf8")); + + const stage = join(dir, ".trash", "1700000000910"); + mkdirSync(stage, { recursive: true }); + // Sparse legacy manifest: no threads snapshot, only the compressed physical file. + writeFileSync(join(stage, "rollout-old.jsonl.zst"), compressed); + writeFileSync(join(stage, "manifest.json"), JSON.stringify({ + quarantinedAt: 1_700_000_000_910, + mode: "quarantine", + entries: [ + { + relPath: "archived_sessions/rollout-old.jsonl", + bytes: compressed.byteLength, + mtimeMs: OLD.getTime(), + physicalRelPaths: ["archived_sessions/rollout-old.jsonl.zst"], + threadId: "told", + rolloutPath: "archived_sessions/rollout-old.jsonl", + archived: 1, + }, + ], + })); + // Intentionally no satellite-backup.json and no plain .jsonl sibling. + + const db = new Database(join(dir, "state_5.sqlite")); + db.exec(`CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + model_provider TEXT NOT NULL, + source TEXT NOT NULL, + first_user_message TEXT NOT NULL, + has_user_event INTEGER NOT NULL DEFAULT 0, + archived INTEGER, + archived_at INTEGER + )`); + db.close(); + + const restored = restoreTrashEntry(".trash/1700000000910", { codexHome: home }); + expect(restored.ok).toBe(true); + expect(existsSync(join(dir, "archived_sessions", "rollout-old.jsonl.zst"))).toBe(true); + // Must not materialize an unbounded plain decompressed sibling on disk. + expect(existsSync(join(dir, "archived_sessions", "rollout-old.jsonl"))).toBe(false); + expect(existsSync(stage)).toBe(false); + + const state = new Database(join(dir, "state_5.sqlite"), { readonly: true }); + const row = state.query<{ + id: string; + rollout_path: string; + model_provider: string; + source: string; + first_user_message: string; + has_user_event: number; + archived: number | null; + }, []>( + `SELECT id, rollout_path, model_provider, source, first_user_message, has_user_event, archived + FROM threads WHERE id='told'`, + ).get(); + state.close(); + expect(row).toEqual({ + id: "told", + rollout_path: "archived_sessions/rollout-old.jsonl", + model_provider: "openai", + source: "cli", + first_user_message: "compressed restore please", + has_user_event: 1, + archived: 1, + }); + }); + test.each([ ["failAfterStateCommit", { failAfterStateCommit: true }, "db_reconcile_failed"], ["failAfterFirstSatelliteCommit", { failAfterFirstSatelliteCommit: true }, "db_reconcile_failed"], diff --git a/tests/storage-restore-job-responsive.test.ts b/tests/storage-restore-job-responsive.test.ts index fb59e7d2f..513c2c6a1 100644 --- a/tests/storage-restore-job-responsive.test.ts +++ b/tests/storage-restore-job-responsive.test.ts @@ -78,6 +78,8 @@ describe("storage trash restore job responsiveness", () => { try { const res = await fetch(new URL("/api/storage/trash/restore/test-stream", server.url)); expect(res.status).toBe(404); + expect(res.headers.get("content-type") ?? "").toContain("application/json"); + expect(await res.json()).toEqual({ error: "not_available" }); } finally { await server.stop(true); } From 05397051eb9c0e766bc3af4034bca28e01124e41 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:55:11 +0200 Subject: [PATCH 015/129] test(storage): cover restore test-stream route gate cases (#574) * test(storage): cover restore test-stream route gate cases Address CodeRabbit on #571: assert JSON 404 when the stream hook is null under OPENCODEX_CLEANUP_TEST_HOOKS=1, and that the enabled stream path returns the text/plain chunked response. * test(storage): isolate restore test-stream route env and hooks Reset OPENCODEX_CLEANUP_TEST_HOOKS and restore-job hooks in a nested finally so enableTestStream cannot leak across cases in the responsive suite. --- tests/storage-restore-job-responsive.test.ts | 70 +++++++++++++++++--- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/tests/storage-restore-job-responsive.test.ts b/tests/storage-restore-job-responsive.test.ts index 513c2c6a1..206ccd94f 100644 --- a/tests/storage-restore-job-responsive.test.ts +++ b/tests/storage-restore-job-responsive.test.ts @@ -71,20 +71,74 @@ afterEach(() => { }); describe("storage trash restore job responsiveness", () => { - test("test-stream route is absent without OPENCODEX_CLEANUP_TEST_HOOKS", async () => { - delete process.env.OPENCODEX_CLEANUP_TEST_HOOKS; - setRestoreTrashJobTestHooks({ enableTestStream: true }); + async function withTestStreamRoute( + setup: () => void, + assert: (serverUrl: string) => Promise, + ): Promise { + const previousHooksEnv = process.env.OPENCODEX_CLEANUP_TEST_HOOKS; + setup(); const server = startServer(0); try { - const res = await fetch(new URL("/api/storage/trash/restore/test-stream", server.url)); - expect(res.status).toBe(404); - expect(res.headers.get("content-type") ?? "").toContain("application/json"); - expect(await res.json()).toEqual({ error: "not_available" }); + await assert(server.url.toString()); } finally { - await server.stop(true); + try { + await server.stop(true); + } finally { + setRestoreTrashJobTestHooks(null); + if (previousHooksEnv === undefined) delete process.env.OPENCODEX_CLEANUP_TEST_HOOKS; + else process.env.OPENCODEX_CLEANUP_TEST_HOOKS = previousHooksEnv; + } } + } + + test("test-stream route is absent without OPENCODEX_CLEANUP_TEST_HOOKS", async () => { + await withTestStreamRoute( + () => { + delete process.env.OPENCODEX_CLEANUP_TEST_HOOKS; + setRestoreTrashJobTestHooks({ enableTestStream: true }); + }, + async (serverUrl) => { + const res = await fetch(new URL("/api/storage/trash/restore/test-stream", serverUrl)); + expect(res.status).toBe(404); + expect(res.headers.get("content-type") ?? "").toContain("application/json"); + expect(await res.json()).toEqual({ error: "not_available" }); + }, + ); }); + test("test-stream route returns JSON 404 when hooks are enabled but stream is null", async () => { + await withTestStreamRoute( + () => { + process.env.OPENCODEX_CLEANUP_TEST_HOOKS = "1"; + // enableTestStream omitted → getRestoreTrashTestStreamResponse() returns null + setRestoreTrashJobTestHooks({ blockMs: 0 }); + }, + async (serverUrl) => { + const res = await fetch(new URL("/api/storage/trash/restore/test-stream", serverUrl)); + expect(res.status).toBe(404); + expect(res.headers.get("content-type") ?? "").toContain("application/json"); + expect(await res.json()).toEqual({ error: "not_available" }); + }, + ); + }); + + test("test-stream route serves the enabled hook stream", async () => { + await withTestStreamRoute( + () => { + process.env.OPENCODEX_CLEANUP_TEST_HOOKS = "1"; + setRestoreTrashJobTestHooks({ enableTestStream: true }); + }, + async (serverUrl) => { + const res = await fetch(new URL("/api/storage/trash/restore/test-stream", serverUrl)); + expect(res.status).toBe(200); + expect(res.headers.get("content-type") ?? "").toContain("text/plain"); + const body = await res.text(); + expect(body).toContain("chunk-0"); + expect(body).toContain("chunk-7"); + }, + ); + }, { timeout: 10_000 }); + test("blocked worker keeps /healthz and streaming response responsive", async () => { const blockMs = 1200; setRestoreTrashJobTestHooks({ blockMs, enableTestStream: true }); From 458c363a9a40b8bb4ffdc71a86a1c34fd25e1f47 Mon Sep 17 00:00:00 2001 From: wonsh42 Date: Tue, 28 Jul 2026 06:59:49 +0900 Subject: [PATCH 016/129] feat(quota): per-account Claude rate limits, canonical 5-hour window (#493) Anthropic OAuth usage is per credential: probe each logged-in account for 5h/weekly bars, use canonical fiveHour fields, and refresh overview quotas after active-account switches. Maintainer takeover on latest dev with CodeRabbit/Codex/Wibias review harden (unavailable+TTL, local-cli fail-closed, health rebuild, probe sharing, GUI/docs, cache/inflight/login invalidation, async account-list load). Credit: @wonsh42 (original feature). Co-authored-by: wonsh42 --- .../src/content/docs/guides/web-dashboard.md | 2 +- .../content/docs/ja/guides/web-dashboard.md | 2 +- .../content/docs/ko/guides/web-dashboard.md | 2 +- .../content/docs/ru/guides/web-dashboard.md | 2 +- .../docs/zh-cn/guides/web-dashboard.md | 2 +- .../provider-workspace/ProviderAuthPanel.tsx | 15 +- .../ProviderWorkspaceShell.tsx | 11 +- .../components/provider-workspace/types.ts | 4 + gui/src/hooks/useProviderAccountPools.ts | 27 ++ gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Providers.tsx | 10 +- .../styles/provider-workspace-settings.css | 7 + src/oauth/index.ts | 9 + src/providers/quota.ts | 239 +++++++++-- src/server/management/oauth-account-routes.ts | 58 ++- tests/provider-account-quota.test.ts | 376 ++++++++++++++++++ tests/provider-quota.test.ts | 5 +- 22 files changed, 725 insertions(+), 52 deletions(-) create mode 100644 tests/provider-account-quota.test.ts diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 739e6acef..fff8735b2 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | -| **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. | +| **Providers** | Add, edit, enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. | | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 2a253dc9c..c0ae3a6c1 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **起動安全性** | 注入された Codex ルーティングが再起動後も機能するか、サービスと launcher shim の状態、正確な修復コマンドと共に表示します。 | | **Windows トレイ** | ユーザーのログイントレイを導入し、プロキシ開始・停止・再起動・ダッシュボード・状態をクリックで操作します。トレイは再起動サービスではありません。 | | **Codex 自動起動** | インストール済み Codex launcher shim に `ocx ensure` の実行を許可します。このトグルは shim やバックグラウンドサービスをインストールしません。 | -| **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。 | +| **プロバイダー** | プロバイダーを追加、編集、有効化/無効化、削除し、対応する OAuth アカウントプールと API キープールを管理します。Claude(Anthropic)OAuth プールでは、ログイン済みの各アカウントに独自の 5 時間・週間レート制限バーが表示され(利用量は資格情報単位)、取得失敗時は直近の値を保持して一時利用不可と表示します。 | | **プロバイダー追加** | レジストリベースのプリセットからアカウントログイン、API キーサービス、ローカルサーバー、custom エンドポイントを検索します。 | | **Codex 認証** | ChatGPT/Codex プールアカウントを追加し、次回セッションアカウントを選び、5 時間 / 週間 / 30 日クォータを更新し、クォータ自動切り替えのオン/オフと 1~100% のしきい値、一時的失敗フェイルオーバーを設定します。 | | **サブエージェント** | `spawn_agent` オーバーライド一覧にネイティブまたはルーティングモデルを最大 5 つまで優先公開します。 | diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 8ab11d4e6..473759ac0 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **시작 안전성** | 주입된 Codex 라우팅이 재부팅 후에도 유지되는지 서비스와 launcher shim 상태, 정확한 복구 명령과 함께 표시합니다. | | **Windows 트레이** | 로그인할 때 사용자 전용 트레이를 시작하고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다. 트레이는 재시작 서비스가 아닙니다. | | **Codex 자동 시작** | 이미 설치된 Codex launcher shim이 `ocx ensure`를 실행하도록 허용합니다. 이 토글은 shim이나 백그라운드 서비스를 설치하지 않습니다. | -| **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. | +| **Providers** | 프로바이더를 추가, 편집, 활성화/비활성화, 제거하고, 지원되는 OAuth 계정 풀과 API key 풀을 관리합니다. Claude(Anthropic) OAuth 풀에서는 로그인한 계정마다 자체 5시간·주간 한도 막대가 표시되며(사용량은 자격 증명 단위), 조회 실패 시 마지막 값을 유지하고 일시 불가 상태로 표시합니다. | | **Add provider** | 레지스트리 기반 프리셋에서 계정 로그인, API key 서비스, 로컬 서버, custom endpoint를 검색합니다. | | **Codex Auth** | ChatGPT/Codex 풀 계정을 추가하고, 다음 세션 계정을 선택하고, 5시간 / 주간 / 30일 할당량을 갱신하며, 할당량 자동 전환을 켜거나 끄고 1~100% 임계값과 일시적 실패 failover를 설정합니다. | | **Subagents** | `spawn_agent` override 목록에 네이티브 또는 라우팅 모델을 최대 5개까지 우선 노출합니다. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 70d8e9e22..45b433513 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -32,7 +32,7 @@ bun run dev:gui | **Безопасность запуска** | Показывает, сохранит ли внедрённая маршрутизация Codex работоспособность после перезагрузки, отдельно отображая службу, launcher shim и точные команды исправления. | | **Трей Windows** | Устанавливает пользовательский значок входа для запуска, остановки, перезапуска, панели и состояния прокси одним щелчком. Трей не является службой перезапуска. | | **Автозапуск Codex** | Разрешает уже установленному launcher shim Codex выполнять `ocx ensure`. Переключатель не устанавливает shim или фоновую службу. | -| **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. | +| **Providers** | Добавление, редактирование, включение/отключение и удаление провайдеров; управление пулами OAuth-аккаунтов и пулами API-ключей там, где они поддерживаются. Для пулов Claude (Anthropic) OAuth у каждого вошедшего аккаунта свои полосы 5-часового и недельного лимита (использование по учётным данным); при сбое опроса сохраняются последние известные значения с пометкой недоступности. | | **Add provider** | Поиск по пресетам из реестра: вход по аккаунту, сервисы с API-ключом, локальные серверы или пользовательская конечная точка. | | **Codex Auth** | Добавление аккаунтов пула ChatGPT/Codex, выбор аккаунта для следующей сессии, обновление квот 5 ч / недельных / 30-дневных, включение или отключение автопереключения, настройка его порога 1–100% и failover при временных сбоях. | | **Subagents** | Выделение до пяти «голых» нативных или маршрутизируемых моделей с пространством имён в списке переопределений `spawn_agent`. | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index d799eea53..ece5b04a0 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -31,7 +31,7 @@ bun run dev:gui | **启动安全** | 显示注入的 Codex 路由能否在重启后继续工作,并分别显示服务、launcher shim 状态和准确的修复命令。 | | **Windows 托盘** | 安装用户登录托盘,一键控制代理启动、停止、重启、面板和状态。托盘不是代理重启服务。 | | **Codex 自动启动** | 允许已安装的 Codex launcher shim 运行 `ocx ensure`。此开关不会安装 shim 或后台服务。 | -| **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。 | +| **Providers** | 添加、编辑、启用/禁用、删除 provider,并在支持时管理 OAuth 账号池和 API key 池。Claude(Anthropic)OAuth 池中,每个已登录账号显示各自的 5 小时与周限额条(用量按凭证计);探测失败时保留上次已知数值并标记为暂时不可用。 | | **Add provider** | 搜索 registry preset,选择账号登录、API key 服务、本地服务器或自定义 endpoint。 | | **Codex Auth** | 添加 ChatGPT/Codex 池账号,选择下一 session 的账号,刷新 5h / 每周 / 30d 配额,启用或停用配额自动切换,设置其 1–100% 阈值和临时故障 failover。 | | **Subagents** | 在 `spawn_agent` override 列表中置顶最多五个原生或路由模型。 | diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index efa61b33e..45049d16e 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -20,6 +20,7 @@ import { } from "../../oauth-health-display"; import CodexAccountPool from "../CodexAccountPool"; import { LoginUrlBlock } from "../login-url-block"; +import QuotaBars from "../QuotaBars"; import { useCopyFeedback } from "../use-copy-feedback"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; import type { AccountLoadState, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers } from "./types"; @@ -181,7 +182,8 @@ export default function ProviderAuthPanel({ const healthSummary = formatOAuthHealthSummary(t, item.name, account.id, account.health); const copyDoctor = () => { doctorCopy.copy(DOCTOR_CMD, account.id); }; return ( -
  • +
  • +
    +
    + {(account.quota || account.quotaUnavailable) && ( +
    + {account.quota && ( + + )} + {account.quotaUnavailable && ( +

    {t("pws.accountQuotaUnavailable")}

    + )} +
    + )}
  • ); })} diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 952ec4c4b..869a867fe 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -65,6 +65,8 @@ export default function ProviderWorkspaceShell({ jsonSaving = false, modelsRefreshToken = 0, activeAccountNeedsReauth, + /** Stable key of active OAuth account ids — refetch overview quotas after account switch. */ + quotaRefreshKey = "", detail, }: { providers: Record; @@ -81,6 +83,11 @@ export default function ProviderWorkspaceShell({ /** Bump after login/config changes so /api/selected-models is refetched. */ modelsRefreshToken?: number; activeAccountNeedsReauth?: Record; + /** + * Explicit active-account identity key (e.g. `anthropic:|…`). Prefer this over + * `activeAccountNeedsReauth` object identity so healthy account switches still refresh. + */ + quotaRefreshKey?: string; /** Detail body for the selected provider (WP090); a placeholder renders when absent. */ detail?: (item: WorkspaceItem, data: DetailSlotData) => ReactNode; }) { @@ -200,7 +207,9 @@ export default function ProviderWorkspaceShell({ }) .catch(() => { /* keep last-good */ }); return () => { cancelled = true; }; - }, [apiBase]); + // Key on active-account identity (not the reauth boolean map) so switching between two + // healthy accounts still re-reads /api/provider-quotas for the Usage/overview bars. + }, [apiBase, quotaRefreshKey]); useEffect(() => { if (!filterOpen) return; diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 16a594906..9bac652a6 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -3,6 +3,7 @@ * workspace shell/rail/detail (WP080a). Data shapes only; no React. */ import type { ProviderSortMode, WorkspaceItem } from "../../provider-workspace/catalog"; +import type { AccountQuota } from "../../codex-quota-utils"; export type { ProviderSortMode, WorkspaceItem }; @@ -49,6 +50,9 @@ export type OAuthAccountRow = { healthLabel?: string; healthSummary?: string; healthAction?: string; + /** Per-account rate limits, for providers that report usage per credential (anthropic). */ + quota?: AccountQuota | null; + quotaUnavailable?: boolean; }; export type ApiKeyRow = { diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index 42647e9ed..b1220ba2f 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import type { AccountLoadState } from "../components/provider-workspace/types"; import { accountNeedsReauth } from "../oauth-health-display"; +import type { AccountQuota } from "../codex-quota-utils"; import { oauthAccountDisplayLabel } from "../provider-workspace/auth"; export interface Config { @@ -21,6 +22,10 @@ export interface OAuthAccount { healthLabel?: string; healthSummary?: string; healthAction?: string; + /** Per-account rate limits (providers that report usage per credential, e.g. anthropic). */ + quota?: AccountQuota | null; + /** Set when the per-account probe could not reach upstream (expired login, 429, network). */ + quotaUnavailable?: boolean; } export interface ApiKeyEntry { id: string; label?: string; masked: string; active: boolean } @@ -75,12 +80,34 @@ export function useProviderAccountPools(deps: { const generation = (accountRequestGenerationRef.current[provider] ?? 0) + 1; accountRequestGenerationRef.current[provider] = generation; try { + // Cheap local read first so account switch / reauth / remove controls appear + // even when Anthropic's usage endpoint is slow or timing out. const res = await fetch(`${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}`); if (!res.ok) throw new Error(String(res.status)); const data = await res.json() as { activeAccountId?: string | null; accounts?: OAuthAccount[] }; if (!aliveRef.current || accountRequestGenerationRef.current[provider] !== generation) return true; setAccountSets(current => ({ ...current, [provider]: { activeAccountId: data.activeAccountId ?? null, accounts: data.accounts ?? [] } })); setAccountLoadStates(current => ({ ...current, [provider]: "ready" })); + + // Enrich with per-account rate limits asynchronously (Anthropic reports usage + // per credential). Failures leave the already-ready account rows untouched. + void (async () => { + try { + const quotaRes = await fetch(`${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}"a=1`); + if (!quotaRes.ok) return; + const quotaData = await quotaRes.json() as { activeAccountId?: string | null; accounts?: OAuthAccount[] }; + if (!aliveRef.current || accountRequestGenerationRef.current[provider] !== generation) return; + setAccountSets(current => ({ + ...current, + [provider]: { + activeAccountId: quotaData.activeAccountId ?? data.activeAccountId ?? null, + accounts: quotaData.accounts ?? data.accounts ?? [], + }, + })); + } catch { + /* keep local account rows without quota enrichment */ + } + })(); return true; } catch { if (!aliveRef.current || accountRequestGenerationRef.current[provider] !== generation) return true; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d206e2c2d..3b5e71f88 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1115,6 +1115,7 @@ export const de: Record = { "pws.usageUnavailable": "Noch keine Nutzung erfasst.", "pws.rateLimits": "Limits", "pws.quotaUnavailable": "Keine Kontingentdaten für diesen Provider.", + "pws.accountQuotaUnavailable": "Ratenlimit-Daten vorübergehend nicht verfügbar; falls vorhanden, werden zuletzt bekannte Werte angezeigt.", "pws.selected": "Ausgewählt", "pws.copyModelId": "ID kopieren", "pws.modelCopied": "Kopiert!", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a2b07434d..56b1288ef 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -891,6 +891,7 @@ export const en = { "pws.usageUnavailable": "No usage recorded yet.", "pws.rateLimits": "Rate limits", "pws.quotaUnavailable": "No quota data for this provider.", + "pws.accountQuotaUnavailable": "Rate-limit data temporarily unavailable; showing last known values when present.", "pws.selected": "Selected", "pws.copyModelId": "Copy ID", "pws.modelCopied": "Copied!", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d27de2415..5c4877215 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -848,6 +848,7 @@ export const ja: Record = { "pws.usageUnavailable": "まだ使用量が記録されていません。", "pws.rateLimits": "レート制限", "pws.quotaUnavailable": "このプロバイダーのクォータデータがありません。", + "pws.accountQuotaUnavailable": "レート制限データを一時的に取得できません。前回の値がある場合はそれを表示します。", "pws.selected": "選択中", "pws.copyModelId": "ID をコピー", "pws.modelCopied": "コピーしました!", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1257596e9..9e7aac326 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1135,6 +1135,7 @@ export const ko: Record = { "pws.usageUnavailable": "아직 기록된 사용량이 없습니다.", "pws.rateLimits": "요청 한도", "pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.", + "pws.accountQuotaUnavailable": "요금 한도 데이터를 일시적으로 가져올 수 없습니다. 이전 값이 있으면 그대로 표시합니다.", "pws.selected": "선택됨", "pws.copyModelId": "ID 복사", "pws.modelCopied": "복사됨!", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 82efcb20d..aaa66fd6b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -890,6 +890,7 @@ export const ru: Record = { "pws.usageUnavailable": "Использование пока не зафиксировано.", "pws.rateLimits": "Лимиты запросов", "pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.", + "pws.accountQuotaUnavailable": "Данные о лимитах временно недоступны; при наличии показываются последние известные значения.", "pws.selected": "Выбрана", "pws.copyModelId": "Копировать ID", "pws.modelCopied": "Скопировано!", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 796724fc1..7651c00cb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1135,6 +1135,7 @@ export const zh: Record = { "pws.usageUnavailable": "尚无用量记录。", "pws.rateLimits": "速率限制", "pws.quotaUnavailable": "此提供商暂无配额数据。", + "pws.accountQuotaUnavailable": "速率限制数据暂时不可用;若有上次已知值则继续显示。", "pws.selected": "已选择", "pws.copyModelId": "复制 ID", "pws.modelCopied": "已复制!", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 0a369195b..14d7baae7 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; import ProviderDetails from "../components/provider-workspace/ProviderDetails"; import type { WorkspaceProvider } from "../provider-workspace/catalog"; @@ -73,6 +73,13 @@ export default function Providers({ apiBase }: { apiBase: string }) { switchAccount, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias, removeAccount, activeAccountNeedsReauth, } = pools; + const quotaRefreshKey = useMemo( + () => Object.entries(accountSets) + .map(([provider, set]) => `${provider}:${set.activeAccountId ?? ""}`) + .sort() + .join("|"), + [accountSets], + ); const jsonEditor = useJsonConfigEditor({ apiBase, config, notify, @@ -203,6 +210,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { jsonSaving={jsonSaving} modelsRefreshToken={modelsRefreshToken} activeAccountNeedsReauth={activeAccountNeedsReauth} + quotaRefreshKey={quotaRefreshKey} detail={(item, data) => { const loginStatus = accountLoginStatus[item.name] ?? oauthStatus[item.name]; return ( diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index 8da4fc545..5241c5905 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -42,6 +42,13 @@ } .pwi-auth-row:hover { background: var(--hover); } .pwi-auth-row--active { background: var(--accent-soft); } + +/* One account entry: the row itself plus (optionally) that account's own rate-limit bars. */ +.pwi-auth-acct { display: flex; flex-direction: column; gap: 2px; border-radius: var(--radius-xs); } +.pwi-auth-acct--active { background: var(--accent-soft); } +.pwi-auth-acct--active .pwi-auth-row--active { background: none; } +.pwi-auth-acct-quota { padding: 0 8px 8px 26px; } +.pwi-auth-acct-quota-stale { margin: 4px 0 0; font-size: 0.85em; } .pwi-auth-row-main { appearance: none; flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; border: 0; background: transparent; color: inherit; text-align: left; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index a610a8cbd..da6fbc858 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -667,6 +667,15 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L } else { await saveCredential(provider, cred); } + if (provider !== "chatgpt") { + try { + const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota"); + clearProviderQuotaCache(); + clearAccountQuotaCache(provider); + } catch { + // Quota module may be unavailable in tightly scoped unit tests. + } + } if (provider === "chatgpt") return cred; const config = loadConfig(); upsertOAuthProvider(config, provider); diff --git a/src/providers/quota.ts b/src/providers/quota.ts index cfe76f8df..f0503f4ec 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1,13 +1,16 @@ import { fetchMainAccountInfo, listCodexAuthAccounts } from "../codex/auth-api"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { resolveEnvValue } from "../config"; -import { getValidAccessToken } from "../oauth"; -import { getCredential } from "../oauth/store"; +import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; +import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers"; +/** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */ +const ACCOUNT_TOKEN_SKEW_MS = 60_000; + const CACHE_TTL_MS = 5 * 60_000; const REQUEST_TIMEOUT_MS = 8_000; const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"; @@ -188,43 +191,221 @@ function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number return { percent, resetAt }; } +/** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */ +const anthropicUsageInflight = new Map>(); + +async function fetchAnthropicUsageQuota(accessToken: string): Promise { + const joinable = anthropicUsageInflight.get(accessToken); + if (joinable) return joinable; + + const probe = (async (): Promise => { + const response = await fetch("https://api.anthropic.com/api/oauth/usage", { + headers: { + Accept: "application/json, text/plain, */*", + "Content-Type": "application/json", + "User-Agent": "claude-cli/2.1.63 (external, cli)", + "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", + Authorization: `Bearer ${accessToken}`, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + const body = asRecord(await response.json().catch(() => null)); + if (!body) return null; + const fiveHour = parseClaudeBucket(body.five_hour); + const sevenDay = parseClaudeBucket(body.seven_day); + const opus = parseClaudeBucket(body.seven_day_opus); + const sonnet = parseClaudeBucket(body.seven_day_sonnet); + const customWindows: ProviderQuotaWindow[] = []; + if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) }); + if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) }); + const quota: ProviderQuota = { + // Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly + // rows: report it in the canonical fields so the dashboard renders it with the standard + // "5-hour limit" label and ordering instead of as a generic extra window. + ...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}), + ...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}), + ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}), + ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}), + ...(customWindows.length > 0 ? { customWindows } : {}), + updatedAt: Date.now(), + }; + // Empty / schema-changed payloads must not cache as "success with no bars". + return hasQuotaRows(quota) ? quota : null; + })().finally(() => { + if (anthropicUsageInflight.get(accessToken) === probe) anthropicUsageInflight.delete(accessToken); + }); + anthropicUsageInflight.set(accessToken, probe); + return probe; +} + async function fetchAnthropicQuota(provider: string): Promise { + // Capture the account we intend to probe before awaiting — a mid-flight active + // switch must not seed the wrong account's cache with this response. + const probedAccountId = getAccountSet("anthropic")?.activeAccountId; let accessToken: string; try { accessToken = await getValidAccessToken("anthropic"); } catch { return null; } - const response = await fetch("https://api.anthropic.com/api/oauth/usage", { - headers: { - Accept: "application/json, text/plain, */*", - "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.63 (external, cli)", - "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", - Authorization: `Bearer ${accessToken}`, - }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) return null; - const body = asRecord(await response.json().catch(() => null)); - if (!body) return null; - const fiveHour = parseClaudeBucket(body.five_hour); - const sevenDay = parseClaudeBucket(body.seven_day); - const opus = parseClaudeBucket(body.seven_day_opus); - const sonnet = parseClaudeBucket(body.seven_day_sonnet); - const customWindows: ProviderQuotaWindow[] = []; - if (fiveHour?.percent !== undefined) customWindows.push({ label: "5h", percent: fiveHour.percent, ...(fiveHour.resetAt !== undefined ? { resetAt: fiveHour.resetAt } : {}) }); - if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) }); - if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) }); - const quota: ProviderQuota = { - ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}), - ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}), - ...(customWindows.length > 0 ? { customWindows } : {}), - updatedAt: Date.now(), - }; + const quota = await fetchAnthropicUsageQuota(accessToken); + if (!quota) return null; + // Share the active-account probe with the per-account cache so Providers-page + // loads do not double-hit Anthropic's rate-limited usage endpoint. + if (probedAccountId) { + const stillOwnsToken = getAccountCredential("anthropic", probedAccountId)?.access === accessToken; + if (stillOwnsToken) { + accountQuotaCache.set(accountCacheKey("anthropic", probedAccountId), { ts: Date.now(), quota }); + } + } return report(provider, "anthropic:oauth-usage", quota); } +// --------------------------------------------------------------------------- +// Per-account quota (multiauth) +// --------------------------------------------------------------------------- + +/** + * Anthropic reports usage per CREDENTIAL, so every logged-in account can be probed with its + * own bearer token — the active-account selection and the local usage log are irrelevant here. + * Mirrors the Codex pool behaviour (codex/auth-api.ts:fetchPoolAccountQuota), including a + * per-account TTL so N accounts cost at most N upstream calls per window. + * + * The TTL is deliberately longer than the provider-level one: this path multiplies by account + * count, and Anthropic rate-limits the usage endpoint (observed 429 under repeated probing). + */ +const ACCOUNT_QUOTA_TTL_MS = 10 * 60_000; +type AccountQuotaCacheEntry = { + ts: number; + quota: ProviderQuota | null; + /** Last probe failed (429 / network / expired login); still may hold last-good quota. */ + unavailable?: true; +}; +const accountQuotaCache = new Map(); +const accountQuotaInflight = new Map>(); + +export interface ProviderAccountQuota { + accountId: string; + quota: ProviderQuota | null; + /** Set when the probe could not reach upstream (expired login, 429, network). */ + unavailable?: true; +} + +/** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */ +export function supportsPerAccountQuota(provider: string): boolean { + return provider === "anthropic"; +} + +function accountCacheKey(provider: string, accountId: string): string { + return `${provider}\u0000${accountId}`; +} + +/** Drop cached per-account rows (all, or just one provider's). */ +export function clearAccountQuotaCache(provider?: string): void { + if (!provider) { + accountQuotaCache.clear(); + accountQuotaInflight.clear(); + return; + } + const prefix = `${provider}\u0000`; + for (const key of [...accountQuotaCache.keys()]) { + if (key.startsWith(prefix)) accountQuotaCache.delete(key); + } + // Drop in-flight probes too so a late resolve cannot repopulate after logout/remove. + for (const key of [...accountQuotaInflight.keys()]) { + if (key.startsWith(prefix)) accountQuotaInflight.delete(key); + } +} + +/** + * Resolve a bearer for quota probing without silently adopting a newer global + * Claude CLI credential into a background multiauth slot. + * + * - Fresh stored access → use as-is (no refresh). + * - Active account with expired access → normal refresh path. + * - Background `local-cli` with expired access → fail closed (unavailable): + * `getValidAccessTokenForAccount` can persist a mismatched Claude CLI identity. + * - Background ordinary OAuth (`source !== "local-cli"`) → safe to refresh; + * Anthropic's lock only adopts disk credentials for `local-cli` rows. + */ +async function getTokenForAccountQuotaProbe(provider: string, accountId: string): Promise { + const stored = getAccountCredential(provider, accountId); + if (!stored) throw new Error("account credential missing"); + if (stored.expires > Date.now() + ACCOUNT_TOKEN_SKEW_MS) return stored.access; + const activeId = getAccountSet(provider)?.activeAccountId; + if (activeId !== accountId && stored.source === "local-cli") { + throw new Error("background local-cli token expired; skip CLI-adopting refresh for quota probe"); + } + return getValidAccessTokenForAccount(provider, accountId); +} + +async function fetchAccountQuota( + provider: string, + accountId: string, + forceRefresh: boolean, +): Promise { + const key = accountCacheKey(provider, accountId); + const cached = accountQuotaCache.get(key); + if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) return cached; + const joinable = accountQuotaInflight.get(key); + if (joinable) return joinable; + + const probe = (async (): Promise => { + try { + const token = await getTokenForAccountQuotaProbe(provider, accountId); + const quota = await fetchAnthropicUsageQuota(token); + if (!quota) { + // Preserve last-good bars and mark unavailable; advance TTL so failures + // negative-cache instead of re-probing on every GUI poll. + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), + quota: cached?.quota ?? null, + unavailable: true, + }; + accountQuotaCache.set(key, entry); + return entry; + } + const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota }; + accountQuotaCache.set(key, entry); + return entry; + } catch { + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), + quota: cached?.quota ?? null, + unavailable: true, + }; + accountQuotaCache.set(key, entry); + return entry; + } + })().finally(() => { + if (accountQuotaInflight.get(key) === probe) accountQuotaInflight.delete(key); + }); + accountQuotaInflight.set(key, probe); + return probe; +} + +/** + * Per-account quota rows for a provider's logged-in accounts. Probes run in parallel; a + * single failing account never blocks the others. + */ +export async function fetchProviderAccountQuotas( + provider: string, + forceRefresh = false, +): Promise { + if (!supportsPerAccountQuota(provider)) return []; + const set = getAccountSet(provider); + if (!set) return []; + return await Promise.all(set.accounts.map(async account => { + const entry = await fetchAccountQuota(provider, account.id, forceRefresh); + return { + accountId: account.id, + quota: entry.quota, + ...(entry.unavailable ? { unavailable: true as const } : {}), + }; + })); +} + function normalizedBaseUrl(value: string): string | null { try { const url = new URL(value); diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 70ac002a3..009f1147a 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -27,7 +27,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; +import { clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports, supportsPerAccountQuota } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; @@ -146,8 +146,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< await removeCredential(provider); clearLoginState(provider); // Drop cached/last-good quota rows tied to the removed credential. - const { clearProviderQuotaCache } = await import("../../providers/quota"); + const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); + clearAccountQuotaCache(provider); return jsonResponse({ success: true }); } @@ -163,18 +164,46 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< projectOAuthAccountHealth, projectStoredOAuthAccountHealth, } = await import("../../oauth/health"); - const set = getAccountSet(provider); - const accounts = (status.accounts ?? []).map(summary => { - const full = set?.accounts.find(account => account.id === summary.id); - const health = full - ? projectStoredOAuthAccountHealth(provider, full) - : projectOAuthAccountHealth({ - needsReauth: summary.needsReauth === true, - reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined, - }); - return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) }; + const projectAccounts = () => { + const set = getAccountSet(provider); + const current = getLoginStatus(provider); + return { + activeAccountId: current.activeAccountId ?? null, + accounts: (current.accounts ?? []).map(summary => { + const full = set?.accounts.find(account => account.id === summary.id); + const health = full + ? projectStoredOAuthAccountHealth(provider, full) + : projectOAuthAccountHealth({ + needsReauth: summary.needsReauth === true, + reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined, + }); + return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) }; + }), + }; + }; + // Per-account rate limits: Anthropic reports usage per credential, so every logged-in + // account can show its own 5h/weekly bars (not just the active one). Opt-in via ?quota=1 + // so the plain account list stays a cheap local read; ?refresh=1 bypasses the TTL. + const wantQuota = url.searchParams.get("quota") === "1" && supportsPerAccountQuota(provider); + if (!wantQuota) return jsonResponse(projectAccounts()); + const forceRefresh = url.searchParams.get("refresh") === "1"; + // Probing may refresh the active credential and mark needsReauth — project health + // from the post-probe store so the response is not stale. + const rows = await fetchProviderAccountQuotas(provider, forceRefresh); + const byId = new Map(rows.map(row => [row.accountId, row])); + const projected = projectAccounts(); + return jsonResponse({ + activeAccountId: projected.activeAccountId, + accounts: projected.accounts.map(account => { + const row = byId.get(account.id); + if (!row) return account; + return { + ...account, + quota: row.quota, + ...(row.unavailable ? { quotaUnavailable: true } : {}), + }; + }), }); - return jsonResponse({ activeAccountId: status.activeAccountId ?? null, accounts }); } if (url.pathname === "/api/oauth/accounts/active" && req.method === "PUT") { const body = await req.json().catch(() => ({})) as { provider?: string; accountId?: string }; @@ -209,8 +238,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const { removeAccount, getAccountSet } = await import("../../oauth/store"); if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404); if (!getAccountSet(provider)) clearLoginState(provider); - const { clearProviderQuotaCache } = await import("../../providers/quota"); + const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); + clearAccountQuotaCache(provider); return jsonResponse({ ok: true }); } diff --git a/tests/provider-account-quota.test.ts b/tests/provider-account-quota.test.ts new file mode 100644 index 000000000..16dd5dd3a --- /dev/null +++ b/tests/provider-account-quota.test.ts @@ -0,0 +1,376 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCredential } from "../src/oauth/store"; +import type { OcxConfig } from "../src/types"; +import { + clearAccountQuotaCache, + clearProviderQuotaCache, + fetchProviderAccountQuotas, + fetchProviderQuotaReports, + supportsPerAccountQuota, +} from "../src/providers/quota"; + +const originalFetch = globalThis.fetch; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let opencodexHome: string; + +const FIRST = { accountId: "acct-first", email: "first@example.com" }; +const SECOND = { accountId: "acct-second", email: "second@example.com" }; + +/** Two logged-in Claude accounts, each with its own (non-expired) bearer token. */ +async function seedTwoAccounts(): Promise { + const expires = Date.now() + 60 * 60_000; + await saveCredential("anthropic", { access: "token-first", refresh: "refresh-first", expires, ...FIRST }); + await saveCredential("anthropic", { access: "token-second", refresh: "refresh-second", expires, ...SECOND }); +} + +function usageBody(fiveHour: number, sevenDay: number): string { + return JSON.stringify({ + five_hour: { utilization: fiveHour, resets_at: "2026-07-05T12:00:00Z" }, + seven_day: { utilization: sevenDay, resets_at: "2026-07-08T12:00:00Z" }, + }); +} + +beforeEach(() => { + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-account-quota-")); + process.env.OPENCODEX_HOME = opencodexHome; + clearAccountQuotaCache(); + clearProviderQuotaCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(opencodexHome, { recursive: true, force: true }); + clearAccountQuotaCache(); + clearProviderQuotaCache(); +}); + +describe("fetchProviderAccountQuotas", () => { + test("reports each account's own rate limits, keyed by the account's bearer token", async () => { + await seedTwoAccounts(); + const seenTokens: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("https://api.anthropic.com/api/oauth/usage"); + const auth = new Headers(init?.headers).get("authorization") ?? ""; + seenTokens.push(auth); + // Distinct upstream numbers per credential — the whole point of a per-account probe. + const body = auth.endsWith("token-first") ? usageBody(70, 15) : usageBody(3, 21); + return new Response(body, { status: 200 }); + }) as typeof fetch; + + const rows = await fetchProviderAccountQuotas("anthropic"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + const ids = Object.keys(byId); + expect(ids.length).toBe(2); + + const values = rows.map(row => `${row.quota?.fiveHourPercent}/${row.quota?.weeklyPercent}`).sort(); + expect(values).toEqual(["3/21", "70/15"]); + expect(seenTokens.sort()).toEqual(["Bearer token-first", "Bearer token-second"]); + // The 5-hour window lands in the canonical fields, not in customWindows. + for (const row of rows) expect(row.quota?.customWindows).toBeUndefined(); + }); + + test("a cached row is reused instead of re-probing upstream", async () => { + await seedTwoAccounts(); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(usageBody(50, 10), { status: 200 }); + }) as typeof fetch; + + await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(2); + await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(2); + + // A forced refresh bypasses the TTL. + await fetchProviderAccountQuotas("anthropic", true); + expect(calls).toBe(4); + }); + + test("a failing probe is flagged unavailable without dropping the other account", async () => { + await seedTwoAccounts(); + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const auth = new Headers(init?.headers).get("authorization") ?? ""; + // Anthropic rate-limits this endpoint; one 429 must not blank the sibling account. + if (auth.endsWith("token-first")) return new Response("rate limited", { status: 429 }); + return new Response(usageBody(3, 21), { status: 200 }); + }) as typeof fetch; + + const rows = await fetchProviderAccountQuotas("anthropic"); + const failed = rows.find(row => row.quota === null); + const ok = rows.find(row => row.quota !== null); + expect(failed?.unavailable).toBe(true); + expect(ok?.quota?.fiveHourPercent).toBe(3); + }); + + test("success-then-fail preserves last-good quota and keeps unavailable", async () => { + await seedTwoAccounts(); + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const auth = new Headers(init?.headers).get("authorization") ?? ""; + calls += 1; + if (calls <= 2) { + const body = auth.endsWith("token-first") ? usageBody(70, 15) : usageBody(3, 21); + return new Response(body, { status: 200 }); + } + return new Response("rate limited", { status: 429 }); + }) as typeof fetch; + + const first = await fetchProviderAccountQuotas("anthropic"); + expect(first.every(row => row.quota && !row.unavailable)).toBe(true); + expect(calls).toBe(2); + const firstByValues = Object.fromEntries( + first.map(row => [`${row.quota?.fiveHourPercent}/${row.quota?.weeklyPercent}`, row.accountId]), + ); + + const second = await fetchProviderAccountQuotas("anthropic", true); + expect(calls).toBe(4); + for (const row of second) { + expect(row.unavailable).toBe(true); + expect(row.quota).not.toBeNull(); + } + const byId = Object.fromEntries(second.map(row => [row.accountId, row])); + expect(byId[firstByValues["70/15"]!]?.quota?.fiveHourPercent).toBe(70); + expect(byId[firstByValues["3/21"]!]?.quota?.fiveHourPercent).toBe(3); + }); + + test("failed probes negative-cache for the account TTL instead of re-probing", async () => { + await seedTwoAccounts(); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response("rate limited", { status: 429 }); + }) as typeof fetch; + + const first = await fetchProviderAccountQuotas("anthropic"); + expect(first.every(row => row.unavailable && row.quota === null)).toBe(true); + expect(calls).toBe(2); + + const second = await fetchProviderAccountQuotas("anthropic"); + expect(second.every(row => row.unavailable && row.quota === null)).toBe(true); + expect(calls).toBe(2); + }); + + test("expired background accounts skip CLI-adopting refresh for quota probes", async () => { + const expires = Date.now() - 60_000; + await saveCredential("anthropic", { + access: "token-active", refresh: "refresh-active", expires: Date.now() + 60 * 60_000, + accountId: "acct-active", email: "active@example.com", + }); + await saveCredential("anthropic", { + access: "token-bg", refresh: "refresh-bg", expires, + accountId: "acct-bg", email: "bg@example.com", source: "local-cli", + }); + const { getAccountSet, setActiveAccount } = await import("../src/oauth/store"); + const set = getAccountSet("anthropic"); + const active = set?.accounts.find(a => a.credential.email === "active@example.com"); + const background = set?.accounts.find(a => a.credential.email === "bg@example.com"); + expect(active && background).toBeTruthy(); + await setActiveAccount("anthropic", active!.id); + + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls += 1; + const auth = new Headers(init?.headers).get("authorization") ?? ""; + expect(auth).toBe("Bearer token-active"); + return new Response(usageBody(11, 22), { status: 200 }); + }) as typeof fetch; + + const rows = await fetchProviderAccountQuotas("anthropic"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + expect(byId[active!.id]?.quota?.fiveHourPercent).toBe(11); + expect(byId[active!.id]?.unavailable).toBeUndefined(); + expect(byId[background!.id]?.quota).toBeNull(); + expect(byId[background!.id]?.unavailable).toBe(true); + // Only the active credential was probed; background expired slot failed closed. + expect(calls).toBe(1); + // Credential integrity: the expired local-cli background slot must not have + // been overwritten with the active (or any other) disk/CLI identity. + const after = getAccountSet("anthropic")?.accounts.find(a => a.id === background!.id); + expect(after?.credential.access).toBe("token-bg"); + expect(after?.credential.email).toBe("bg@example.com"); + expect(after?.credential.source).toBe("local-cli"); + }); + + test("providers without a per-account usage API are skipped", async () => { + expect(supportsPerAccountQuota("anthropic")).toBe(true); + expect(supportsPerAccountQuota("kiro")).toBe(false); + let called = false; + globalThis.fetch = (async () => { called = true; return new Response("{}", { status: 200 }); }) as typeof fetch; + expect(await fetchProviderAccountQuotas("kiro")).toEqual([]); + expect(called).toBe(false); + }); + + test("a provider with no logged-in accounts yields no rows and no upstream calls", async () => { + let called = false; + globalThis.fetch = (async () => { called = true; return new Response("{}", { status: 200 }); }) as typeof fetch; + expect(await fetchProviderAccountQuotas("anthropic")).toEqual([]); + expect(called).toBe(false); + }); + + test("provider-report probe seeds the active account cache for per-account reads", async () => { + await seedTwoAccounts(); + const { getAccountSet, setActiveAccount } = await import("../src/oauth/store"); + const set = getAccountSet("anthropic"); + const first = set?.accounts.find(a => a.credential.email === "first@example.com"); + expect(first).toBeTruthy(); + await setActiveAccount("anthropic", first!.id); + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls += 1; + const auth = new Headers(init?.headers).get("authorization") ?? ""; + const body = auth.endsWith("token-first") ? usageBody(70, 15) : usageBody(3, 21); + return new Response(body, { status: 200 }); + }) as typeof fetch; + + const config: OcxConfig = { + port: 1455, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + authMode: "oauth", + baseUrl: "https://api.anthropic.com/v1", + }, + }, + }; + await fetchProviderQuotaReports(config, true); + expect(calls).toBe(1); + + const rows = await fetchProviderAccountQuotas("anthropic"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + // Active account reused the provider-report probe; only the sibling was hit again. + expect(calls).toBe(2); + expect(byId[first!.id]?.quota?.fiveHourPercent).toBe(70); + const sibling = rows.find(row => row.accountId !== first!.id); + expect(sibling?.quota?.fiveHourPercent).toBe(3); + }); + + test("empty Anthropic usage payloads are treated as probe failures", async () => { + await seedTwoAccounts(); + globalThis.fetch = (async () => new Response("{}", { status: 200 })) as typeof fetch; + const rows = await fetchProviderAccountQuotas("anthropic"); + expect(rows).toHaveLength(2); + expect(rows.every(row => row.unavailable && row.quota === null)).toBe(true); + }); + + test("expired ordinary OAuth background accounts still refresh for quota probes", async () => { + const expires = Date.now() - 60_000; + await saveCredential("anthropic", { + access: "token-active", refresh: "refresh-active", expires: Date.now() + 60 * 60_000, + accountId: "acct-active", email: "active@example.com", source: "oauth", + }); + await saveCredential("anthropic", { + access: "token-bg", refresh: "refresh-bg", expires, + accountId: "acct-bg", email: "bg@example.com", source: "oauth", + }); + const { getAccountSet, setActiveAccount } = await import("../src/oauth/store"); + const set = getAccountSet("anthropic"); + const active = set?.accounts.find(a => a.credential.email === "active@example.com"); + const background = set?.accounts.find(a => a.credential.email === "bg@example.com"); + expect(active && background).toBeTruthy(); + await setActiveAccount("anthropic", active!.id); + + let refreshCalls = 0; + let usageForBg = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/v1/oauth/token")) { + refreshCalls += 1; + return new Response(JSON.stringify({ + access_token: "token-bg-fresh", + refresh_token: "refresh-bg", + expires_in: 3600, + }), { status: 200 }); + } + const auth = new Headers(init?.headers).get("authorization") ?? ""; + if (auth.endsWith("token-bg-fresh")) { + usageForBg += 1; + return new Response(usageBody(44, 55), { status: 200 }); + } + return new Response(usageBody(11, 22), { status: 200 }); + }) as typeof fetch; + + const rows = await fetchProviderAccountQuotas("anthropic"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + expect(refreshCalls).toBeGreaterThanOrEqual(1); + expect(usageForBg).toBe(1); + expect(byId[background!.id]?.quota?.fiveHourPercent).toBe(44); + expect(byId[background!.id]?.unavailable).toBeUndefined(); + }); + + test("clearing account quota cache after failure allows a fresh probe", async () => { + await seedTwoAccounts(); + globalThis.fetch = (async () => new Response("rate limited", { status: 429 })) as typeof fetch; + const failed = await fetchProviderAccountQuotas("anthropic"); + expect(failed.every(row => row.unavailable)).toBe(true); + + // runLogin / reauth clears this cache after credentials are replaced. + clearAccountQuotaCache("anthropic"); + globalThis.fetch = (async () => new Response(usageBody(9, 8), { status: 200 })) as typeof fetch; + const after = await fetchProviderAccountQuotas("anthropic"); + expect(after.every(row => row.quota && !row.unavailable)).toBe(true); + }); + + test("provider-report seeding binds to the probed account across an active switch", async () => { + await seedTwoAccounts(); + const { getAccountSet, setActiveAccount } = await import("../src/oauth/store"); + const set = getAccountSet("anthropic"); + const first = set?.accounts.find(a => a.credential.email === "first@example.com"); + const second = set?.accounts.find(a => a.credential.email === "second@example.com"); + expect(first && second).toBeTruthy(); + await setActiveAccount("anthropic", first!.id); + + let releaseUsage!: () => void; + const usageGate = new Promise(resolve => { releaseUsage = resolve; }); + let usageCalls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const auth = new Headers(init?.headers).get("authorization") ?? ""; + if (auth.endsWith("token-first")) { + usageCalls += 1; + await usageGate; + return new Response(usageBody(70, 15), { status: 200 }); + } + return new Response(usageBody(3, 21), { status: 200 }); + }) as typeof fetch; + + const config: OcxConfig = { + port: 1455, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + authMode: "oauth", + baseUrl: "https://api.anthropic.com/v1", + }, + }, + }; + const reportPromise = fetchProviderQuotaReports(config, true); + // Switch active mid-flight before Anthropic responds. + await setActiveAccount("anthropic", second!.id); + releaseUsage(); + await reportPromise; + + // First account still owns token-first — seed must land on first, not second. + clearProviderQuotaCache(); + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls += 1; + const auth = new Headers(init?.headers).get("authorization") ?? ""; + const body = auth.endsWith("token-first") ? usageBody(70, 15) : usageBody(3, 21); + return new Response(body, { status: 200 }); + }) as typeof fetch; + const rows = await fetchProviderAccountQuotas("anthropic"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + // First was seeded (no re-probe); only second needs a fresh probe after the switch. + expect(usageCalls).toBe(1); + expect(byId[first!.id]?.quota?.fiveHourPercent).toBe(70); + expect(byId[second!.id]?.quota?.fiveHourPercent).toBe(3); + expect(calls).toBe(1); + }); +}); diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index ad23fa1c4..11e22aaa4 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -184,8 +184,11 @@ describe("fetchProviderQuotaReports", () => { expect(byProvider.openai?.quota.weeklyPercent).toBe(34); expect(byProvider.xai?.quota.monthlyPercent).toBe(25); expect(byProvider.anthropic?.quota.weeklyPercent).toBe(72); + // Claude's 5-hour window is reported in the canonical fields (like the Codex login rows), + // so only the model-specific windows remain as custom entries. + expect(byProvider.anthropic?.quota.fiveHourPercent).toBe(41.5); + expect(byProvider.anthropic?.quota.fiveHourResetAt).toBe(Date.parse("2026-07-05T12:00:00Z")); expect(byProvider.anthropic?.quota.customWindows).toEqual([ - { label: "5h", percent: 41.5, resetAt: Date.parse("2026-07-05T12:00:00Z") }, { label: "Opus", percent: 88 }, { label: "Sonnet", percent: 19 }, ]); From 053ad66063839921d6abf92f52dcd797d1ef9649 Mon Sep 17 00:00:00 2001 From: wonsh42 Date: Tue, 28 Jul 2026 07:22:18 +0900 Subject: [PATCH 017/129] feat(codex): add opt-in native subagent defaults (#498) Co-authored-by: wonsh42 --- .../content/docs/guides/codex-app-models.md | 12 +- .../content/docs/guides/sub-agent-surface.md | 15 +- .../src/content/docs/guides/web-dashboard.md | 28 +- .../docs/ja/guides/codex-app-models.md | 12 +- .../docs/ja/guides/sub-agent-surface.md | 15 +- .../content/docs/ja/guides/web-dashboard.md | 22 +- .../docs/ja/reference/configuration.md | 7 +- .../docs/ko/guides/codex-app-models.md | 12 +- .../docs/ko/guides/sub-agent-surface.md | 15 +- .../content/docs/ko/guides/web-dashboard.md | 20 +- .../docs/ko/reference/configuration.md | 7 +- .../content/docs/reference/configuration.md | 7 +- .../docs/ru/guides/codex-app-models.md | 15 +- .../docs/ru/guides/sub-agent-surface.md | 15 +- .../content/docs/ru/guides/web-dashboard.md | 25 +- .../docs/ru/reference/configuration.md | 7 +- .../docs/zh-cn/guides/codex-app-models.md | 9 +- .../docs/zh-cn/guides/sub-agent-surface.md | 15 +- .../docs/zh-cn/guides/web-dashboard.md | 17 +- .../docs/zh-cn/reference/configuration.md | 7 +- gui/src/App.tsx | 22 +- gui/src/i18n/de.ts | 8 +- gui/src/i18n/en.ts | 8 +- gui/src/i18n/ja.ts | 8 +- gui/src/i18n/ko.ts | 8 +- gui/src/i18n/ru.ts | 8 +- gui/src/i18n/zh.ts | 8 +- gui/src/pages/dashboard-core-poll.ts | 26 +- gui/src/pages/dashboard-overview-sections.tsx | 78 +-- gui/src/pages/dashboard-shared.ts | 3 +- gui/src/pages/use-dashboard-data.ts | 41 +- gui/src/stop-proxy.ts | 66 +++ gui/tests/app-stop.test.ts | 69 +++ gui/tests/dashboard-contracts.test.ts | 37 +- gui/tests/multi-agent-guidance.test.tsx | 154 +++-- src/cli/index.ts | 62 +- src/codex/inject.ts | 128 +++- src/codex/subagent-defaults.ts | 550 ++++++++++++++++++ src/codex/sync.ts | 3 + src/config.ts | 102 +++- .../management/agent-settings-routes.ts | 32 +- src/server/management/config-routes.ts | 1 + src/types.ts | 5 + structure/02_config-and-codex-home.md | 7 + structure/03_catalog-and-subagents.md | 9 + structure/05_gui-and-management-api.md | 2 +- tests/cli-restore-back.test.ts | 52 +- tests/codex-inject-integration.test.ts | 138 ++++- tests/codex-inject.test.ts | 24 + tests/codex-journal.test.ts | 150 ++++- tests/codex-sync-api.test.ts | 61 ++ tests/config.test.ts | 138 +++++ tests/grok-lifecycle.test.ts | 15 + tests/injection-model-api.test.ts | 152 ++++- tests/subagent-defaults.test.ts | 381 ++++++++++++ 55 files changed, 2545 insertions(+), 293 deletions(-) create mode 100644 gui/src/stop-proxy.ts create mode 100644 gui/tests/app-stop.test.ts create mode 100644 src/codex/subagent-defaults.ts create mode 100644 tests/subagent-defaults.test.ts diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 9a781a4f1..b039b338d 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -136,9 +136,10 @@ Set the mode from the Dashboard or Models page, `ocx v2 mode v1|default|v2`, or with `{ "multiAgentMode": "v1" }`. Changes apply to new Codex sessions. :::caution -On the v2 (`multi_agent_v2`) surface, spawned sub-agents inherit the parent session's model. The -dashboard's delegation model/effort picker is v1 prompt guidance, not a proxy-side per-spawn -cross-model router. See [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical +On the v2 (`multi_agent_v2`) surface, a spawn without an explicit model can inherit the parent +session's model. OpenCodex guidance can ask Codex to pass the selected model/effort explicitly, and +the separate native-default opt-in can supply defaults after sync/restart. Neither mechanism is a +proxy-side per-spawn router. See [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical behavior. ::: @@ -175,8 +176,9 @@ Codex sorts picker-visible catalog entries by ascending `priority` and advertise through `subagentModels` or the dashboard Subagents page; opencodex gives those entries priorities 0-4 in the chosen order. Other models remain callable by exact id. -The featured-model list is separate from the Dashboard's **Sub-agent delegation** guidance. In -particular, featured model overrides do not bypass v2's parent-model inheritance rule. +The featured-model list is separate from the Dashboard's **Sub-agent delegation** selection. It +controls which overrides Codex offers first; it does not select a model or trigger delegation by +itself. ## Refreshing model state diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 764e8feee..ae9738ad9 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ The override is the final pass in both the live `/v1/models` catalog response an ### Delegation model and effort -The dashboard's **Sub-agent delegation** picker stores an `injectionModel` and, optionally, an `injectionEffort`. These are delegation guidance settings, not a proxy-side spawn router. An optional `injectionPrompt` replaces the built-in guidance text entirely. +The dashboard's **Sub-agent delegation** picker stores an `injectionModel` and, optionally, an `injectionEffort`. OpenCodex-authored delegation guidance can use these selections, but it remains controlled separately by **OpenCodex multi-agent guidance**. These settings are not a proxy-side spawn router. An optional `injectionPrompt` replaces the built-in guidance text entirely. + +The default-off **Use as native Codex subagent defaults** switch sets `syncCodexSubagentDefaults`. When OpenCodex manages the active Codex routing, a sync or restart applies the selected model and effort as native Codex `[agents]` defaults for newly created Codex tasks. External user-managed provider configs remain untouched. This does not trigger delegation. Existing user-owned `[agents]` defaults are preserved rather than overwritten, so they remain authoritative. `multiAgentGuidanceText` identifies the surface from the request's tools — including the Codex Desktop WebSocket path (`responses_lite`), where tools arrive inside an `additional_tools` input item instead of the request's `tools` array. @@ -56,7 +58,7 @@ To replace the built-in v2 guidance, set `injectionPrompt` (config key, or `PUT - **Dashboard** → first stat cell: click **v1**, **base**, or **v2**. - **Models** page → top-row segmented control. - Both pages have a **?** button that opens a help modal with a link back here. -- **Dashboard** → **Sub-agent delegation**: choose a preferred model and optional reasoning effort. On v2 the injected guidance instructs the agent to spawn with `fork_turns: "none"` so the model override applies. If a native→routed child receives only encrypted task content, use a native target or v1; external-only delivery now fails explicitly with `unreadable_encrypted_agent_task` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **Dashboard** → **Sub-agent delegation**: choose a preferred model and optional reasoning effort. Enable **OpenCodex multi-agent guidance** for delegation instructions, or independently enable **Use as native Codex subagent defaults** to apply the selection to new Codex tasks after sync/restart. The defaults switch does not cause delegation, and existing user-owned `[agents]` defaults are preserved rather than overwritten. On v2 the injected guidance instructs the agent to spawn with `fork_turns: "none"` so the model override applies. If a native→routed child receives only encrypted task content, use a native target or v1; external-only delivery now fails explicitly with `unreadable_encrypted_agent_task` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# Opt in to native Codex defaults for new tasks after sync/restart (requires a model) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # Set a custom guidance prompt ({{model}}/{{effort}}/{{roster}} placeholders) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` returns `model`, `effort`, `prompt`, the global `efforts` ladder, and enabled native/routed `available` models. For PUT, omitting `effort` or `prompt` keeps the current value, `null` clears it, and clearing `model` always clears the effort too. The API validates effort against the global Codex ladder; Codex still validates a spawn effort against the target catalog entry. +`GET /api/injection-model` returns `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled`, `syncCodexSubagentDefaults`, the global `efforts` ladder, and enabled native/routed `available` models. PUT is partial: omitted fields keep their current values, `null` clears nullable values, and clearing `model` always clears both the effort and native-default opt-in. Enabling `syncCodexSubagentDefaults` requires a model. The API validates effort against the global Codex ladder; Codex still validates a spawn effort against the target catalog entry. ## Reasoning effort -The optional sub-agent effort setting is stored as `injectionEffort` and is meaningful only with an injection model. It adds a `reasoning_effort` instruction to the injected v2 guidance; it does not change the parent session's effort. On any fork that accepts overrides, Codex applies a `reasoning_effort` passed to `spawn_agent` directly. +The optional sub-agent effort setting is stored as `injectionEffort` and is meaningful only with an injection model. It adds a `reasoning_effort` instruction to the injected v2 guidance; with native-default sync enabled, it also becomes the `[agents]` reasoning default for new Codex tasks after sync/restart. It does not change the parent session's effort. On any fork that accepts overrides, Codex applies a `reasoning_effort` passed to `spawn_agent` directly. `ultra` ranks above `max` in the Codex catalog and adds automatic-delegation semantics, but it never reaches a provider as a literal wire value. Codex converts `ultra` to `max` at the client boundary. opencodex then keeps the provider request valid: diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index fff8735b2..a1938821b 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -26,7 +26,7 @@ bun run dev:gui | Area | What it does | | --- | --- | | **Dashboard summary** | Multi-agent mode, online state, version, uptime, provider count, 30-day token total, active providers, and available native/routed models. | -| **Sub-agent delegation** | Choose a native or routed guidance model and an optional reasoning effort for v1 delegation prompts. This is not a per-spawn router; see below. | +| **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by OpenCodex delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. | | **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | | **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. | | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | @@ -61,17 +61,29 @@ The **Models** switches show final Codex visibility: a routed model is on only w ## Delegation picker vs spawn routing The Dashboard's **Sub-agent delegation** picker stores `injectionModel` and, optionally, -`injectionEffort`. On a v1 turn, opencodex injects guidance telling the parent agent which exact -model and reasoning effort to pass to `spawn_agent`. Choosing a model enables that guidance at any -parent reasoning effort; clearing the model also clears the stored effort. +`injectionEffort`. **OpenCodex multi-agent guidance** independently controls the delegation +instructions that use those values. On eligible v2 turns, that guidance tells the parent +agent which exact model and reasoning effort to pass to `spawn_agent`; clearing the model also clears +the stored effort. + +The default-off **Use as native Codex subagent defaults** switch applies the same selection to Codex's +native `[agents]` defaults on the next sync/restart when OpenCodex manages the active Codex routing. +External user-managed provider configs remain untouched. Those defaults affect newly created Codex tasks +and do not themselves cause delegation. Existing user-owned `[agents]` defaults are preserved rather +than overwritten, so they may continue to override the requested defaults. :::caution -This picker is delegation guidance for the v1 compatibility surface. On `multi_agent_v2`, the -current proxy does not append the v1 injection message, and every spawned sub-agent inherits the -parent session's model. It is not a proxy-side cross-model router. See +Neither control is a proxy-side cross-model spawn router. OpenCodex guidance asks Codex to pass +overrides to `spawn_agent`; native `[agents]` defaults apply only when Codex creates a new task after +they have been synchronized. See [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical v1/base/v2 behavior. ::: +The spawn override guarantee applies to the **built-in** v2 guidance text. A custom +`injectionPrompt` replaces that text entirely and must include `{{model}}` and `{{effort}}` +placeholders (and optionally `{{roster}}`) or those values will not appear in the injected +guidance. + The picker offers enabled native and routed models plus the global Codex effort ladder. The API validates the selected effort globally; Codex still validates a spawn effort against the target catalog entry. @@ -107,7 +119,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `POST /api/sync` | Rebuild the shared model catalog and stale the Codex model cache. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Check, run, and monitor self-update jobs. | | `GET` / `PUT /api/sidecar-settings` | Read or set search/vision sidecar model settings. | -| `GET` / `PUT /api/injection-model` | Read or set the v1 delegation guidance model and optional effort. | +| `GET` / `PUT /api/injection-model` | Read or set the shared sub-agent model/effort selection and the independent guidance/native-default switches. | | `GET` / `PUT /api/v2` | Read or set the surface mode, Codex feature flag, and v2 thread limit. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | List, add/replace, enable/disable, or remove providers. | | `GET /api/models` · `PUT /api/disabled-models` | List native/routed model rows and update the shared disabled-model set. | diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index b041abdcf..035e50075 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -112,10 +112,10 @@ opencodex は全カタログ項目の `multi_agent_version` を制御する 3 セッションから適用されます。 :::caution -v2(`multi_agent_v2`)サーフェスで生成されたサブエージェントは親セッションのモデルを継承します。ダッシュボードの -委任モデル/強度セレクターは v1 プロンプトガイダンスであり、プロキシがスポーンごとに別モデルにルーティングする機能では -ありません。正確な動作は[サブエージェントサーフェス](/ja/guides/sub-agent-surface/)を -参照してください。 +v2(`multi_agent_v2`)サーフェスでは、モデルを明示しないスポーンは親セッションのモデルを継承できます。 +OpenCodex ガイダンスは選択したモデル/強度を明示的に渡すよう Codex に指示でき、別のネイティブデフォルト設定は +sync/restart 後に既定値を提供できます。どちらもプロキシ側のスポーン単位ルーターではありません。正確な動作は +[サブエージェントサーフェス](/ja/guides/sub-agent-surface/)を参照してください。 ::: ## 最上位推論段階 @@ -151,8 +151,8 @@ Codex はピッカーに表示されるカタログ項目を `priority` 昇順 ネイティブ ID または `provider/model` ID を最大 5 つ選ぶと opencodex が選択順に priority 0-4 を 付与します。残りのモデルも正確な ID で直接呼び出し可能です。 -フィーチャー済みモデル一覧はダッシュボードの **Sub-agent delegation** ガイダンスとは別物です。特にフィーチャー済みモデル -オーバーライドで v2 の親モデル継承ルールをバイパスできません。 +フィーチャー済みモデル一覧はダッシュボードの **Sub-agent delegation** 選択とは別物です。Codex が最初に提示する +オーバーライドを決めるだけで、モデルの選択や委任の開始は行いません。 ## モデル状態のリフレッシュ diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index cc57ae65e..59dce3ccb 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル ### 委任モデルと推論強度 -ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を保存します。この値は委任ガイドを作る設定であり、プロキシがスポーンリクエストを別モデルに再ルーティングする設定ではありません。`injectionPrompt` を指定すると内蔵ガイド文言全体を希望テキストに差し替えできます。 +ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を保存します。選択値は OpenCodex が作成する委任ガイダンスで使われ、そのガイダンスは `multiAgentGuidanceEnabled` で別に制御されます。これらはプロキシがスポーンリクエストを別モデルに再ルーティングする規則ではありません。`injectionPrompt` を指定すると内蔵ガイド文言全体を希望テキストに差し替えできます。 + +`syncCodexSubagentDefaults` を明示的に有効にすると、OpenCodex が有効な Codex ルーティングを管理している場合、次回の sync または restart で選択したモデルと effort が Codex ネイティブの `[agents]` サブエージェント既定値として適用されます。外部のユーザー管理 provider 設定は変更しません。この既定値は新しく作成される Codex タスクだけに適用され、設定自体が委任を発生させることはありません。既存のユーザー所有 `[agents]` 既定値は上書きせず保持するため、要求した既定値と実際の Codex 既定値が異なる場合があります。 `multiAgentGuidanceText` はリクエストに入ってきたツール一覧でサーフェスを判定します。Codex Desktop の WebSocket 経路(`responses_lite`)のようにツールがリクエストの `tools` 配列ではなく `additional_tools` input 項目として届く場合も認識します。 @@ -56,7 +58,7 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル - **ダッシュボード** → 最初のスタットセルで **v1**、**base**、**v2** を選択します。 - **モデル** ページ → 上部セグメントコントロールで選択します。 - 両ページとも **?** ボタンを押すとこのドキュメントに繋がるヘルプモーダルが開きます。 -- **ダッシュボード** → **サブエージェント委任** で推奨モデルとオプションの推論強度を選びます。v2 では注入ガイドが `fork_turns: "none"` スポーンを指示しモデルオーバーライドを適用させます — ただしネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/lidge-jun/opencodex/issues/92))。 +- **ダッシュボード** → **サブエージェント委任** で推奨モデルとオプションの推論強度を選びます。**ネイティブ Codex サブエージェント既定値として使用**を有効にすると、OpenCodex が有効な Codex ルーティングを管理している場合、次回の sync または restart から新しい Codex タスクにも同じ選択値が適用されます。外部のユーザー管理 provider 設定は変更しません。このトグルは委任ガイダンスのトグルとは独立しています。v2 では注入ガイドが `fork_turns: "none"` スポーンを指示しモデルオーバーライドを適用させます — ただしネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/lidge-jun/opencodex/issues/92))。 ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# 選択値を Codex ネイティブのサブエージェント既定値へ同期するよう設定(モデルが必要) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # カスタムガイドプロンプトを設定({{model}}/{{effort}}/{{roster}} プレースホルダ) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` は `model`、`effort`、`prompt`、グローバル `efforts` 段階、有効化されたネイティブ・ルーティングモデルである `available` を返します。PUT で `effort` や `prompt` を省略すると既存値を維持し、`null` なら消去します。`model` を消去すると推論強度も常に一緒に消去されます。API はグローバル Codex 段階に合う推論強度か検証し、Codex はスポーン時に対象カタログ項目がその強度をサポートするか再検証します。 +`GET /api/injection-model` は `model`、`effort`、`prompt`、`multiAgentGuidanceEnabled`、`syncCodexSubagentDefaults`、グローバル `efforts` 段階、有効化されたネイティブ・ルーティングモデルである `available` を返します。PUT は部分更新です。`effort` や `prompt` を省略すると既存値を維持し、`null` なら消去します。`syncCodexSubagentDefaults: true` には選択済みモデルが必要で、`model` を消去すると推論強度の消去とネイティブ既定値の同期解除も行われます。API はグローバル Codex 段階に合う推論強度か検証し、Codex はスポーン時に対象カタログ項目がその強度をサポートするか再検証します。 ## 推論強度 -サブエージェント推論強度は `injectionEffort` に保存され注入モデルがあるときのみ意味を持ちます。この値は注入 v2 ガイドに `reasoning_effort` 指示を追加し、親セッションの推論強度は変えません。オーバーライドが許可される fork では `spawn_agent` に渡された `reasoning_effort` を Codex がそのまま適用します。 +サブエージェント推論強度は `injectionEffort` に保存され注入モデルがあるときのみ意味を持ちます。この値は注入 v2 ガイドに `reasoning_effort` 指示を追加し、親セッションの推論強度は変えません。`syncCodexSubagentDefaults` が有効で OpenCodex が有効な Codex ルーティングを管理している場合は、次回の sync または restart から新しい Codex タスクのネイティブなサブエージェント既定 effort としても使われます。オーバーライドが許可される fork では `spawn_agent` に渡された `reasoning_effort` を Codex がそのまま適用します。 `ultra` は Codex カタログで `max` より高い段階で自動委任の意味が加わりますが、プロバイダー wire に `ultra` という値がそのまま渡るわけではありません。Codex がクライアント境界で `ultra` を `max` に変え、opencodex がプロバイダーに合う有効な値に調整します。 diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index c0ae3a6c1..16a62950a 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -26,7 +26,7 @@ bun run dev:gui | 領域 | 機能 | --- | --- | | **ダッシュボード要約** | マルチエージェントモード、オンライン状態、バージョン、稼働時間、プロバイダー数、直近30日トークン合計、アクティブプロバイダーと利用可能なネイティブ/ルーティングモデルを表示します。 | -| **サブエージェント委任** | v1 委任プロンプトに入れるネイティブまたはルーティングモデルとオプションの推論強度を選びます。スポーンごとのルーターではありません。下記の説明を確認してください。 | +| **サブエージェント委任** | OpenCodex の委任ガイダンスとオプションの Codex ネイティブサブエージェント既定値で共有するネイティブ/ルーティングモデルと任意の推論強度を選びます。スポーンごとのルーターではありません。下記を参照してください。 | | **サイドカー** | ウェブ検索モデルと強度、画像説明モデルを選択します。次回リクエストから適用されます。 | | **メンテナンス** | Codex モデルカタログを再同期し、プロジェクトローカル設定のバイパス警告を確認し、latest/preview 更新を照会またはオプションのプロキシ再起動と共にインストールします。 | | **起動安全性** | 注入された Codex ルーティングが再起動後も機能するか、サービスと launcher shim の状態、正確な修復コマンドと共に表示します。 | @@ -56,14 +56,20 @@ bun run dev:gui ## 委任セレクターとスポーンルーティングの違い ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を -保存します。v1 ターンでは opencodex が親エージェントに `spawn_agent` に渡す正確なモデルと推論 -強度を伝えるガイドを注入します。モデルを選ぶと親の現在の推論強度に関係なくこのガイドが -有効化され、モデルを消去すると保存された強度も一緒に消去されます。 +保存します。選択値は OpenCodex が作成する委任ガイダンスで使われ、そのガイダンスは +`multiAgentGuidanceEnabled` で別に制御されます。モデルを消去すると保存済み effort も消去され、 +ネイティブ既定値の同期も無効になります。 + +**Codex ネイティブサブエージェント既定値として使用**を有効にすると、OpenCodex が有効な Codex +ルーティングを管理している場合、次回の sync または restart で選択したモデルと effort がネイティブ +`[agents]` 既定値として適用されます。外部のユーザー管理 provider 設定は変更しません。この既定値は新しく作成される +Codex タスクだけに適用され、このオプション自体が委任を発生させることはありません。既存のユーザー所有 +`[agents]` 既定値は上書きせず保持するため、要求した既定値と実際の Codex 既定値が異なる場合があります。 :::caution -このセレクターは v1 互換サーフェス用の委任ガイドです。`multi_agent_v2` では現在プロキシは v1 注入 -メッセージを追加せず、生成されたすべてのサブエージェントが親セッションのモデルを継承します。プロキシが -スポーンごとにモデルを変えるルーターではありません。v1/base/v2 の正確な動作は +2 つのトグルは独立しています。OpenCodex の委任ガイダンスを無効にしてもネイティブ既定値の同期は +無効にならず、ネイティブ既定値の同期を有効にしても委任ガイダンスや委任そのものは有効になりません。 +どちらもプロキシがスポーンごとにモデルを変えるルーターではありません。v1/base/v2 の正確な動作は [サブエージェントサーフェス](/ja/guides/sub-agent-surface/)を参照してください。 ::: @@ -97,7 +103,7 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | `POST /api/sync` | 共有モデルカタログを再構築し Codex モデルキャッシュを古い状態としてマークします。 | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 自己更新作業を確認、実行、追跡します。 | | `GET` / `PUT /api/sidecar-settings` | 検索/ビジョンサイドカーモデル設定を読むか変えます。 | -| `GET` / `PUT /api/injection-model` | v1 委任ガイドモデルとオプションの強度を読むか変えます。 | +| `GET` / `PUT /api/injection-model` | 委任ガイダンスのモデル/effort、ガイダンストグル、Codex ネイティブサブエージェント既定値の同期トグルを読み取りまたは変更します。 | | `GET` / `PUT /api/v2` | サーフェスモード、Codex 機能フラグ、v2 スレッド上限を読むか変えます。 | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | プロバイダー一覧の参照、追加/差替、有効化/無効化、削除。 | | `GET /api/models` · `PUT /api/disabled-models` | ネイティブ/ルーティングモデル行を参照し共有 disabled model 一覧を更新します。 | diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index b42afd31c..e9a42e87d 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -31,12 +31,13 @@ namespaced selected id を bare id に変えます。 | `openaiProviderTierVersion?` | `2` | 移行設定 | 単一の省略可能 OpenAI projection 完了マーカー。 | | `defaultProvider` | `string` | `"openai"` | ルーティングでより良い match が見つからなかったときに使うプロバイダー。 | | `subagentModels?` | `string[]` | `gpt-5.5`、GPT-5.6 3種、`gpt-5.4-mini` | Codex サブエージェントセレクターの先頭に表示するネイティブ slug または `provider/model` id。最大 5 つで、明示的な空配列もそのまま保存します。v2 ガイダンスのロスターは、Codex の picker-visible・v2 互換・priority 順の先頭 5 件との設定済みモデルの共通部分で、正規カタログ slug と利用可能な effort ラダーを使います。除外された項目も設定には残ります。 | -| `injectionModel?` | `string` | — | 注入される multi-agent 案内(v2 surface)に入るネイティブ/ルーティングモデル。委任案内でこのモデルを `fork_turns: "none"` とともに `spawn_agent` に渡します。 | -| `injectionEffort?` | `string` | — | 希望する `spawn_agent` reasoning effort(`low` から `ultra`)。`injectionModel` と一緒に使うときだけ意味を持ちます。 | +| `injectionModel?` | `string` | — | 希望するネイティブ/ルーティングのサブエージェントモデル。別の `multiAgentGuidanceEnabled` が制御する OpenCodex 作成の v2 委任ガイダンスで使われ、`syncCodexSubagentDefaults` のオプトインにより新しいタスクの Codex ネイティブ既定値にも適用できます。 | +| `injectionEffort?` | `string` | — | 希望するサブエージェント reasoning effort(`low` から `ultra`)。`injectionModel` と一緒に使うときだけ意味を持ち、委任ガイダンスとオプションの Codex ネイティブ既定値で使われます。 | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | OpenCodex が有効な Codex ルーティングを管理している場合、選択した `injectionModel` / `injectionEffort` を次回の sync または restart で Codex ネイティブの `[agents]` サブエージェント既定値へ適用するオプトイン設定。外部のユーザー管理 provider 設定は変更しません。新しく作成される Codex タスクだけに作用し、設定自体が委任を発生させることはありません。既存のユーザー所有対象項目は競合として上書きせず保持します。`injectionModel` が必要で、モデルを消去するとこのオプトインも解除されます。`GET/PUT /api/injection-model` の部分更新フィールドとして公開されます。 | | `effortCap?` | `string` | — | reasoning effort にリクエストごとに適用する強制上限。マルチエージェント V2 専用機能で、自身のツールリストに V2 協調 surface を持つメインターンと、`x-openai-subagent: collab_spawn` ヘッダーまたは `x-codex-turn-metadata` の `"subagent_kind": "thread_spawn"` 標識が正確に一致する spawn された子ターンに適用されます(標識のついた子は自身のツール surface と無関係に適用対象です)。通常のメインターンと V1 surface メインターンは触れず、コンパクションターンは常に上限をバイパスし、`multiAgentMode: "v1"` は上限機能全体を無効化します(ダッシュボードもパネルを隠します)。`low` から `ultra` を許可し、値を上げずに下げるだけです。上限以下でモデルがサポートする最も高い段階に下げます。モデルが effort 制御を公開しない、または上限以下にサポート段階がない場合は effort フィールドを削除しプロバイダーのデフォルトを適用します。`max` と `ultra` も許可しますが、より低いランク上限を作りません(クライアントが `ultra` を `max` に変換するためリクエストは `low` から `max` で入ります)。ただし、既知のモデル effort ラダーに従い段階が下がるかフィールドが削除される可能性があります。ダッシュボードセレクターは `low` から `xhigh` まで提供します。`GET /api/effort-caps` と `PUT /api/effort-caps` で管理します。 | | `subagentEffortCap?` | `string` | — | 同じ強制上限を codex-rs 標識が正確に一致する spawn された子ターンにだけ適用します: `x-openai-subagent: collab_spawn` または `x-codex-turn-metadata` の `"subagent_kind": "thread_spawn"`。それ以外の内部サブエージェントカテゴリ(レビュー、コンパクション、メモリ整理)はこの上限にかからず、`multiAgentMode: "v1"` は機能全体を無効化します。`low` から `ultra` を許可し両方の上限が設定されていればより低い値を適用し、値を上げずに下げるだけです。上限以下でモデルがサポートする最も高い段階に下げます。モデルが effort 制御を公開しない、または上限以下にサポート段階がない場合は effort フィールドを削除しプロバイダーのデフォルトを適用します。`max` と `ultra` も許可しますが、より低いランク上限を作りません(クライアントが `ultra` を `max` に変換するためリクエストは `low` から `max` で入ります)。ただし、既知のモデル effort ラダーに従い段階が下がるかフィールドが削除される可能性があります。ダッシュボードセレクターは `low` から `xhigh` まで提供します。`GET /api/effort-caps` と `PUT /api/effort-caps` で管理します。 | | `injectionPrompt?` | `string` | — | 注入される v2 案内本文を丸ごと差し替えるカスタムテキスト。`{{model}}`、`{{effort}}`、`{{roster}}` placeholder が置換され、発火条件はそのままです。`PUT /api/injection-model` の `prompt` キーでも設定できます。 | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex が作成する multi-agent developer ガイダンスだけを制御します。未設定/`true` は v1/v2 ガイダンスを維持し、`false` は collaboration surface、`subagentModels`、routing、effort cap を変えずに両方を抑止します。`GET/PUT /api/injection-model` は有効値を返し、PUT は部分更新です。 | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex が作成する multi-agent developer ガイダンスだけを制御します。未設定/`true` は v1/v2 ガイダンスを維持し、`false` は Codex ネイティブの `[agents]` 既定値、collaboration surface、`subagentModels`、routing、effort cap を変えずに両方を抑止します。`GET/PUT /api/injection-model` は有効値を返し、PUT は部分更新です。 | | `disabledModels?` | `string[]` | — | Codex で隠すモデル。ルーティングされた `provider/model` id はカタログと `/v1/models` から除外します。`gpt-5.4` のような通常のネイティブ GPT slug はカタログ項目を `visibility: "hide"` に変え、通常の `/v1/models` 一覧から外します。ダッシュボードの Models ページでモデルごとに切り替えできます。 | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 3 段階 multi-agent surface override。`"v1"` は上流 pin より優先してすべてのモデルを v1 に、`"default"` は上流 model pin(sol/terra=v2、luna=v1)に従い、`"v2"` はすべてを v2 に強制します。ダッシュボードの Models ページまたは `ocx v2 mode` で設定します。 | | `providerContextCaps?` | `Record` | `{}` | プロバイダー別の Codex 表示 context cap。既知の context window を下げるだけです。 | diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 3dd14431c..f255a1a2c 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -114,10 +114,10 @@ Dashboard나 Models 페이지, `ocx v2 mode v1|default|v2`, 또는 세션부터 적용됩니다. :::caution -v2(`multi_agent_v2`) 서피스에서 생성된 서브에이전트는 부모 세션의 모델을 상속합니다. 대시보드의 -위임 모델/강도 선택기는 v1 프롬프트 안내이며, 프록시가 스폰마다 다른 모델로 라우팅하는 기능이 -아닙니다. 정확한 동작은 [서브에이전트 서피스](/ko/guides/sub-agent-surface/)를 -참고하세요. +v2(`multi_agent_v2`) 서피스에서는 모델을 명시하지 않은 스폰이 부모 세션의 모델을 상속할 수 있습니다. +OpenCodex 안내는 선택한 모델/강도를 명시적으로 전달하도록 Codex에 요청할 수 있고, 별도의 네이티브 기본값 +옵트인은 sync/restart 후 기본값을 제공할 수 있습니다. 어느 쪽도 프록시가 스폰마다 모델을 정하는 라우터는 +아닙니다. 정확한 동작은 [서브에이전트 서피스](/ko/guides/sub-agent-surface/)를 참고하세요. ::: ## 최상위 reasoning 단계 @@ -153,8 +153,8 @@ Codex는 선택기에 표시되는 카탈로그 항목을 `priority` 오름차 네이티브 id 또는 `provider/model` id를 최대 5개 고르면 opencodex가 선택 순서대로 priority 0-4를 부여합니다. 나머지 모델도 정확한 id로 직접 호출할 수 있습니다. -featured 모델 목록은 Dashboard의 **Sub-agent delegation** 안내와 별개입니다. 특히 featured 모델 -override로 v2의 부모 모델 상속 규칙을 우회할 수 없습니다. +featured 모델 목록은 Dashboard의 **Sub-agent delegation** 선택과 별개입니다. Codex가 먼저 보여 줄 +override를 정할 뿐, 모델을 선택하거나 위임을 시작하지는 않습니다. ## 모델 상태 새로고침 diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 733b9bd59..5623a6ae6 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 ### 위임 모델과 추론 강도 -대시보드의 **서브에이전트 위임** 선택기는 `injectionModel`과 선택 사항인 `injectionEffort`를 저장합니다. 이 값은 위임 가이드를 만드는 설정이지, 프록시가 스폰 요청을 다른 모델로 다시 라우팅하는 설정이 아닙니다. `injectionPrompt`를 지정하면 내장 가이드 문구 전체를 원하는 텍스트로 교체할 수 있습니다. +대시보드의 **서브에이전트 위임** 선택기는 `injectionModel`과 선택 사항인 `injectionEffort`를 저장합니다. 선택한 값은 OpenCodex가 작성하는 위임 가이드에 사용되며, 이 가이드는 `multiAgentGuidanceEnabled`가 별도로 제어합니다. 이 값은 프록시가 스폰 요청을 다른 모델로 다시 라우팅하는 규칙이 아닙니다. `injectionPrompt`를 지정하면 내장 가이드 문구 전체를 원하는 텍스트로 교체할 수 있습니다. + +`syncCodexSubagentDefaults`를 명시적으로 켜면 OpenCodex가 활성 Codex 라우팅을 관리하는 경우 다음 sync 또는 restart에서 선택한 모델과 강도를 Codex 네이티브 `[agents]` 서브에이전트 기본값으로 적용합니다. 외부 사용자 관리 provider 설정은 변경하지 않습니다. 이 기본값은 새로 생성되는 Codex task에만 적용되며 위임 자체를 일으키지는 않습니다. 기존 사용자 소유 `[agents]` 기본값은 덮어쓰지 않고 보존하므로, 요청한 기본값과 실제 Codex 기본값이 다를 수 있습니다. `multiAgentGuidanceText`는 요청에 들어온 툴 목록으로 서피스를 판별합니다. Codex Desktop의 WebSocket 경로(`responses_lite`)처럼 툴이 요청의 `tools` 배열 대신 `additional_tools` input 항목으로 도착하는 경우도 인식합니다. @@ -56,7 +58,7 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 - **대시보드** → 첫 번째 스탯 셀에서 **v1**, **base**, **v2**를 선택합니다. - **모델** 페이지 → 상단 세그먼트 컨트롤에서 선택합니다. - 두 페이지 모두 **?** 버튼을 누르면 이 문서로 연결되는 도움말 모달이 열립니다. -- **대시보드** → **서브에이전트 위임**에서 선호 모델과 선택 사항인 추론 강도를 고릅니다. v2에서는 주입된 가이드가 `fork_turns: "none"` 스폰을 지시해 모델 오버라이드가 적용되게 합니다 — 다만 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **대시보드** → **서브에이전트 위임**에서 선호 모델과 선택 사항인 추론 강도를 고릅니다. **Codex 네이티브 서브에이전트 기본값으로 사용**을 켜면 OpenCodex가 활성 Codex 라우팅을 관리할 때 다음 sync 또는 restart부터 새 Codex task에도 같은 선택값을 적용합니다. 외부 사용자 관리 provider 설정은 그대로 유지합니다. 이 토글은 위임 가이드 토글과 별개입니다. v2에서는 주입된 가이드가 `fork_turns: "none"` 스폰을 지시해 모델 오버라이드가 적용되게 합니다 — 다만 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# 선택값을 Codex 네이티브 서브에이전트 기본값으로 동기화하도록 설정(모델 필요) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # 커스텀 가이드 프롬프트 설정 ({{model}}/{{effort}}/{{roster}} 플레이스홀더) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model`은 `model`, `effort`, `prompt`, 전역 `efforts` 단계, 활성화된 네이티브·라우팅 모델인 `available`을 반환합니다. PUT에서 `effort`나 `prompt`를 생략하면 기존 값을 유지하고, `null`이면 지웁니다. `model`을 지우면 추론 강도도 항상 함께 지워집니다. API는 전역 Codex 단계에 맞는 추론 강도인지 검증하고, Codex는 스폰 시 대상 카탈로그 항목이 그 강도를 지원하는지 다시 검증합니다. +`GET /api/injection-model`은 `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled`, `syncCodexSubagentDefaults`, 전역 `efforts` 단계, 활성화된 네이티브·라우팅 모델인 `available`을 반환합니다. PUT은 부분 업데이트입니다. `effort`나 `prompt`를 생략하면 기존 값을 유지하고, `null`이면 지웁니다. `syncCodexSubagentDefaults: true`에는 선택된 모델이 필요하며, `model`을 지우면 추론 강도와 네이티브 기본값 동기화도 함께 꺼집니다. API는 전역 Codex 단계에 맞는 추론 강도인지 검증하고, Codex는 스폰 시 대상 카탈로그 항목이 그 강도를 지원하는지 다시 검증합니다. ## 추론 강도 -서브에이전트 추론 강도는 `injectionEffort`에 저장되며 주입 모델이 있을 때만 의미가 있습니다. 이 값은 주입된 v2 가이드에 `reasoning_effort` 지시를 추가하며, 부모 세션의 추론 강도를 바꾸지는 않습니다. 오버라이드가 허용되는 fork에서는 `spawn_agent`에 전달된 `reasoning_effort`를 Codex가 그대로 적용합니다. +서브에이전트 추론 강도는 `injectionEffort`에 저장되며 주입 모델이 있을 때만 의미가 있습니다. 이 값은 주입된 v2 가이드에 `reasoning_effort` 지시를 추가하며, 부모 세션의 추론 강도를 바꾸지는 않습니다. `syncCodexSubagentDefaults`를 켜고 OpenCodex가 활성 Codex 라우팅을 관리하는 경우에는 다음 sync 또는 restart부터 새 Codex task의 네이티브 서브에이전트 기본 강도로도 사용됩니다. 오버라이드가 허용되는 fork에서는 `spawn_agent`에 전달된 `reasoning_effort`를 Codex가 그대로 적용합니다. `ultra`는 Codex 카탈로그에서 `max`보다 높은 단계이며 자동 위임 의미가 더해지지만, 프로바이더 와이어에는 `ultra`라는 값이 그대로 전달되지 않습니다. Codex가 클라이언트 경계에서 `ultra`를 `max`로 바꾸고, opencodex가 프로바이더에 맞는 유효한 값으로 조정합니다. diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 473759ac0..b5ea350c9 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -26,7 +26,7 @@ bun run dev:gui | 영역 | 기능 | | --- | --- | | **Dashboard 요약** | Multi-agent 모드, 온라인 상태, 버전, 가동 시간, 프로바이더 수, 최근 30일 토큰 합계, 활성 프로바이더와 사용 가능한 네이티브/라우팅 모델을 보여줍니다. | -| **Sub-agent delegation** | v1 위임 프롬프트에 넣을 네이티브 또는 라우팅 모델과 선택적 reasoning 강도를 고릅니다. 스폰별 라우터는 아닙니다. 아래 설명을 확인하세요. | +| **Sub-agent delegation** | OpenCodex 위임 가이드와 선택적인 Codex 네이티브 서브에이전트 기본값이 함께 사용할 네이티브/라우팅 모델과 선택적 reasoning 강도를 고릅니다. 스폰별 라우터는 아닙니다. 아래 설명을 확인하세요. | | **사이드카** | 웹 검색 모델과 강도, 이미지 설명 모델을 선택합니다. 다음 요청부터 적용됩니다. | | **Maintenance** | Codex 모델 카탈로그를 다시 동기화하고, 프로젝트 로컬 설정의 우회 경고를 확인하고, latest/preview 업데이트를 조회하거나 선택적 프록시 재시작과 함께 설치합니다. | | **시작 안전성** | 주입된 Codex 라우팅이 재부팅 후에도 유지되는지 서비스와 launcher shim 상태, 정확한 복구 명령과 함께 표시합니다. | @@ -56,13 +56,19 @@ bun run dev:gui ## 위임 선택기와 스폰 라우팅의 차이 Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적인 `injectionEffort`를 -저장합니다. v1 턴에서는 opencodex가 부모 에이전트에게 `spawn_agent`에 넘길 정확한 모델과 reasoning -강도를 알려 주는 안내를 주입합니다. 모델을 고르면 부모의 현재 reasoning 강도와 관계없이 이 안내가 -활성화되며, 모델을 지우면 저장된 강도도 함께 지워집니다. +저장합니다. 선택한 값은 OpenCodex가 작성하는 위임 가이드에 사용되고, 이 가이드는 +`multiAgentGuidanceEnabled`가 별도로 제어합니다. 모델을 지우면 저장된 강도도 지워지고 네이티브 +기본값 동기화도 꺼집니다. + +**Codex 네이티브 서브에이전트 기본값으로 사용**을 켜면 OpenCodex가 활성 Codex 라우팅을 관리하는 +경우 다음 sync 또는 restart에서 선택한 모델과 강도를 네이티브 `[agents]` 기본값으로 적용합니다. 외부 +사용자 관리 provider 설정은 변경하지 않습니다. 이 기본값은 새로 생성되는 Codex task에만 적용되고, +이 옵션 자체가 위임을 일으키지는 않습니다. 기존 사용자 소유 `[agents]` 기본값은 덮어쓰지 않고 +보존하므로 요청한 기본값과 실제 Codex 기본값이 다를 수 있습니다. :::caution -이 선택기는 v1 호환 서피스용 위임 안내입니다. `multi_agent_v2`에서는 현재 프록시가 v1 주입 -메시지를 덧붙이지 않으며, 생성된 모든 서브에이전트가 부모 세션의 모델을 상속합니다. 프록시가 +두 토글은 서로 독립적입니다. OpenCodex 위임 가이드를 꺼도 네이티브 기본값 동기화는 꺼지지 않고, +네이티브 기본값 동기화를 켜도 위임 가이드를 켜거나 위임을 발생시키지 않습니다. 어느 쪽도 프록시가 스폰마다 모델을 바꾸는 라우터가 아닙니다. v1/base/v2의 정확한 동작은 [서브에이전트 서피스](/ko/guides/sub-agent-surface/)를 참고하세요. ::: @@ -99,7 +105,7 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | `POST /api/sync` | 공유 모델 카탈로그를 다시 만들고 Codex 모델 캐시를 오래된 상태로 표시합니다. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 자체 업데이트 작업을 확인, 실행, 추적합니다. | | `GET` / `PUT /api/sidecar-settings` | 검색/비전 사이드카 모델 설정을 읽거나 바꿉니다. | -| `GET` / `PUT /api/injection-model` | v1 위임 안내 모델과 선택적 강도를 읽거나 바꿉니다. | +| `GET` / `PUT /api/injection-model` | 위임 가이드의 모델/강도, 가이드 토글, Codex 네이티브 서브에이전트 기본값 동기화 토글을 읽거나 바꿉니다. | | `GET` / `PUT /api/v2` | 서피스 모드, Codex 기능 플래그, v2 thread 상한을 읽거나 바꿉니다. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 프로바이더 목록 조회, 추가/교체, 활성화/비활성화, 제거. | | `GET /api/models` · `PUT /api/disabled-models` | 네이티브/라우팅 모델 행을 조회하고 공용 disabled model 목록을 갱신합니다. | diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index ba7b7c5ec..f56e6288d 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -32,12 +32,13 @@ namespaced selected id를 bare id로 바꿉니다. | `openaiProviderTierVersion?` | `2` | migration 설정 | 단일 옵션형 OpenAI projection 완료 마커. | | `defaultProvider` | `string` | `"openai"` | 라우팅에서 더 나은 match를 찾지 못했을 때 쓸 프로바이더. | | `subagentModels?` | `string[]` | `gpt-5.5`, GPT-5.6 3종, `gpt-5.4-mini` | Codex 서브에이전트 선택기 앞쪽에 표시할 네이티브 slug 또는 `provider/model` id. 최대 5개이며, 명시적인 빈 배열도 그대로 보존합니다. v2 가이던스 로스터는 설정 목록과 Codex의 picker-visible·v2 호환·priority 순 상위 5개의 교집합이며 정규 카탈로그 slug와 사용 가능한 effort 사다리를 씁니다. 제외된 항목도 설정에는 남습니다. | -| `injectionModel?` | `string` | — | 주입되는 multi-agent 안내(v2 표면)에 들어갈 네이티브/라우팅 모델. 위임 안내에서 이 모델을 `fork_turns: "none"`과 함께 `spawn_agent`에 넘기게 합니다. | -| `injectionEffort?` | `string` | — | 선호하는 `spawn_agent` reasoning effort(`low`부터 `ultra`). `injectionModel`과 함께 쓸 때만 의미가 있습니다. | +| `injectionModel?` | `string` | — | 선호하는 네이티브/라우팅 서브에이전트 모델. 별도 `multiAgentGuidanceEnabled`가 제어하는 OpenCodex 작성 v2 위임 가이드에서 사용하며, `syncCodexSubagentDefaults`를 선택하면 새 task의 Codex 네이티브 기본값으로도 적용할 수 있습니다. | +| `injectionEffort?` | `string` | — | 선호하는 서브에이전트 reasoning effort(`low`부터 `ultra`). `injectionModel`과 함께 쓸 때만 의미가 있으며, 위임 가이드와 선택적인 Codex 네이티브 기본값에서 사용합니다. | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | OpenCodex가 활성 Codex 라우팅을 관리할 때 선택한 `injectionModel`/`injectionEffort`를 다음 sync 또는 restart에서 Codex 네이티브 `[agents]` 서브에이전트 기본값으로 적용하는 선택 기능. 외부 사용자 관리 provider 설정은 변경하지 않습니다. 새로 생성되는 Codex task에만 적용하고 위임 자체를 일으키지는 않습니다. 기존 사용자 소유 대상 항목은 충돌로 취급해 덮어쓰지 않고 보존합니다. `injectionModel`이 필요하며 모델을 지우면 이 옵션도 꺼집니다. `GET/PUT /api/injection-model`의 부분 업데이트 필드로 제공됩니다. | | `effortCap?` | `string` | — | reasoning effort에 요청별로 적용하는 강제 상한입니다. 멀티 에이전트 V2 전용 기능으로, 자체 도구 목록에 V2 협업 표면이 있는 메인 턴과, `x-openai-subagent: collab_spawn` 헤더 또는 `x-codex-turn-metadata`의 `"subagent_kind": "thread_spawn"` 표식이 정확히 일치하는 스폰된 자식 턴에 적용됩니다(표식이 붙은 자식은 자체 도구 표면과 무관하게 적용 대상입니다). 일반 메인 턴과 V1 표면 메인 턴은 건드리지 않고, 컴팩션 턴은 항상 상한을 우회하며, `multiAgentMode: "v1"`은 상한 기능 전체를 비활성화합니다(대시보드도 패널을 숨깁니다). `low`부터 `ultra`까지 허용하며 값을 높이지 않고 낮추기만 합니다. 상한 이하에서 모델이 지원하는 가장 높은 단계로 내립니다. 모델이 effort 제어를 노출하지 않거나 상한 이하에 지원 단계가 없으면 effort 필드를 제거하고 프로바이더 기본값을 적용합니다. `max`와 `ultra`도 허용하지만 더 낮은 rank 상한을 만들지는 않습니다(클라이언트가 `ultra`를 `max`로 변환하므로 요청은 `low`부터 `max`로 들어옵니다). 단, 알려진 모델 effort 사다리에 따라 단계가 내려가거나 필드가 제거될 수 있습니다. 대시보드 선택기는 `low`부터 `xhigh`까지 제공합니다. `GET /api/effort-caps`와 `PUT /api/effort-caps`로 관리합니다. | | `subagentEffortCap?` | `string` | — | 같은 강제 상한을 codex-rs 표식이 정확히 일치하는 스폰된 자식 턴에만 적용합니다: `x-openai-subagent: collab_spawn` 또는 `x-codex-turn-metadata`의 `"subagent_kind": "thread_spawn"`. 그 외 내부 서브에이전트 범주(리뷰, 컴팩션, 메모리 정리)는 이 상한에 걸리지 않으며, `multiAgentMode: "v1"`은 기능 전체를 비활성화합니다. `low`부터 `ultra`까지 허용하며 두 상한이 모두 설정되면 더 낮은 값이 적용되고, 값을 높이지 않고 낮추기만 합니다. 상한 이하에서 모델이 지원하는 가장 높은 단계로 내립니다. 모델이 effort 제어를 노출하지 않거나 상한 이하에 지원 단계가 없으면 effort 필드를 제거하고 프로바이더 기본값을 적용합니다. `max`와 `ultra`도 허용하지만 더 낮은 rank 상한을 만들지는 않습니다(클라이언트가 `ultra`를 `max`로 변환하므로 요청은 `low`부터 `max`로 들어옵니다). 단, 알려진 모델 effort 사다리에 따라 단계가 내려가거나 필드가 제거될 수 있습니다. 대시보드 선택기는 `low`부터 `xhigh`까지 제공합니다. `GET /api/effort-caps`와 `PUT /api/effort-caps`로 관리합니다. | | `injectionPrompt?` | `string` | — | 주입되는 v2 안내 본문을 통째로 교체하는 커스텀 텍스트. `{{model}}`, `{{effort}}`, `{{roster}}` 플레이스홀더가 치환되며 발화 조건은 그대로입니다. `PUT /api/injection-model`의 `prompt` 키로도 설정할 수 있습니다. | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex가 작성하는 multi-agent developer 가이던스만 제어합니다. 미설정/`true`는 v1/v2 가이던스를 유지하고, `false`는 collaboration surface, `subagentModels`, routing, effort cap을 바꾸지 않고 둘 다 억제합니다. `GET/PUT /api/injection-model`은 유효값을 제공하며 PUT은 부분 업데이트입니다. | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex가 작성하는 multi-agent developer 가이던스만 제어합니다. 미설정/`true`는 v1/v2 가이던스를 유지하고, `false`는 Codex 네이티브 `[agents]` 기본값, collaboration surface, `subagentModels`, routing, effort cap을 바꾸지 않고 둘 다 억제합니다. `GET/PUT /api/injection-model`은 유효값을 제공하며 PUT은 부분 업데이트입니다. | | `disabledModels?` | `string[]` | — | Codex에서 숨길 모델. 라우팅된 `provider/model` id는 카탈로그와 `/v1/models`에서 제외합니다. `gpt-5.4` 같은 일반 네이티브 GPT slug는 카탈로그 항목을 `visibility: "hide"`로 바꾸고 일반 `/v1/models` 목록에서 뺍니다. 대시보드 Models 페이지에서 모델별로 전환할 수 있습니다. | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 3단계 multi-agent surface override. `"v1"`은 업스트림 pin보다 우선해 모든 모델을 v1로, `"default"`는 업스트림 model pin(sol/terra=v2, luna=v1)을 따르고, `"v2"`는 모두 v2로 강제합니다. 대시보드 Models 페이지나 `ocx v2 mode`에서 설정합니다. | | `providerContextCaps?` | `Record` | `{}` | 프로바이더별 Codex 표시 context cap. 알려진 context window를 낮추기만 합니다. | diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 66ceca796..c25f59525 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -36,12 +36,13 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `openaiProviderTierVersion?` | `2` | set by migration | Marks the single option-aware OpenAI projection as complete. | | `defaultProvider` | `string` | `"openai"` | Provider used when routing finds no better match. | | `subagentModels?` | `string[]` | `gpt-5.5`, GPT-5.6 trio, `gpt-5.4-mini` | Up to 5 native slugs or `provider/model` ids featured first in Codex's subagent picker. The v2 guidance roster is the configured intersection of Codex's picker-visible, v2-compatible, priority-sorted first five, using canonical catalog slugs and available effort ladders; excluded entries remain configured. An explicit empty list is preserved. | -| `injectionModel?` | `string` | — | Preferred native or routed model named in the injected multi-agent guidance (v2 surface); delegation is told to pass this exact model to `spawn_agent` with `fork_turns: "none"`. | -| `injectionEffort?` | `string` | — | Preferred `spawn_agent` reasoning effort (`low` through `ultra`). Only meaningful with `injectionModel`. | +| `injectionModel?` | `string` | — | Preferred native or routed sub-agent model. OpenCodex-authored v2 guidance tells delegation to pass this exact model to `spawn_agent` with `fork_turns: "none"`; the separate `syncCodexSubagentDefaults` opt-in can also apply it as Codex's native default for new tasks. | +| `injectionEffort?` | `string` | — | Preferred sub-agent reasoning effort (`low` through `ultra`). Only meaningful with `injectionModel`; used by delegation guidance and, when opted in, the native Codex subagent default. | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | Opt in to applying `injectionModel` and optional `injectionEffort` as native Codex `[agents]` defaults during sync/restart when OpenCodex manages the active Codex routing. External user-managed provider configs remain untouched. The defaults affect newly created Codex tasks and do not themselves cause delegation. Unmarked user-owned target entries are preserved as conflicts instead of being overwritten and remain authoritative. Requires `injectionModel`; clearing the model clears this opt-in. Exposed by `GET/PUT /api/injection-model` as a partial-update field. | | `effortCap?` | `string` | — | Hard per-request ceiling for reasoning effort. A multi-agent V2 feature: it applies to main turns whose own tool list carries the V2 collab surface, plus spawned-child turns marked with exactly `x-openai-subagent: collab_spawn` or `"subagent_kind": "thread_spawn"` in `x-codex-turn-metadata` (marked children qualify regardless of their own tool surface). Plain and V1-surface main turns are untouched, compaction turns always bypass caps, and `multiAgentMode: "v1"` disables caps entirely (the Dashboard hides the panel). Accepts `low` through `ultra`; caps only lower, never raise. Snaps down to the highest supported rung at or below the cap. If the model exposes no effort control, or no supported rung fits under the cap, the effort field is removed and the provider default applies. `max` and `ultra` are accepted but do not impose a lower rank ceiling (requests arrive as `low` through `max` after the client's `ultra` → `max` conversion), though known model ladders may still cause snap-down or strip. The Dashboard picker offers `low` through `xhigh`. Managed via `GET /api/effort-caps` and `PUT /api/effort-caps`. | | `subagentEffortCap?` | `string` | — | The same hard ceiling, applied only to spawned-child turns identified by codex-rs markers matched exactly: `x-openai-subagent: collab_spawn` or `"subagent_kind": "thread_spawn"` in `x-codex-turn-metadata`. Other internal sub-agent categories (review, compaction, memory consolidation) never trip this cap, and `multiAgentMode: "v1"` disables it entirely. Accepts `low` through `ultra`; when both caps are set, the lower one wins, and caps only lower, never raise. Snaps down to the highest supported rung at or below the cap. If the model exposes no effort control, or no supported rung fits under the cap, the effort field is removed and the provider default applies. `max` and `ultra` are accepted but do not impose a lower rank ceiling (requests arrive as `low` through `max` after the client's `ultra` → `max` conversion), though known model ladders may still cause snap-down or strip. The Dashboard picker offers `low` through `xhigh`. Managed via `GET /api/effort-caps` and `PUT /api/effort-caps`. | | `injectionPrompt?` | `string` | — | Custom override for the injected v2 guidance body. Replaces the built-in text; `{{model}}`, `{{effort}}`, and `{{roster}}` placeholders are substituted. Firing gates are unchanged. Settable via `PUT /api/injection-model` (`prompt` key). | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Controls only OpenCodex-authored multi-agent developer guidance. Unset/`true` preserves v1/v2 guidance; `false` suppresses both without changing the collaboration surface, `subagentModels`, routing, or effort caps. `GET/PUT /api/injection-model` exposes the effective value; PUT is a partial update. | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Controls only OpenCodex-authored multi-agent developer guidance. Unset/`true` preserves v1/v2 guidance; `false` suppresses both without changing native Codex `[agents]` defaults, the collaboration surface, `subagentModels`, routing, or effort caps. `GET/PUT /api/injection-model` exposes the effective value; PUT is a partial update. | | `disabledModels?` | `string[]` | — | Models **hidden** from Codex's catalog and `/v1/models` (not blocked at the proxy). Routed `provider/model` ids are excluded from listings; bare native GPT slugs (e.g. `gpt-5.4`) flip their catalog entry to `visibility: "hide"` and drop from the bare `/v1/models` list. Exact model ids remain directly callable. Toggleable per model from the dashboard Models page. | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 3-state multi-agent surface override. `"v1"` forces all models to the v1 surface (overrides upstream pins); `"default"` respects upstream model pins (sol/terra=v2, luna=v1); `"v2"` forces all models to v2. Settable from the dashboard Models page or `ocx v2 mode`. | | `providerContextCaps?` | `Record` | `{}` | Per-provider Codex-visible context caps. A cap only lowers known context windows. | diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index 7ac97ef49..f9345e147 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -142,10 +142,11 @@ opencodex добавляет трёхпозиционное переопреде запросом `PUT /api/v2` с `{ "multiAgentMode": "v1" }`. Изменения применяются к новым сессиям Codex. :::caution -На поверхности v2 (`multi_agent_v2`) порождённые подагенты наследуют модель родительской сессии. -Селектор модели/уровня делегирования в дашборде — это подсказка для промпта v1, а не -кросс-модельный маршрутизатор на стороне прокси для каждого порождения. Каноничное поведение -описано в разделе [Поверхность подагентов](/ru/guides/sub-agent-surface/). +На поверхности v2 (`multi_agent_v2`) подагент без явно заданной модели может унаследовать модель +родительской сессии. Подсказка OpenCodex может попросить Codex явно передать выбранные модель и +уровень, а отдельная настройка нативных значений по умолчанию применяет их после sync/restart. +Ни один механизм не является маршрутизатором на стороне прокси для каждого порождения. Каноничное +поведение описано в разделе [Поверхность подагентов](/ru/guides/sub-agent-surface/). ::: ## Верхние уровни рассуждений @@ -185,9 +186,9 @@ id `provider/model` через `subagentModels` или страницу Subagent присваивает этим записям приоритеты 0–4 в выбранном порядке. Остальные модели по-прежнему можно вызывать по точному id. -Список избранных моделей не связан с подсказкой **Sub-agent delegation** на дашборде. В -частности, переопределения избранных моделей не обходят правило наследования родительской модели -в v2. +Список избранных моделей отделён от выбора **Sub-agent delegation** на дашборде. Он лишь определяет, +какие переопределения Codex показывает первыми, и сам по себе не выбирает модель и не запускает +делегирование. ## Обновление состояния моделей diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index ef3609caa..27510501d 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ opencodex позволяет выбрать поверхность мульти ### Модель делегирования и уровень рассуждений -Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, `injectionEffort`. Это настройки инструкции по делегированию, а не маршрутизатор порождений на стороне прокси. Необязательный `injectionPrompt` полностью заменяет встроенный текст инструкции. +Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, `injectionEffort`. Выбранные значения используются в добавляемом OpenCodex руководстве по делегированию, которое отдельно управляется полем `multiAgentGuidanceEnabled`. Они не задают маршрутизацию порождений на стороне прокси. Необязательный `injectionPrompt` полностью заменяет встроенный текст инструкции. + +Если явно включить `syncCodexSubagentDefaults`, следующая синхронизация или перезапуск применит выбранные модель и уровень как нативные значения по умолчанию для подагентов Codex в `[agents]`, когда активной маршрутизацией Codex управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Эти значения действуют только для вновь создаваемых задач Codex и сами по себе не запускают делегирование. Существующие пользовательские значения `[agents]` не перезаписываются, а сохраняются, поэтому запрошенные и фактические значения Codex по умолчанию могут различаться. `multiAgentGuidanceText` определяет поверхность по инструментам запроса — включая WebSocket-путь Codex Desktop (`responses_lite`), где инструменты приходят внутри входного элемента `additional_tools`, а не в массиве `tools` запроса. @@ -56,7 +58,7 @@ opencodex позволяет выбрать поверхность мульти - **Dashboard** → первая ячейка статистики: нажмите **v1**, **base** или **v2**. - Страница **Models** → сегментированный переключатель в верхнем ряду. - На обеих страницах есть кнопка **?**, открывающая модальное окно справки со ссылкой на эту страницу. -- **Dashboard** → **Sub-agent delegation**: выберите предпочтительную модель и, при желании, уровень рассуждений. На v2 внедрённая инструкция велит агенту порождать с `fork_turns: "none"`, чтобы переопределение модели сработало, — хотя для потомков native→routed тело задачи сейчас может приходить зашифрованным ([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **Dashboard** → **Sub-agent delegation**: выберите предпочтительную модель и, при желании, уровень рассуждений. Включите **Использовать как нативные значения по умолчанию для подагентов Codex**, чтобы после следующей синхронизации или перезапуска применять тот же выбор к новым задачам Codex, когда активной маршрутизацией управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Этот переключатель не зависит от переключателя руководства по делегированию. На v2 внедрённая инструкция велит агенту порождать с `fork_turns: "none"`, чтобы переопределение модели сработало, — хотя для потомков native→routed тело задачи сейчас может приходить зашифрованным ([#92](https://github.com/lidge-jun/opencodex/issues/92)). ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# Синхронизировать выбранные значения с нативными значениями подагентов Codex по умолчанию (нужна модель) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # Задать пользовательский промпт инструкции (плейсхолдеры {{model}}/{{effort}}/{{roster}}) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` возвращает `model`, `effort`, `prompt`, глобальную шкалу `efforts` и включённые нативные/маршрутизируемые модели в `available`. В PUT пропуск `effort` или `prompt` сохраняет текущее значение, `null` очищает его, а очистка `model` всегда очищает и уровень. API валидирует уровень по глобальной шкале Codex; Codex дополнительно валидирует уровень порождения по целевой записи каталога. +`GET /api/injection-model` возвращает `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled`, `syncCodexSubagentDefaults`, глобальную шкалу `efforts` и включённые нативные/маршрутизируемые модели в `available`. PUT является частичным обновлением: пропуск `effort` или `prompt` сохраняет текущее значение, а `null` очищает его. Для `syncCodexSubagentDefaults: true` требуется выбранная модель; очистка `model` всегда очищает уровень и отключает синхронизацию нативных значений по умолчанию. API валидирует уровень по глобальной шкале Codex; Codex дополнительно валидирует уровень порождения по целевой записи каталога. ## Уровень рассуждений -Необязательная настройка уровня рассуждений подагента хранится как `injectionEffort` и имеет смысл только вместе с моделью внедрения. Она добавляет указание `reasoning_effort` во внедряемую инструкцию v2 и не меняет уровень рассуждений родительской сессии. При любом форке, допускающем переопределения, Codex напрямую применяет `reasoning_effort`, переданный в `spawn_agent`. +Необязательная настройка уровня рассуждений подагента хранится как `injectionEffort` и имеет смысл только вместе с моделью внедрения. Она добавляет указание `reasoning_effort` во внедряемую инструкцию v2 и не меняет уровень рассуждений родительской сессии. При включённом `syncCodexSubagentDefaults` и маршрутизации под управлением OpenCodex после следующей синхронизации или перезапуска она также становится нативным уровнем подагента по умолчанию для новых задач Codex. При любом форке, допускающем переопределения, Codex напрямую применяет `reasoning_effort`, переданный в `spawn_agent`. `ultra` стоит выше `max` в каталоге Codex и добавляет семантику автоматического делегирования, но никогда не доходит до провайдера как буквальное значение в запросе. Codex преобразует `ultra` в `max` на границе клиента. Затем opencodex сохраняет запрос к провайдеру валидным: diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 45b433513..c9fde72ee 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -26,7 +26,7 @@ bun run dev:gui | Раздел | Что делает | | --- | --- | | **Сводка Dashboard** | Мультиагентный режим, состояние онлайн, версия, время работы, число провайдеров, сумма токенов за 30 дней, активные провайдеры и доступные нативные/маршрутизируемые модели. | -| **Sub-agent delegation** | Выбор нативной или маршрутизируемой модели для инструкции и необязательного уровня рассуждений для v1-промптов делегирования. Это не маршрутизатор для каждого порождения; см. ниже. | +| **Sub-agent delegation** | Выбор нативной/маршрутизируемой модели и необязательного уровня рассуждений, общих для руководства OpenCodex по делегированию и опциональных нативных значений подагентов Codex по умолчанию. Это не маршрутизатор отдельных порождений; см. ниже. | | **Сайдкары** | Выбор модели и уровня рассуждений для веб-поиска, а также модели описания изображений. Изменения применяются со следующего запроса. | | **Maintenance** | Пересинхронизация каталога моделей Codex, просмотр предупреждений об обходе через проектную локальную конфигурацию, проверка последнего или предварительного выпуска и запуск обновления с необязательным перезапуском прокси. | | **Безопасность запуска** | Показывает, сохранит ли внедрённая маршрутизация Codex работоспособность после перезагрузки, отдельно отображая службу, launcher shim и точные команды исправления. | @@ -57,15 +57,22 @@ bun run dev:gui ## Селектор делегирования и маршрутизация порождений Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, -`injectionEffort`. В ходах v1 opencodex внедряет инструкцию, сообщающую родительскому агенту, какие -именно модель и уровень рассуждений передать в `spawn_agent`. Выбор модели включает эту инструкцию -при любом уровне рассуждений родителя; очистка модели очищает и сохранённый уровень. +`injectionEffort`. Выбранные значения используются в добавляемом OpenCodex руководстве по +делегированию, которое отдельно управляется полем `multiAgentGuidanceEnabled`. Очистка модели также +очищает сохранённый уровень и отключает синхронизацию нативных значений по умолчанию. + +Если включить **Использовать как нативные значения подагентов Codex по умолчанию**, следующая +синхронизация или перезапуск применит выбранные модель и уровень как нативные значения `[agents]`, +когда активной маршрутизацией Codex управляет OpenCodex. Внешняя пользовательская конфигурация +провайдера остаётся неизменной. Они действуют только для вновь создаваемых задач Codex, и эта настройка сама по себе не запускает +делегирование. Существующие пользовательские значения `[agents]` не перезаписываются, а сохраняются, +поэтому запрошенные и фактические значения Codex по умолчанию могут различаться. :::caution -Этот селектор — инструкция делегирования для поверхности совместимости v1. На `multi_agent_v2` -текущий прокси не добавляет v1-сообщение внедрения, и каждый порождённый подагент наследует модель -родительской сессии. Это не кросс-модельный маршрутизатор на стороне прокси. Каноничное поведение -v1/base/v2 описано на странице +Эти два переключателя независимы. Отключение руководства OpenCodex по делегированию не отключает +синхронизацию нативных значений по умолчанию; включение синхронизации не включает руководство и не +вызывает делегирование. Ни одна из настроек не является кросс-модельным маршрутизатором отдельных +порождений на стороне прокси. Каноничное поведение v1/base/v2 описано на странице [Поверхность подагентов](/ru/guides/sub-agent-surface/). ::: @@ -102,7 +109,7 @@ GUI — это тонкий клиент поверх JSON-API управлен | `POST /api/sync` | Пересборка общего каталога моделей и инвалидация кэша моделей Codex. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Проверка, запуск и мониторинг задач самообновления. | | `GET` / `PUT /api/sidecar-settings` | Чтение или настройка моделей сайдкаров поиска/vision. | -| `GET` / `PUT /api/injection-model` | Чтение или настройка модели v1-инструкции делегирования и необязательного уровня. | +| `GET` / `PUT /api/injection-model` | Чтение или настройка модели/уровня руководства по делегированию, его переключателя и переключателя синхронизации нативных значений подагентов Codex по умолчанию. | | `GET` / `PUT /api/v2` | Чтение или настройка режима поверхности, фиче-флага Codex и лимита потоков v2. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | Список, добавление/замена, включение/отключение или удаление провайдеров. | | `GET /api/models` · `PUT /api/disabled-models` | Список строк нативных/маршрутизируемых моделей и обновление общего набора отключённых моделей. | diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 7e5457c12..4d426322f 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -36,12 +36,13 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `openaiProviderTierVersion?` | `2` | задаётся миграцией | Отмечает, что единая проекция OpenAI с учётом опций завершена. | | `defaultProvider` | `string` | `"openai"` | Провайдер, используемый, когда маршрутизация не находит лучшего совпадения. | | `subagentModels?` | `string[]` | `gpt-5.5`, тройка GPT-5.6, `gpt-5.4-mini` | До 5 нативных slug или id вида `provider/model`, отображаемых первыми в селекторе подагентов Codex. Список в руководстве v2 — пересечение настроенных моделей с первыми пятью видимыми в селекторе, совместимыми с v2 и отсортированными по priority записями Codex; используются канонические slug каталога и доступные уровни effort, а исключённые элементы остаются в конфигурации. Явно заданный пустой список сохраняется. | -| `injectionModel?` | `string` | — | Предпочитаемая нативная или маршрутизируемая модель, указываемая во внедряемом multi-agent-руководстве (поверхность v2); руководству по делегированию предписывается передавать именно эту модель в `spawn_agent` с `fork_turns: "none"`. | -| `injectionEffort?` | `string` | — | Предпочитаемый уровень рассуждений для `spawn_agent` (от `low` до `ultra`). Имеет смысл только вместе с `injectionModel`. | +| `injectionModel?` | `string` | — | Предпочитаемая нативная или маршрутизируемая модель подагента. Она используется в v2-руководстве по делегированию от OpenCodex, отдельно управляемом `multiAgentGuidanceEnabled`, а опциональное `syncCodexSubagentDefaults` также может сделать её нативным значением Codex по умолчанию для новых задач. | +| `injectionEffort?` | `string` | — | Предпочитаемый уровень рассуждений подагента (от `low` до `ultra`). Имеет смысл только вместе с `injectionModel`; используется руководством по делегированию и, при явном согласии, нативным значением Codex по умолчанию. | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | Явное согласие применить выбранные `injectionModel` / `injectionEffort` как нативные значения подагентов Codex по умолчанию в `[agents]` при следующей синхронизации или перезапуске, когда активной маршрутизацией Codex управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Они влияют только на вновь создаваемые задачи Codex и сами по себе не запускают делегирование. Существующие пользовательские целевые записи сохраняются как конфликты, а не перезаписываются. Требует `injectionModel`; очистка модели отключает это поле. Доступно как поле частичного обновления `GET/PUT /api/injection-model`. | | `effortCap?` | `string` | — | Жёсткий потолок уровня рассуждений на каждый запрос. Функция multi-agent V2: применяется к основным ходам, чей собственный список инструментов несёт поверхность совместной работы V2, а также к ходам порождённых потомков, помеченным ровно `x-openai-subagent: collab_spawn` или `"subagent_kind": "thread_spawn"` в `x-codex-turn-metadata` (помеченные потомки подпадают под потолок независимо от их собственной поверхности инструментов). Обычные основные ходы и основные ходы с поверхностью V1 не затрагиваются, ходы compaction всегда обходят потолки, а `multiAgentMode: "v1"` полностью отключает потолки (дашборд скрывает панель). Принимает значения от `low` до `ultra`; потолки только понижают уровень, никогда не повышают. Уровень опускается до самой высокой поддерживаемой ступени, не превышающей потолок. Если модель не предоставляет управление уровнем рассуждений или под потолком нет ни одной поддерживаемой ступени, поле уровня удаляется и действует значение провайдера по умолчанию. `max` и `ultra` принимаются, но не задают потолок более низкого ранга (после клиентского преобразования `ultra` → `max` запросы приходят со значениями от `low` до `max`), хотя известные лестницы моделей всё же могут вызвать понижение ступени или удаление поля. Селектор в дашборде предлагает значения от `low` до `xhigh`. Управляется через `GET /api/effort-caps` и `PUT /api/effort-caps`. | | `subagentEffortCap?` | `string` | — | Тот же жёсткий потолок, применяемый только к ходам порождённых потомков, идентифицированным маркерами codex-rs с точным совпадением: `x-openai-subagent: collab_spawn` или `"subagent_kind": "thread_spawn"` в `x-codex-turn-metadata`. Другие внутренние категории подагентов (ревью, compaction, консолидация памяти) никогда не подпадают под этот потолок, а `multiAgentMode: "v1"` полностью его отключает. Принимает значения от `low` до `ultra`; когда заданы оба потолка, действует более низкий, и потолки только понижают уровень, никогда не повышают. Уровень опускается до самой высокой поддерживаемой ступени, не превышающей потолок. Если модель не предоставляет управление уровнем рассуждений или под потолком нет ни одной поддерживаемой ступени, поле уровня удаляется и действует значение провайдера по умолчанию. `max` и `ultra` принимаются, но не задают потолок более низкого ранга (после клиентского преобразования `ultra` → `max` запросы приходят со значениями от `low` до `max`), хотя известные лестницы моделей всё же могут вызвать понижение ступени или удаление поля. Селектор в дашборде предлагает значения от `low` до `xhigh`. Управляется через `GET /api/effort-caps` и `PUT /api/effort-caps`. | | `injectionPrompt?` | `string` | — | Пользовательская замена текста внедряемого v2-руководства. Заменяет встроенный текст; плейсхолдеры `{{model}}`, `{{effort}}` и `{{roster}}` подставляются. Условия срабатывания не меняются. Настраивается через `PUT /api/injection-model` (ключ `prompt`). | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Управляет только developer-руководством multi-agent, добавляемым OpenCodex. Отсутствующее значение/`true` сохраняет руководство v1/v2; `false` подавляет оба варианта, не меняя поверхность совместной работы, `subagentModels`, маршрутизацию и пределы effort. `GET/PUT /api/injection-model` возвращает эффективное значение; PUT является частичным обновлением. | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Управляет только developer-руководством multi-agent, добавляемым OpenCodex. Отсутствующее значение/`true` сохраняет руководство v1/v2; `false` подавляет оба варианта, не меняя нативные значения `[agents]` по умолчанию, поверхность совместной работы, `subagentModels`, маршрутизацию и пределы effort. `GET/PUT /api/injection-model` возвращает эффективное значение; PUT является частичным обновлением. | | `disabledModels?` | `string[]` | — | Модели, скрываемые от Codex. Маршрутизируемые id `provider/model` исключаются из каталога и `/v1/models`; «голые» нативные GPT-slug (например, `gpt-5.4`) переводят свою запись каталога в `visibility: "hide"` и исчезают из «голого» списка `/v1/models`. Переключается для каждой модели на странице Models дашборда. | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | Трёхпозиционное переопределение multi-agent-поверхности. `"v1"` принудительно переводит все модели на поверхность v1 (перекрывает вышестоящие привязки); `"default"` учитывает вышестоящие привязки моделей (sol/terra=v2, luna=v1); `"v2"` принудительно переводит все модели на v2. Настраивается на странице Models дашборда или через `ocx v2 mode`. | | `providerContextCaps?` | `Record` | `{}` | Видимые Codex лимиты контекста по провайдерам. Лимит только понижает известные контекстные окна. | diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 15f4c90e3..9fda55528 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -108,8 +108,9 @@ opencodex 为每个目录条目的 `multi_agent_version` 提供三态 override `{ "multiAgentMode": "v1" }` 的 `PUT /api/v2` 设置该模式。变更从新的 Codex session 开始生效。 :::caution -在 v2(`multi_agent_v2`)界面中,生成的子代理会继承父 session 的模型。仪表盘中的委派模型/ -reasoning 选择器只是 v1 prompt 指引,并不是由代理在每次生成时执行跨模型路由。权威说明见 +在 v2(`multi_agent_v2`)界面中,未显式指定模型的子代理可能继承父 session 的模型。 +OpenCodex 指引可以要求 Codex 显式传递所选模型/reasoning,独立的原生默认值开关则可在 +sync/restart 后提供默认值。两者都不是由代理在每次生成时执行的代理侧路由。权威说明见 [子代理界面](/zh-cn/guides/sub-agent-surface/)。 ::: @@ -145,8 +146,8 @@ override。你可以通过 `subagentModels` 或仪表盘的 Subagents 页面选 `provider/model` id;opencodex 会按所选顺序赋予它们 0-4 的 priority。其他模型仍可通过精确 id 直接调用。 -置顶模型列表与 Dashboard 的 **Sub-agent delegation** 指引相互独立。尤其需要注意,置顶模型 -override 不能绕过 v2 的父模型继承规则。 +置顶模型列表与 Dashboard 的 **Sub-agent delegation** 选择相互独立。它只决定 Codex 优先显示 +哪些 override,本身不会选择模型或触发委派。 ## 刷新模型状态 diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index e47bd7064..ad2828a28 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 ### 委托模型与推理强度 -仪表盘中的 **子代理委托** 选择器会保存 `injectionModel`,以及可选的 `injectionEffort`。它们用于生成委托指引,并不是由 proxy 执行的子代理路由规则。设置 `injectionPrompt` 可以把内置指引文本整体替换为自定义内容。 +仪表盘中的 **子代理委托** 选择器会保存 `injectionModel`,以及可选的 `injectionEffort`。所选值会用于由 OpenCodex 编写的委派指引,而该指引由 `multiAgentGuidanceEnabled` 单独控制。它们并不是由 proxy 执行的子代理路由规则。设置 `injectionPrompt` 可以把内置指引文本整体替换为自定义内容。 + +显式启用 `syncCodexSubagentDefaults` 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会把所选模型和 effort 应用为 Codex 原生 `[agents]` 子代理默认值。外部用户管理的 provider 配置不会被修改。这些默认值只影响新建的 Codex 任务,该设置本身不会触发委派。已有的用户自有 `[agents]` 默认值会保留而不会被覆盖,因此请求的默认值可能与 Codex 实际使用的默认值不同。 `multiAgentGuidanceText` 根据请求中的工具列表判断当前界面 —— 包括 Codex Desktop 的 WebSocket 路径(`responses_lite`),此时工具位于 `additional_tools` input 项中而不是请求的 `tools` 数组。 @@ -56,7 +58,7 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 - **Dashboard** → 第一个状态单元:选择 **v1**、**base** 或 **v2**。 - **Models** 页面 → 使用顶部的分段控件。 - 两个页面都有 **?** 按钮,可打开帮助弹窗并返回本文。 -- **Dashboard** → **子代理委托**:选择首选模型和可选的推理强度。在 v2 上,注入的指引会要求以 `fork_turns: "none"` 生成,使模型覆盖得以应用。如果原生→路由子代理只收到加密任务内容,请使用原生目标或 v1;仅外部目标的传输现在会明确返回 `unreadable_encrypted_agent_task`([#92](https://github.com/lidge-jun/opencodex/issues/92))。 +- **Dashboard** → **子代理委托**:选择首选模型和可选的推理强度。启用 **用作原生 Codex 子代理默认值** 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会把相同选择应用于新建 Codex 任务;外部用户管理的 provider 配置不会被修改。此开关与委派指引开关相互独立。在 v2 上,注入的指引会要求以 `fork_turns: "none"` 生成,使模型覆盖得以应用。如果原生→路由子代理只收到加密任务内容,请使用原生目标或 v1;仅外部目标的传输现在会明确返回 `unreadable_encrypted_agent_task`([#92](https://github.com/lidge-jun/opencodex/issues/92))。 ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# 将所选值同步为 Codex 原生子代理默认值(需先设置 model) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # 设置自定义指引提示词({{model}}/{{effort}}/{{roster}} 占位符) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` 返回 `model`、`effort`、`prompt`、全局 `efforts` 阶梯,以及由已启用原生/路由模型组成的 `available` 列表。PUT 请求省略 `effort` 或 `prompt` 时会保留当前值,传入 `null` 时会清除它;清除 `model` 一定会同时清除推理强度。API 会按全局 Codex 阶梯验证推理强度,Codex 仍会在生成时检查目标目录条目是否支持该强度。 +`GET /api/injection-model` 返回 `model`、`effort`、`prompt`、`multiAgentGuidanceEnabled`、`syncCodexSubagentDefaults`、全局 `efforts` 阶梯,以及由已启用原生/路由模型组成的 `available` 列表。PUT 为部分更新:省略 `effort` 或 `prompt` 时会保留当前值,传入 `null` 时会清除它。`syncCodexSubagentDefaults: true` 要求已经选择模型;清除 `model` 一定会同时清除推理强度,并关闭原生默认值同步。API 会按全局 Codex 阶梯验证推理强度,Codex 仍会在生成时检查目标目录条目是否支持该强度。 ## 推理强度 -可选的子代理推理强度保存在 `injectionEffort` 中,只有同时设置注入模型时才有意义。它会向注入的 v2 指引加入 `reasoning_effort` 要求,但不会改变父会话的推理强度。在接受覆盖的 fork 上,Codex 会直接应用传给 `spawn_agent` 的 `reasoning_effort`。 +可选的子代理推理强度保存在 `injectionEffort` 中,只有同时设置注入模型时才有意义。它会向注入的 v2 指引加入 `reasoning_effort` 要求,但不会改变父会话的推理强度。启用 `syncCodexSubagentDefaults` 且 OpenCodex 管理当前 Codex 路由时,下一次同步或重启还会把它作为新建 Codex 任务的原生子代理默认 effort。在接受覆盖的 fork 上,Codex 会直接应用传给 `spawn_agent` 的 `reasoning_effort`。 在 Codex 目录中,`ultra` 的级别高于 `max`,并带有自动委托语义;但 provider 永远不会在线路上收到字面量 `ultra`。Codex 会在客户端边界将 `ultra` 转成 `max`,随后 opencodex 再确保 provider 收到有效值: diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index ece5b04a0..cac81c25c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -25,7 +25,7 @@ bun run dev:gui | 区域 | 作用 | | --- | --- | | **Dashboard 摘要** | 显示 multi-agent 模式、在线状态、版本、运行时间、provider 数量、30 天 token 总量、活动 provider 和可用的原生/路由模型。 | -| **Sub-agent delegation** | 为 v1 委派 prompt 选择原生或路由模型,并可指定 reasoning 强度。它不是逐次生成的路由器,详见下文。 | +| **Sub-agent delegation** | 选择供 OpenCodex 委派指引与可选的 Codex 原生子代理默认值共用的原生/路由模型和可选 reasoning 强度。它不是逐次生成的路由器,详见下文。 | | **Sidecar** | 选择 web-search 模型及强度,以及图像描述模型;更改从下一次请求开始生效。 | | **Maintenance** | 重新同步 Codex 模型目录,查看项目级配置绕过警告,检查 latest/preview 版本,并可在更新后重启代理。 | | **启动安全** | 显示注入的 Codex 路由能否在重启后继续工作,并分别显示服务、launcher shim 状态和准确的修复命令。 | @@ -55,13 +55,16 @@ bun run dev:gui ## 委派选择器与生成路由的区别 Dashboard 的 **Sub-agent delegation** 选择器会保存 `injectionModel`,以及可选的 -`injectionEffort`。在 v1 turn 中,opencodex 会注入一段指引,告诉父代理调用 `spawn_agent` 时应 -传入哪个精确模型和 reasoning 强度。只要选定模型,无论父代理当前使用何种 reasoning 强度,都会 -启用这段指引;清除模型时也会清除已保存的强度。 +`injectionEffort`。所选值会用于由 OpenCodex 编写的委派指引,而该指引由 +`multiAgentGuidanceEnabled` 单独控制。清除模型时也会清除已保存的强度,并关闭原生默认值同步。 + +启用 **用作原生 Codex 子代理默认值** 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会 +把所选模型和强度应用为原生 `[agents]` 默认值;外部用户管理的 provider 配置不会被修改。这些默认值只影响新建的 Codex 任务,该选项本身不会触发委派。已有的用户自有 +`[agents]` 默认值会保留而不会被覆盖,因此请求的默认值可能与 Codex 实际使用的默认值不同。 :::caution -该选择器是面向 v1 兼容界面的委派指引。在 `multi_agent_v2` 中,当前代理不会附加 v1 注入消息, -而且所有生成的子代理都会继承父 session 的模型。它不是代理侧的跨模型路由器。v1/base/v2 的 +两个开关相互独立:关闭 OpenCodex 委派指引不会关闭原生默认值同步;启用原生默认值同步也不会 +启用委派指引或触发委派。两者都不是代理侧的逐次跨模型路由器。v1/base/v2 的 权威说明见 [子代理界面](/zh-cn/guides/sub-agent-surface/)。 ::: @@ -94,7 +97,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | `POST /api/sync` | 重建共享模型目录,并把 Codex 模型缓存标记为过期。 | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 检查、运行和监控自更新任务。 | | `GET` / `PUT /api/sidecar-settings` | 读取或设置 search/vision sidecar 模型。 | -| `GET` / `PUT /api/injection-model` | 读取或设置 v1 委派指引模型及可选强度。 | +| `GET` / `PUT /api/injection-model` | 读取或设置委派指引模型/强度、指引开关及 Codex 原生子代理默认值同步开关。 | | `GET` / `PUT /api/v2` | 读取或设置界面模式、Codex feature flag 和 v2 thread 上限。 | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 列出、添加/替换、启用/禁用或删除 provider。 | | `GET /api/models` · `PUT /api/disabled-models` | 列出原生/路由模型,并更新共享的 disabled-model 集合。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 8b9e67334..f41c59aea 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -30,12 +30,13 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `openaiProviderTierVersion?` | `2` | migration 设置 | 单一选项式 OpenAI projection 完成标记。 | | `defaultProvider` | `string` | `"openai"` | 路由找不到更优匹配时使用的 provider。 | | `subagentModels?` | `string[]` | `gpt-5.5`、三款 GPT-5.6、`gpt-5.4-mini` | 最多 5 个原生 slug 或 `provider/model` id,优先显示在 Codex subagent picker 中。v2 指引清单是已配置模型与 Codex 中 picker 可见、兼容 v2、按 priority 排序后前五项的交集,并使用规范目录 slug 与可用 effort 档位;被排除的条目仍保留在配置中。显式空数组会被保留。 | -| `injectionModel?` | `string` | — | 注入 multi-agent 指南(v2 界面)的首选原生或路由模型;委派指南会要求把该模型连同 `fork_turns: "none"` 一起传给 `spawn_agent`。 | -| `injectionEffort?` | `string` | — | 首选 `spawn_agent` reasoning effort(`low` 到 `ultra`)。只有与 `injectionModel` 一起使用才有意义。 | +| `injectionModel?` | `string` | — | 首选原生或路由子代理模型。它用于由独立 `multiAgentGuidanceEnabled` 控制、OpenCodex 编写的 v2 委派指引;选择 `syncCodexSubagentDefaults` 后,也可将其用作新任务的 Codex 原生默认值。 | +| `injectionEffort?` | `string` | — | 首选子代理 reasoning effort(`low` 到 `ultra`)。只有与 `injectionModel` 一起使用才有意义;它用于委派指引,并可在选择同步后作为 Codex 原生默认值。 | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | 当 OpenCodex 管理当前 Codex 路由时,选择是否在下一次同步或重启把已选的 `injectionModel` / `injectionEffort` 应用为 Codex 原生 `[agents]` 子代理默认值。外部用户管理的 provider 配置不会被修改。这些默认值只影响新建 Codex 任务,本身不会触发委派。已有的用户自有目标条目会作为冲突保留而不会被覆盖。此字段要求 `injectionModel`;清除模型会同时关闭该选项。它作为 `GET/PUT /api/injection-model` 的部分更新字段提供。 | | `effortCap?` | `string` | — | reasoning effort 的逐请求硬上限。这是多代理 V2 专属功能:适用于工具列表带有 V2 协作表面的主轮次,以及标记精确匹配 `x-openai-subagent: collab_spawn` 或 `x-codex-turn-metadata` 中 `"subagent_kind": "thread_spawn"` 的派生子轮次(带标记的子轮次无论自身工具表面如何都会被覆盖)。普通主轮次与 V1 表面主轮次不受影响,压缩(compaction)轮次始终绕过上限,`multiAgentMode: "v1"` 会完全禁用上限功能(仪表盘同时隐藏该面板)。接受 `low` 到 `ultra`;只会降低 effort,绝不会提高。会降至不高于上限的最高受支持档位。若模型不提供 effort 控制,或上限之下没有可用档位,则移除 effort 字段并采用 provider 默认值。`max` 和 `ultra` 均可使用,但不会形成更低的等级上限(客户端会将 `ultra` 转换为 `max`,因此请求以 `low` 到 `max` 的范围到达);不过,已知的模型 effort 阶梯仍可能触发降档或移除字段。仪表盘选择器提供 `low` 到 `xhigh`。通过 `GET /api/effort-caps` 和 `PUT /api/effort-caps` 管理。 | | `subagentEffortCap?` | `string` | — | 同样的硬上限,但只用于 codex-rs 标记精确匹配的派生子轮次:`x-openai-subagent: collab_spawn`,或 `x-codex-turn-metadata` 中的 `"subagent_kind": "thread_spawn"`。其他内部子代理类别(评审、压缩、记忆整理)不会触发此上限,`multiAgentMode: "v1"` 会完全禁用该功能。接受 `low` 到 `ultra`;两个上限同时设置时取较低者,且只会降低 effort,绝不会提高。会降至不高于上限的最高受支持档位。若模型不提供 effort 控制,或上限之下没有可用档位,则移除 effort 字段并采用 provider 默认值。`max` 和 `ultra` 均可使用,但不会形成更低的等级上限(客户端会将 `ultra` 转换为 `max`,因此请求以 `low` 到 `max` 的范围到达);不过,已知的模型 effort 阶梯仍可能触发降档或移除字段。仪表盘选择器提供 `low` 到 `xhigh`。通过 `GET /api/effort-caps` 和 `PUT /api/effort-caps` 管理。 | | `injectionPrompt?` | `string` | — | 整体替换注入的 v2 指南正文的自定义文本。`{{model}}`、`{{effort}}`、`{{roster}}` 占位符会被替换,触发条件保持不变。也可通过 `PUT /api/injection-model` 的 `prompt` 键设置。 | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | 仅控制由 OpenCodex 添加的 multi-agent developer 指引。未设置/`true` 保持 v1/v2 指引;`false` 会同时禁止两者,但不改变协作界面、`subagentModels`、路由或 effort 上限。`GET/PUT /api/injection-model` 返回有效值,PUT 为部分更新。 | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | 仅控制由 OpenCodex 添加的 multi-agent developer 指引。未设置/`true` 保持 v1/v2 指引;`false` 会同时禁止两者,但不改变 Codex 原生 `[agents]` 默认值、协作界面、`subagentModels`、路由或 effort 上限。`GET/PUT /api/injection-model` 返回有效值,PUT 为部分更新。 | | `disabledModels?` | `string[]` | — | 从 Codex 隐藏的模型。路由 `provider/model` id 会从目录和 `/v1/models` 排除;bare 原生 GPT slug(如 `gpt-5.4`)的目录条目会改成 `visibility: "hide"`,并从 bare `/v1/models` 列表移除。可在仪表盘 Models 页面按模型切换。 | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 三态 multi-agent surface override。`"v1"` 覆盖 upstream pin,强制全部模型使用 v1;`"default"` 遵循 upstream model pin(sol/terra=v2,luna=v1);`"v2"` 强制全部模型使用 v2。可在仪表盘 Models 页面或 `ocx v2 mode` 中设置。 | | `providerContextCaps?` | `Record` | `{}` | provider 级 Codex 可见 context cap。只会降低已知 context window。 | diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 81a38269c..d60e839b8 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -21,6 +21,7 @@ import { installApiAuthFetch } from "./api"; import { readJsonIfOk } from "./fetch-json"; import { type Page } from "./app-routing"; import { useAppRouteState } from "./use-app-route-state"; +import { requestProxyStop } from "./stop-proxy"; installApiAuthFetch(); @@ -185,17 +186,16 @@ export default function App() { const handleStop = async () => { if (!confirm(t("dash.stopConfirm"))) return; setStopping(true); - try { - const res = await fetch(`${API_BASE}/api/stop`, { method: "POST" }); - // A refusal (409: a service under another home owns this proxy) returns normally instead - // of dropping the connection, so the button would otherwise sit in "stopping…" forever - // with nothing explaining why. - if (!res.ok) { - setStopping(false); - const detail = await res.json().catch(() => null) as { message?: string } | null; - if (detail?.message) alert(detail.message); - } - } catch { /* connection drops — the proxy is going down as expected */ } + const outcome = await requestProxyStop(API_BASE, { + formatFailure: status => t("dash.stopFailed", { status: String(status) }), + }); + // Refusals and restore failures return normally instead of dropping the connection. + // In both cases the proxy did not reach a clean-stop result, so re-enable the control + // and surface the server's remediation instead of leaving "stopping…" stuck forever. + if (!outcome.accepted) { + setStopping(false); + alert(outcome.message); + } }; const brand = ( diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3b5e71f88..db2f0226b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -149,6 +149,7 @@ export const de: Record = { "dash.runStart": "Führe {cmd} aus, um den Proxy zu starten.", "dash.stop": "Proxy stoppen", "dash.stopConfirm": "Proxy stoppen und natives Codex wiederherstellen?", + "dash.stopFailed": "Proxy konnte nicht gestoppt werden (HTTP {status}).", "dash.stopping": "Wird gestoppt…", "dash.codexAutoStart": "opencodex mit Codex starten", "dash.codexAutoStartHint": "Erlaubt einem installierten Launcher-Shim, ocx ensure auszuführen. Diese Einstellung installiert keinen Neustartschutz; prüfe den effektiven Zustand unter Startsicherheit.", @@ -177,10 +178,11 @@ export const de: Record = { "dash.sidecarSaved": "Sidecar-Einstellungen gespeichert. Angewendet bei der nächsten Anfrage.", "dash.sidecarSaveFailed": "Sidecar-Einstellungen konnten nicht gespeichert werden.", "dash.injectionLabel": "Sub-Agent-Delegation", - "dash.injectionHint": "Wähle ein geroutetes Modell für den Delegations-Prompt. Der Agent wird angewiesen, es für Teilaufgaben zu nutzen.", + "dash.injectionHint": "Wähle das Modell und den optionalen Aufwand, die von den beiden Steuerelementen unten gemeinsam verwendet werden.", + "dash.syncCodexSubagentDefaults": "Als native Codex-Subagent-Standardwerte verwenden", + "dash.syncCodexSubagentDefaultsHint": "Standardmäßig deaktiviert. Wenn OpenCodex das Codex-Routing verwaltet, wendet eine Synchronisierung oder ein Neustart das ausgewählte Modell und den Aufwand als native Codex-[agents]-Standardwerte für neue Codex-Aufgaben an. Dies löst keine Delegation aus; vorhandene benutzereigene [agents]-Standardwerte bleiben erhalten und werden nicht überschrieben.", "dash.multiAgentGuidance": "OpenCodex-Multi-Agent-Anleitung", - "dash.multiAgentGuidanceHint": "Fügt von OpenCodex erstellte Delegationshinweise hinzu. Beim Ausschalten bleiben v1/v2-Oberfläche, Sub-Agent-Liste, Routing und Aufwandsgrenzen unverändert.", - "dash.injectionActive": "Aktiv", + "dash.multiAgentGuidanceHint": "Fügt von OpenCodex erstellte Delegationshinweise hinzu. Dies ist von den nativen Codex-Standards oben getrennt und ändert weder v1/v2-Oberfläche, Sub-Agent-Liste, Routing noch Aufwandsgrenzen.", "dash.injectionNone": "Keine", "dash.injectionEffortLabel": "Reasoning-Aufwand", "dash.injectionEffortNone": "Modell-Standard", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 56b1288ef..71fc150d3 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -159,6 +159,7 @@ export const en = { "dash.runStart": "Run {cmd} to start the proxy.", "dash.stop": "Stop Proxy", "dash.stopConfirm": "Stop the proxy and restore native Codex?", + "dash.stopFailed": "Failed to stop proxy (HTTP {status}).", "dash.stopping": "Stopping…", "dash.codexAutoStart": "Start opencodex with Codex", "dash.codexAutoStartHint": "Allows an installed launcher shim to run ocx ensure. This setting does not install restart protection; check Startup safety for the effective state.", @@ -187,10 +188,11 @@ export const en = { "dash.sidecarSaved": "Sidecar settings saved. Applied on the next request.", "dash.sidecarSaveFailed": "Failed to save sidecar settings.", "dash.injectionLabel": "Sub-agent delegation", - "dash.injectionHint": "Pick a routed model to inject into the delegation prompt. The agent will be told to use it for sub-tasks.", + "dash.injectionHint": "Choose the model and optional effort shared by the two controls below.", + "dash.syncCodexSubagentDefaults": "Use as native Codex subagent defaults", + "dash.syncCodexSubagentDefaultsHint": "Off by default. When OpenCodex manages Codex routing, sync or restart applies the selected model and effort as native Codex [agents] defaults for new Codex tasks. This does not cause delegation; existing user-owned [agents] defaults are preserved rather than overwritten.", "dash.multiAgentGuidance": "OpenCodex multi-agent guidance", - "dash.multiAgentGuidanceHint": "Adds OpenCodex-authored delegation instructions. Turning this off keeps the v1/v2 surface, sub-agent roster, routing, and effort caps unchanged.", - "dash.injectionActive": "Active", + "dash.multiAgentGuidanceHint": "Adds OpenCodex-authored delegation instructions. This is separate from the native Codex defaults above and does not change the v1/v2 surface, sub-agent roster, routing, or effort caps.", "dash.injectionNone": "None", "dash.injectionEffortLabel": "Reasoning effort", "dash.injectionEffortNone": "Model default", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 5c4877215..0218143f6 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -158,6 +158,7 @@ export const ja: Record = { "dash.runStart": "{cmd} を実行してプロキシを起動してください。", "dash.stop": "プロキシを停止", "dash.stopConfirm": "プロキシを停止してネイティブの Codex に戻しますか?", + "dash.stopFailed": "プロキシを停止できませんでした (HTTP {status})。", "dash.stopping": "停止中…", "dash.codexAutoStart": "Codex と一緒に opencodex を起動", "dash.codexAutoStartHint": "インストール済み launcher shim に ocx ensure の実行を許可します。この設定だけでは再起動保護はインストールされません。起動安全性で実際の状態を確認してください。", @@ -186,10 +187,11 @@ export const ja: Record = { "dash.sidecarSaved": "サイドカー設定を保存しました。次回リクエスト時に適用されます。", "dash.sidecarSaveFailed": "サイドカー設定の保存に失敗しました。", "dash.injectionLabel": "サブエージェント委任", - "dash.injectionHint": "委任プロンプトに注入するルーティングモデルを選択します。エージェントはサブタスクにそれを使うよう指示されます。", + "dash.injectionHint": "下の 2 つの設定で共用するモデルと任意の推論負荷を選択します。", + "dash.syncCodexSubagentDefaults": "ネイティブ Codex サブエージェントの既定値として使用", + "dash.syncCodexSubagentDefaultsHint": "既定ではオフです。OpenCodex が Codex ルーティングを管理している場合、同期または再起動によって選択したモデルと推論負荷を新しい Codex タスクのネイティブ Codex [agents] 既定値として適用します。この設定自体は委任を発生させず、既存のユーザー所有 [agents] 既定値は上書きせずに保持します。", "dash.multiAgentGuidance": "OpenCodex マルチエージェントガイダンス", - "dash.multiAgentGuidanceHint": "OpenCodex が作成する委任指示を追加します。オフにしても v1/v2 サーフェス、サブエージェントロスター、ルーティング、effort 上限は変わりません。", - "dash.injectionActive": "アクティブ", + "dash.multiAgentGuidanceHint": "OpenCodex が作成する委任指示を追加します。上のネイティブ Codex 既定値とは別で、v1/v2 サーフェス、サブエージェントロスター、ルーティング、effort 上限は変わりません。", "dash.injectionNone": "なし", "dash.injectionEffortLabel": "推論負荷", "dash.injectionEffortNone": "モデル既定", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9e7aac326..9c3395fa8 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -153,6 +153,7 @@ export const ko: Record = { "dash.runStart": "{cmd} 를 실행해 프록시를 시작하세요.", "dash.stop": "프록시 중지", "dash.stopConfirm": "프록시를 중지하고 Codex 원본 설정을 복원할까요?", + "dash.stopFailed": "프록시를 중지하지 못했습니다 (HTTP {status}).", "dash.stopping": "중지 중…", "dash.codexAutoStart": "Codex 실행 시 opencodex 시작", "dash.codexAutoStartHint": "설치된 launcher shim이 ocx ensure를 실행하도록 허용합니다. 이 설정은 재부팅 보호를 설치하지 않으므로 시작 안전성에서 실제 상태를 확인하세요.", @@ -181,10 +182,11 @@ export const ko: Record = { "dash.sidecarSaved": "사이드카 설정이 저장됐습니다. 다음 요청부터 적용됩니다.", "dash.sidecarSaveFailed": "사이드카 설정 저장에 실패했습니다.", "dash.injectionLabel": "서브에이전트 위임", - "dash.injectionHint": "위임 프롬프트에 주입할 라우팅 모델을 선택합니다. 에이전트가 서브태스크에 이 모델을 사용하도록 안내됩니다.", + "dash.injectionHint": "아래 두 제어가 함께 사용할 모델과 선택적인 추론 강도를 고릅니다.", + "dash.syncCodexSubagentDefaults": "네이티브 Codex 서브에이전트 기본값으로 사용", + "dash.syncCodexSubagentDefaultsHint": "기본적으로 꺼져 있습니다. OpenCodex가 Codex 라우팅을 관리하는 경우 동기화하거나 재시작하면 선택한 모델과 추론 강도를 새 Codex 작업의 네이티브 Codex [agents] 기본값으로 적용합니다. 이 설정 자체가 위임을 일으키지는 않으며, 기존 사용자 소유 [agents] 기본값은 덮어쓰지 않고 보존합니다.", "dash.multiAgentGuidance": "OpenCodex 멀티 에이전트 가이던스", - "dash.multiAgentGuidanceHint": "OpenCodex가 작성한 위임 안내를 추가합니다. 꺼도 v1/v2 표면, 서브에이전트 로스터, 라우팅, effort 상한은 바뀌지 않습니다.", - "dash.injectionActive": "활성", + "dash.multiAgentGuidanceHint": "OpenCodex가 작성한 위임 안내를 추가합니다. 위의 네이티브 Codex 기본값과 별개이며 v1/v2 표면, 서브에이전트 로스터, 라우팅, effort 상한은 바꾸지 않습니다.", "dash.injectionNone": "없음", "dash.injectionEffortLabel": "추론 강도", "dash.injectionEffortNone": "모델 기본값", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index aaa66fd6b..8732bf5c7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -158,6 +158,7 @@ export const ru: Record = { "dash.runStart": "Выполните {cmd}, чтобы запустить прокси.", "dash.stop": "Остановить прокси", "dash.stopConfirm": "Остановить прокси и восстановить нативный Codex?", + "dash.stopFailed": "Не удалось остановить прокси (HTTP {status}).", "dash.stopping": "Остановка…", "dash.codexAutoStart": "Запускать opencodex вместе с Codex", "dash.codexAutoStartHint": "Разрешает установленному launcher shim выполнять ocx ensure. Эта настройка не устанавливает защиту перезапуска; проверьте фактическое состояние в разделе безопасности запуска.", @@ -186,10 +187,11 @@ export const ru: Record = { "dash.sidecarSaved": "Настройки сайдкара сохранены. Вступят в силу со следующего запроса.", "dash.sidecarSaveFailed": "Не удалось сохранить настройки сайдкара.", "dash.injectionLabel": "Делегирование подагентам", - "dash.injectionHint": "Выберите маршрутизируемую модель для внедрения в промпт делегирования. Агенту будет указано использовать её для подзадач.", + "dash.injectionHint": "Выберите модель и необязательный уровень рассуждений, общие для двух настроек ниже.", + "dash.syncCodexSubagentDefaults": "Использовать как нативные значения подагентов Codex по умолчанию", + "dash.syncCodexSubagentDefaultsHint": "По умолчанию выключено. Если маршрутизацией Codex управляет OpenCodex, синхронизация или перезапуск применяет выбранные модель и уровень рассуждений как нативные значения Codex [agents] для новых задач. Эта настройка сама не вызывает делегирование; существующие пользовательские значения [agents] сохраняются и не перезаписываются.", "dash.multiAgentGuidance": "Мультиагентное руководство OpenCodex", - "dash.multiAgentGuidanceHint": "Добавляет инструкции делегирования от OpenCodex. Отключение не меняет поверхность v1/v2, список подагентов, маршрутизацию и пределы effort.", - "dash.injectionActive": "Активно", + "dash.multiAgentGuidanceHint": "Добавляет инструкции делегирования от OpenCodex. Это отдельная настройка от нативных значений Codex выше; она не меняет поверхность v1/v2, список подагентов, маршрутизацию и пределы effort.", "dash.injectionNone": "Нет", "dash.injectionEffortLabel": "Уровень рассуждений", "dash.injectionEffortNone": "По умолчанию для модели", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 7651c00cb..f79113f7e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -153,6 +153,7 @@ export const zh: Record = { "dash.runStart": "运行 {cmd} 以启动代理。", "dash.stop": "停止代理", "dash.stopConfirm": "停止代理并恢复原生 Codex 配置?", + "dash.stopFailed": "无法停止代理 (HTTP {status})。", "dash.stopping": "正在停止…", "dash.codexAutoStart": "随 Codex 启动 opencodex", "dash.codexAutoStartHint": "允许已安装的 launcher shim 运行 ocx ensure。此设置不会安装重启保护;请在启动安全中检查实际状态。", @@ -181,10 +182,11 @@ export const zh: Record = { "dash.sidecarSaved": "附属设置已保存。将在下一个请求时生效。", "dash.sidecarSaveFailed": "保存附属设置失败。", "dash.injectionLabel": "子代理委托", - "dash.injectionHint": "选择要注入委托提示的路由模型。代理将被告知在子任务中使用它。", + "dash.injectionHint": "选择供下方两个控件共用的模型和可选推理强度。", + "dash.syncCodexSubagentDefaults": "用作原生 Codex 子代理默认值", + "dash.syncCodexSubagentDefaultsHint": "默认关闭。当 OpenCodex 管理 Codex 路由时,同步或重启会将所选模型和推理强度应用为新 Codex 任务的原生 Codex [agents] 默认值。此设置本身不会触发委托;现有的用户自有 [agents] 默认值会保留而不会被覆盖。", "dash.multiAgentGuidance": "OpenCodex 多代理指引", - "dash.multiAgentGuidanceHint": "添加由 OpenCodex 编写的委派指令。关闭后仍保留 v1/v2 界面、子代理清单、路由和 effort 上限。", - "dash.injectionActive": "已激活", + "dash.multiAgentGuidanceHint": "添加由 OpenCodex 编写的委托指引。它与上方的原生 Codex 默认值相互独立,不会更改 v1/v2 界面、子代理清单、路由或 effort 上限。", "dash.injectionNone": "无", "dash.injectionEffortLabel": "推理强度", "dash.injectionEffortNone": "模型默认", diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index fc127e335..c43cae4eb 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -19,12 +19,29 @@ import { export type InjectionPoll = { multiAgentGuidanceEnabled: boolean; + syncCodexSubagentDefaults: boolean; injectionModel: string; injectionEffort: string; injectionEfforts: string[]; injectionAvailable: Array<{ provider: string; model: string; namespaced: string }>; }; +export type InjectionSelectionResponse = { + multiAgentGuidanceEnabled?: boolean; + syncCodexSubagentDefaults?: boolean; + model?: string | null; + effort?: string | null; +}; + +export function normalizeInjectionSelection(data: InjectionSelectionResponse) { + return { + multiAgentGuidanceEnabled: data.multiAgentGuidanceEnabled !== false, + syncCodexSubagentDefaults: data.syncCodexSubagentDefaults === true, + injectionModel: data.model ?? "", + injectionEffort: data.effort ?? "", + }; +} + export type EffortCapPoll = { effortCap: string; subagentEffortCap: string; @@ -197,17 +214,12 @@ export async function fetchDashboardCore( try { const imRes = await fetch(`${apiBase}/api/injection-model`, { signal }); if (imRes.ok) { - const imData = await imRes.json() as { - multiAgentGuidanceEnabled?: boolean; - model?: string | null; - effort?: string | null; + const imData = await imRes.json() as InjectionSelectionResponse & { efforts?: string[]; available?: InjectionPoll["injectionAvailable"]; }; injection = { - multiAgentGuidanceEnabled: imData.multiAgentGuidanceEnabled !== false, - injectionModel: imData.model ?? "", - injectionEffort: imData.effort ?? "", + ...normalizeInjectionSelection(imData), injectionEfforts: imData.efforts ?? [], injectionAvailable: imData.available ?? [], }; diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index fb12ceb0c..9447a30d5 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -86,12 +86,11 @@ export function DashboardEffortCapPanel({ apiBase, d }: { apiBase: string; d: Da ); } -export function DashboardInjectionPanel({ apiBase, d }: { apiBase: string; d: Dash }) { +export function DashboardInjectionPanel({ d }: { apiBase: string; d: Dash }) { const { t, injectionModel, injectionEffort, injectionEfforts, injectionAvailable, injectionSaving, - setInjectionModel, setInjectionEffort, setInjectionSaving, - multiAgentGuidanceEnabled, setMultiAgentGuidanceEnabled, + multiAgentGuidanceEnabled, syncCodexSubagentDefaults, saveInjection, } = d; return ( @@ -104,22 +103,8 @@ export function DashboardInjectionPanel({ apiBase, d }: { apiBase: string; d: Da { value: "", label: t("dash.injectionNone") }, ...injectionAvailable.map(m => ({ value: m.namespaced, label: `${m.provider} / ${m.model}` })), ]} - onChange={async (v) => { - if (injectionSaving) return; - setInjectionSaving(true); - try { - const res = await fetch(`${apiBase}/api/injection-model`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: v || null, effort: injectionEffort || null }), - }); - const data = await requireJson<{ model?: string | null; effort?: string | null }>(res); - setInjectionModel(data.model ?? ""); - setInjectionEffort(data.effort ?? ""); - } catch { /* ignore */ } - finally { setInjectionSaving(false); } - }} - disabled={injectionSaving || !multiAgentGuidanceEnabled} + onChange={(v) => { void saveInjection({ model: v || null, effort: injectionEffort || null }); }} + disabled={injectionSaving} label={t("dash.injectionLabel")} /> {injectionModel && injectionEfforts.length > 0 && ( @@ -129,28 +114,29 @@ export function DashboardInjectionPanel({ apiBase, d }: { apiBase: string; d: Da { value: "", label: t("dash.injectionEffortNone") }, ...injectionEfforts.map(e => ({ value: e, label: e })), ]} - onChange={async (v) => { - if (injectionSaving) return; - setInjectionSaving(true); - try { - const res = await fetch(`${apiBase}/api/injection-model`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: injectionModel || null, effort: v || null }), - }); - const data = await requireJson<{ model?: string | null; effort?: string | null }>(res); - setInjectionModel(data.model ?? ""); - setInjectionEffort(data.effort ?? ""); - } catch { /* ignore */ } - finally { setInjectionSaving(false); } - }} - disabled={injectionSaving || !multiAgentGuidanceEnabled} + onChange={(v) => { void saveInjection({ model: injectionModel || null, effort: v || null }); }} + disabled={injectionSaving} label={t("dash.injectionEffortLabel")} /> )} - {injectionModel && multiAgentGuidanceEnabled && {t("dash.injectionActive")}}
    {t("dash.injectionHint")}
    +
    +
    +
    {t("dash.syncCodexSubagentDefaults")}
    +
    {t("dash.syncCodexSubagentDefaultsHint")}
    +
    + +
    {t("dash.multiAgentGuidance")}
    @@ -159,20 +145,7 @@ export function DashboardInjectionPanel({ apiBase, d }: { apiBase: string; d: Da
    {syncResult && ( -
    - +
    + {syncResult.nativeSubagentDefaultsWarning ? : } {t("dash.syncOk", { count: syncResult.added })} {syncResult.warning ? ` ${syncResult.warning}` : ""} + {syncResult.nativeSubagentDefaultsWarning ? ` ${syncResult.nativeSubagentDefaultsWarning}` : ""} {syncResult.staleAppServerHint ? ` ${t("dash.syncStaleHint")}` : ""}
    diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 8557f0546..2167c0ca9 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -60,6 +60,7 @@ export interface SyncResult { cacheSynced: boolean; message: string; warning?: string; + nativeSubagentDefaultsWarning?: string; staleAppServerHint?: string; projectConfigWarnings?: ProjectCodexConfigWarning[]; } @@ -151,7 +152,7 @@ export function sidecarBackendForModel(models: ModelInfo[], modelId: string): Si } let lastInputWasKeyboard = false; -if (typeof window !== "undefined") { +if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("keydown", () => { lastInputWasKeyboard = true; }, { capture: true, passive: true }); window.addEventListener("pointerdown", () => { lastInputWasKeyboard = false; }, { capture: true, passive: true }); } diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index b9ea608ac..9ca46cb71 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -12,6 +12,7 @@ import { fetchDashboardModels, fetchProjectConfigDiagnostics, fetchStartupHealth, + normalizeInjectionSelection, type DashboardEpochRefs, } from "./dashboard-core-poll"; import { @@ -70,6 +71,7 @@ export function useDashboardData(apiBase: string) { const [injectionAvailable, setInjectionAvailable] = useState>([]); const [injectionSaving, setInjectionSaving] = useState(false); const [multiAgentGuidanceEnabled, setMultiAgentGuidanceEnabled] = useState(true); + const [syncCodexSubagentDefaults, setSyncCodexSubagentDefaults] = useState(false); const [effortCap, setEffortCap] = useState(""); const [subagentEffortCap, setSubagentEffortCap] = useState(""); const [effortCapSaving, setEffortCapSaving] = useState(false); @@ -200,6 +202,7 @@ export function useDashboardData(apiBase: string) { setMaModeResolved(data.maModeResolved); if (data.injection) { setMultiAgentGuidanceEnabled(data.injection.multiAgentGuidanceEnabled); + setSyncCodexSubagentDefaults(data.injection.syncCodexSubagentDefaults); setInjectionModel(data.injection.injectionModel); setInjectionEffort(data.injection.injectionEffort); setInjectionEfforts(data.injection.injectionEfforts); @@ -351,6 +354,41 @@ export function useDashboardData(apiBase: string) { finally { setMaBusy(false); } }; + const saveInjection = async (patch: { + multiAgentGuidanceEnabled?: boolean; + syncCodexSubagentDefaults?: boolean; + model?: string | null; + effort?: string | null; + }) => { + if (injectionSaving) return; + setInjectionSaving(true); + try { + const res = await fetch(`${apiBase}/api/injection-model`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) throw new Error("injection save failed"); + const getRes = await fetch(`${apiBase}/api/injection-model`); + const data = await requireJson<{ + multiAgentGuidanceEnabled?: boolean; + syncCodexSubagentDefaults?: boolean; + model?: string | null; + effort?: string | null; + efforts?: string[]; + available?: Array<{ provider: string; model: string; namespaced: string }>; + }>(getRes); + const normalized = normalizeInjectionSelection(data); + setMultiAgentGuidanceEnabled(normalized.multiAgentGuidanceEnabled); + setSyncCodexSubagentDefaults(normalized.syncCodexSubagentDefaults); + setInjectionModel(normalized.injectionModel); + setInjectionEffort(normalized.injectionEffort); + if (Array.isArray(data.efforts)) setInjectionEfforts(data.efforts); + if (Array.isArray(data.available)) setInjectionAvailable(data.available); + } catch { /* keep the last committed UI state */ } + finally { setInjectionSaving(false); } + }; + const toggleCodexAutoStart = async () => { if (!settings || settingsSaving) return; const next = !settings.codexAutoStart; @@ -484,8 +522,7 @@ export function useDashboardData(apiBase: string) { maMode, maModeResolved, maBusy, setMaHelpOpen, maHelpOpen, effortCapHelpOpen, setEffortCapHelpOpen, shadowCallHelpOpen, setShadowCallHelpOpen, injectionModel, injectionEffort, injectionEfforts, injectionAvailable, injectionSaving, - setInjectionModel, setInjectionEffort, setInjectionSaving, - multiAgentGuidanceEnabled, setMultiAgentGuidanceEnabled, + multiAgentGuidanceEnabled, syncCodexSubagentDefaults, saveInjection, effortCap, subagentEffortCap, effortCapSaving, setEffortCap, setSubagentEffortCap, setEffortCapSaving, syncResult, syncError, projectConfigWarnings, updateOpen, updateChannel, setUpdateRestart, updateRestart, updateLoading, diff --git a/gui/src/stop-proxy.ts b/gui/src/stop-proxy.ts new file mode 100644 index 000000000..ee98d3735 --- /dev/null +++ b/gui/src/stop-proxy.ts @@ -0,0 +1,66 @@ +export interface ProxyStopOutcome { + accepted: boolean; + message?: string; +} + +interface ProxyStopPayload { + success?: unknown; + message?: unknown; + error?: unknown; +} + +const DEFAULT_STOP_TIMEOUT_MS = 15_000; + +export interface ProxyStopOptions { + fetchFn?: typeof fetch; + timeoutMs?: number; + formatFailure?: (status: number) => string; +} + +function failureMessage( + payload: ProxyStopPayload | null, + status: number, + formatFailure: (status: number) => string, +): string { + if (typeof payload?.message === "string" && payload.message.trim()) return payload.message; + if (typeof payload?.error === "string" && payload.error.trim()) return payload.error; + return formatFailure(status); +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException + ? error.name === "AbortError" + : error instanceof Error && error.name === "AbortError"; +} + +/** + * A dropped connection is expected once shutdown starts. A received response, however, + * is authoritative: either a non-2xx status or `{ success: false }` means the UI must + * leave its pending state and surface the server's restore error. + */ +export async function requestProxyStop( + apiBase: string, + options: ProxyStopOptions = {}, +): Promise { + const { + fetchFn = fetch, + timeoutMs = DEFAULT_STOP_TIMEOUT_MS, + formatFailure = status => `Failed to stop proxy (HTTP ${status}).`, + } = options; + let response: Response; + try { + response = await fetchFn(`${apiBase}/api/stop`, { + method: "POST", + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + if (isAbortError(error)) return { accepted: true }; + return { accepted: true }; + } + + const payload = await response.json().catch(() => null) as ProxyStopPayload | null; + if (!response.ok || payload?.success === false) { + return { accepted: false, message: failureMessage(payload, response.status, formatFailure) }; + } + return { accepted: true }; +} diff --git a/gui/tests/app-stop.test.ts b/gui/tests/app-stop.test.ts new file mode 100644 index 000000000..24046b455 --- /dev/null +++ b/gui/tests/app-stop.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { requestProxyStop } from "../src/stop-proxy"; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("App proxy stop", () => { + test("releases the pending UI and exposes a non-2xx server message", async () => { + const outcome = await requestProxyStop("", { + fetchFn: (async () => response({ + success: false, + message: "native Codex restore failed", + }, 500)) as typeof fetch, + formatFailure: status => `Failed to stop proxy (HTTP ${status}).`, + }); + + expect(outcome).toEqual({ accepted: false, message: "native Codex restore failed" }); + }); + + test("rejects an HTTP 200 cleanup failure and exposes its server message", async () => { + const outcome = await requestProxyStop("", { + fetchFn: (async () => response({ + success: false, + message: "native Codex cleanup failed", + })) as typeof fetch, + formatFailure: status => `Failed to stop proxy (HTTP ${status}).`, + }); + + expect(outcome).toEqual({ accepted: false, message: "native Codex cleanup failed" }); + }); + + test("treats a stop timeout like a dropped connection", async () => { + const outcome = await requestProxyStop("", { + fetchFn: (async () => { + throw new DOMException("The operation timed out.", "AbortError"); + }) as typeof fetch, + timeoutMs: 1, + }); + + expect(outcome).toEqual({ accepted: true }); + }); + + test("uses the localized fallback when the server omits a message", async () => { + const outcome = await requestProxyStop("", { + fetchFn: (async () => response({}, 503)) as typeof fetch, + formatFailure: status => `HTTP ${status} stop failed`, + }); + + expect(outcome).toEqual({ accepted: false, message: "HTTP 503 stop failed" }); + }); + + test("App clears stopping state and alerts for every rejected stop outcome", async () => { + const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); + const handleStopIdx = app.indexOf("const handleStop"); + const brandIdx = app.indexOf("const brand"); + expect(handleStopIdx).toBeGreaterThanOrEqual(0); + expect(brandIdx).toBeGreaterThan(handleStopIdx); + const handler = app.slice(handleStopIdx, brandIdx); + + expect(handler).toContain("await requestProxyStop(API_BASE"); + expect(handler).toContain("if (!outcome.accepted)"); + expect(handler).toContain("setStopping(false)"); + expect(handler).toContain("alert(outcome.message)"); + }); +}); diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index 370808bc9..4c6549bdc 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -1,4 +1,6 @@ import { expect, test } from "bun:test"; +import { en } from "../src/i18n/en"; +import { normalizeInjectionSelection } from "../src/pages/dashboard-core-poll"; import { PROJECT_CONFIG_DIAGNOSTICS_POLL_MS } from "../src/startup-health-ui"; test("project-config diagnostics poll cadence is owned by the shared constant", () => { @@ -40,10 +42,39 @@ test("Dashboard workspace pane is a labelled section, not a nested main landmark expect(src).toMatch(/<(section)\b[^>]*dashboard-workspace-main/); }); -test("multi-agent guidance gates injection controls and Active badge on the enabled flag", async () => { +test("native Codex subagent defaults stay separate from OpenCodex guidance", async () => { + const core = await Bun.file(new URL("../src/pages/dashboard-core-poll.ts", import.meta.url)).text(); const sections = await Bun.file(new URL("../src/pages/dashboard-overview-sections.tsx", import.meta.url)).text(); const head = await Bun.file(new URL("../src/pages/dashboard-overview-head.tsx", import.meta.url)).text(); - expect(sections).toContain("!multiAgentGuidanceEnabled"); - expect(sections).toContain("multiAgentGuidanceEnabled &&"); + expect(core).toContain("syncCodexSubagentDefaults: data.syncCodexSubagentDefaults === true"); + expect(sections).toContain("saveInjection({ syncCodexSubagentDefaults: !syncCodexSubagentDefaults })"); + expect(sections).toContain("disabled={injectionSaving || !injectionModel}"); + expect(sections).not.toContain("injectionSaving || !multiAgentGuidanceEnabled"); + expect(sections).not.toContain("dash.injectionActive"); + expect(en["dash.syncCodexSubagentDefaults"]).toBe("Use as native Codex subagent defaults"); + expect(en["dash.syncCodexSubagentDefaultsHint"]).toContain("Off by default"); + expect(en["dash.syncCodexSubagentDefaultsHint"]).toContain("existing user-owned [agents] defaults are preserved rather than overwritten"); + expect(en["dash.multiAgentGuidanceHint"]).not.toContain("proactive"); expect(head).toContain("models.v2Mode_"); }); + +test("injection writes consume the server's model-clear normalization", () => { + expect(normalizeInjectionSelection({ + multiAgentGuidanceEnabled: true, + syncCodexSubagentDefaults: false, + model: null, + effort: null, + })).toEqual({ + multiAgentGuidanceEnabled: true, + syncCodexSubagentDefaults: false, + injectionModel: "", + injectionEffort: "", + }); +}); + +test("Dashboard sync surfaces native subagent default warnings", async () => { + const sections = await Bun.file(new URL("../src/pages/dashboard-overview-sections.tsx", import.meta.url)).text(); + expect(sections).toContain("syncResult.nativeSubagentDefaultsWarning"); + expect(sections).toContain('"notice-warn"'); + expect(sections).toContain(""); +}); diff --git a/gui/tests/multi-agent-guidance.test.tsx b/gui/tests/multi-agent-guidance.test.tsx index 7f59ce782..f3ee3dda3 100644 --- a/gui/tests/multi-agent-guidance.test.tsx +++ b/gui/tests/multi-agent-guidance.test.tsx @@ -1,14 +1,25 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { act, useEffect, useState } from "react"; +import { act } from "react"; import type { Root } from "react-dom/client"; +import { en } from "../src/i18n/en"; +import { DashboardInjectionPanel } from "../src/pages/dashboard-overview-sections"; +import type { useDashboardData } from "../src/pages/use-dashboard-data"; const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; -let previousGlobals: Record<(typeof globals)[number], unknown>; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; let testWindow: Window; +let host: HTMLElement; +let root: Root | null = null; +let requests: unknown[] = []; + +type Dash = ReturnType; beforeEach(() => { - previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + previousGlobals = Object.fromEntries( + globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + root = null; testWindow = new Window({ url: "http://localhost/" }); Object.defineProperties(globalThis, { document: { configurable: true, value: testWindow.document }, @@ -16,62 +27,107 @@ beforeEach(() => { navigator: { configurable: true, value: testWindow.navigator }, }); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + requests = []; + host = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(host as never); }); -afterEach(() => { - testWindow.close(); - for (const key of globals) { - Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); +afterEach(async () => { + try { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + } + } finally { + root = null; + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } } }); -/** Mirrors the Dashboard guidance control contract without mounting the full page. */ -function GuidanceControls({ - enabled, - model, -}: { - enabled: boolean; - model: string; -}) { - return ( -
    - - - {enabled && model ? Active : null} -
    - ); +function dash(overrides: Partial = {}): Dash { + return { + t: (key: keyof typeof en) => en[key], + injectionModel: "anthropic/claude-sonnet-5", + injectionEffort: "high", + injectionEfforts: ["low", "medium", "high"], + injectionAvailable: [{ provider: "anthropic", model: "claude-sonnet-5", namespaced: "anthropic/claude-sonnet-5" }], + injectionSaving: false, + multiAgentGuidanceEnabled: false, + syncCodexSubagentDefaults: true, + saveInjection: async (patch) => { requests.push(patch); }, + ...overrides, + } as unknown as Dash; } -test("guidance-off keeps stored selection but hides Active and disables controls", async () => { +async function mount(d: Dash) { + // ReactDOM must observe the happy-dom globals installed by beforeEach. A static + // import binds to the prior document state and corrupts sibling suites. const { createRoot } = await import("react-dom/client"); - const host = document.createElement("div"); - document.body.append(host); - let root!: Root; - function Shell() { - const [enabled, setEnabled] = useState(false); - useEffect(() => {}, []); - return ( -
    - - -
    - ); - } await act(async () => { root = createRoot(host); - root.render(); + root.render(); }); - const model = host.querySelector('select[aria-label="model"]')!; - expect(model.disabled).toBe(true); - expect(model.value).toBe("claude"); - expect(host.textContent).not.toContain("Active"); - await act(async () => { host.querySelector("button")!.click(); }); +} + +test("renders independent guidance and native-default controls with editable selection", async () => { + await mount(dash()); + + const model = host.querySelector('button[role="combobox"][aria-label="Sub-agent delegation"]')!; + const effort = host.querySelector('button[role="combobox"][aria-label="Reasoning effort"]')!; + const defaults = host.querySelector('button[aria-label="Use as native Codex subagent defaults"]')!; + const guidance = host.querySelector('button[aria-label="OpenCodex multi-agent guidance"]')!; + expect(model.disabled).toBe(false); - expect(host.textContent).toContain("Active"); - await act(async () => { root.unmount(); }); + expect(effort.disabled).toBe(false); + expect(defaults.disabled).toBe(false); + expect(defaults.getAttribute("aria-pressed")).toBe("true"); + expect(guidance.getAttribute("aria-pressed")).toBe("false"); + expect(host.textContent).not.toContain("Guidance active"); +}); + +test("requires a selected model before enabling native Codex subagent defaults", async () => { + await mount(dash({ + injectionModel: "", + injectionEffort: "", + injectionEfforts: [], + syncCodexSubagentDefaults: false, + })); + + const model = host.querySelector('button[role="combobox"][aria-label="Sub-agent delegation"]')!; + const defaults = host.querySelector('button[aria-label="Use as native Codex subagent defaults"]')!; + expect(model.disabled).toBe(false); + expect(defaults.disabled).toBe(true); +}); + +test("sends the native-default opt-in independently from guidance", async () => { + await mount(dash({ + syncCodexSubagentDefaults: false, + })); + + await act(async () => { + host.querySelector('button[aria-label="Use as native Codex subagent defaults"]')!.click(); + await Promise.resolve(); + }); + + expect(requests).toEqual([{ syncCodexSubagentDefaults: true }]); +}); + +test("sends model clearing through the shared save path", async () => { + await mount(dash()); + + const model = host.querySelector('button[role="combobox"][aria-label="Sub-agent delegation"]')!; + await act(async () => { model.click(); }); + const none = Array.from(document.querySelectorAll('[role="option"]')) + .find(option => option.textContent === "None")!; + await act(async () => { + none.click(); + await Promise.resolve(); + }); + + expect(requests).toEqual([{ model: null, effort: "high" }]); }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 196e52be8..5fc1187f8 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -227,8 +227,9 @@ async function handleStart(options: { block?: boolean } = {}) { let historyGuardian: ReturnType | undefined; let cleaned = false; + let cleanupSucceeded = true; const syncCleanup = () => { - if (cleaned) return; + if (cleaned) return cleanupSucceeded; cleaned = true; try { guardian.stop(); } catch { /* best-effort */ } try { historyGuardian?.stop(); } catch { /* best-effort */ } @@ -236,7 +237,16 @@ async function handleStart(options: { block?: boolean } = {}) { removePid(process.pid); removeRuntimePort(process.pid); if (!process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) { - try { restoreNativeCodex(); } catch { /* best-effort restore */ } + try { + const restored = restoreNativeCodex(); + if (!restored.success) { + cleanupSucceeded = false; + console.error(`⚠️ Native Codex restore failed during shutdown: ${restored.message}`); + } + } catch (error) { + cleanupSucceeded = false; + console.error(`⚠️ Native Codex restore failed during shutdown: ${error instanceof Error ? error.message : String(error)}`); + } } // Same ownership rule as `ocx stop`: if the installed service belongs to another home, the // Grok fence is shared state we must not remove — that service keeps running and would be @@ -245,6 +255,7 @@ async function handleStart(options: { block?: boolean } = {}) { if (!process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()) { try { stripGrokConfig(); } catch { /* best-effort restore */ } } + return cleanupSucceeded; }; let shuttingDown = false; @@ -269,8 +280,8 @@ async function handleStart(options: { block?: boolean } = {}) { try { await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000); } finally { - syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit") - process.exit(0); + const restored = syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit") + process.exit(restored ? 0 : 1); } })(); }; @@ -499,7 +510,11 @@ async function handleStop() { } if (!ownershipBlocked) { const r = restoreNativeCodex(); - console.log(`↩️ ${r.message}`); + if (r.success) console.log(`↩️ ${r.message}`); + else { + stopFailed = true; + console.error(`⚠️ ${r.message}`); + } } // revertSystemEnv is NOT gated: it carries its own ownership check and concerns launchctl // user env, not CODEX_HOME. Safety net for when the daemon's syncCleanup didn't run (SIGKILL). @@ -716,19 +731,40 @@ switch (command) { console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically."); process.exit(1); } - await syncModelsToCodex(live.port); + const synced = await syncModelsToCodex(live.port); + if (!synced.ok) { + process.exitCode = 1; + console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry."); + break; + } const target = collectOrcaCodexHomeDiagnostic(); console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`); break; } - const r = restoreNativeCodex(); - console.log(r.success ? `✅ ${r.message}` : `⚠️ ${r.message}`); + let r: { success: boolean; message: string }; + try { + r = restoreNativeCodex(); + } catch (err) { + r = { success: false, message: err instanceof Error ? err.message : String(err) }; + } + if (r.success) console.log(`✅ ${r.message}`); + else { + console.error(`⚠️ ${r.message}`); + process.exitCode = 1; + } try { const g = stripGrokConfig(); if (g.changed) console.log(`✅ ${g.message}`); - else if (!g.ok) console.error(`⚠️ ${g.message}`); + else if (!g.ok) { + console.error(`⚠️ ${g.message}`); + process.exitCode = 1; + } } catch { /* best-effort */ } - console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back"); + if (r.success) { + console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back"); + } else { + console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex."); + } break; } case "recover-history": @@ -767,7 +803,11 @@ switch (command) { break; } case "sync": { - await syncModelsToCodex((await findLiveProxy())?.port); + const synced = await syncModelsToCodex((await findLiveProxy())?.port); + if (!synced.ok) { + process.exitCode = 1; + console.error("Codex sync did not complete. Fix the reported Codex config issue and retry."); + } break; } case "v2": { diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 6fa7b5c5e..c5692d1f4 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs"; -import { atomicWriteFile, loadConfig, websocketsEnabled } from "../config"; +import { atomicWriteFile, loadConfig, subagentDefaultSyncEffective, websocketsEnabled } from "../config"; import { markJournalInjectedState, removeJournal, restoreJournalState, writeJournal } from "./journal"; import { restoreCodexCatalog } from "./catalog"; import { migrateHistoryToOpenai, syncCodexHistoryProvider } from "./history-provider"; @@ -15,6 +15,10 @@ import { } from "./injected-marker"; import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./paths"; import { resolveEffectiveProjectModelProvider } from "./project-config-warnings"; +import { + transformManagedSubagentDefaults, + type ManagedSubagentDefaults, +} from "./subagent-defaults"; import type { OcxConfig } from "../types"; // Ownership predicates live in `./injected-marker` so `journal.ts` can reach them @@ -68,6 +72,16 @@ export interface InjectCodexOptions { catalogPath?: string | null; } +function configuredManagedSubagentDefaults( + config: Pick | undefined, +): ManagedSubagentDefaults | null { + if (!subagentDefaultSyncEffective(config ?? {})) return null; + return { + model: config!.injectionModel!.trim(), + ...(config!.injectionEffort?.trim() ? { reasoningEffort: config!.injectionEffort.trim() } : {}), + }; +} + /** * The `[model_providers.opencodex]` TABLE only. A table is position-independent in TOML, so it is * safe to append at EOF. The bare root key `model_provider = "opencodex"` is NOT included here — @@ -458,7 +472,13 @@ export function chooseCatalogPathForInjection(content: string, requested?: strin return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null; } -export async function injectCodexConfig(port: number, config?: OcxConfig, options: InjectCodexOptions = {}): Promise<{ success: boolean; message: string }> { +export interface CodexInjectResult { + success: boolean; + message: string; + nativeSubagentDefaultsWarning?: string; +} + +export async function injectCodexConfig(port: number, config?: OcxConfig, options: InjectCodexOptions = {}): Promise { if (!existsSync(CODEX_CONFIG_PATH)) { return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` }; } @@ -469,8 +489,12 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option // A launcher may have journaled before the provider manager took ownership. Never let shutdown // replay that stale snapshot over externally managed config. removeJournal(); + const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults(config) + ? `Native Codex sub-agent defaults were not injected: external model_provider ${tomlString(activeProvider)} owns config.toml.` + : undefined; return { success: true, + ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), message: `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` + ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` + ` Configure that provider for Responses passthrough at http://${providerBaseHost(config?.hostname)}:${port}/v1` + @@ -479,16 +503,31 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option }; } + // Marker-owned native defaults are OpenCodex residue, never part of the + // user's journal baseline. Clean them before either snapshotting or adding a + // root routing key: inserting that key ahead of a marker-owned first table + // would otherwise separate the table marker from its header. Ambiguous + // markers fail closed without writing config, profile, or journal state. + const nativeDefaultsBaseline = transformManagedSubagentDefaults(rawContent, null); + if (!nativeDefaultsBaseline.ok) { + return { + success: false, + message: `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. ` + + `No files were changed; inspect ${CODEX_CONFIG_PATH}.`, + }; + } + const baselineContent = nativeDefaultsBaseline.content; + // Classify and journal the same bytes: a native config is a valid original and // supersedes a stale snapshot (#477), while an injected one must never become // one — that is how opencodex routing would survive `ocx stop`. writeJournal({ currentStateIsNative: !hasInjectedCodexRouting(rawContent), - configContent: rawContent, + configContent: baselineContent, }); // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write. const eol = dominantEol(rawContent); - let content = applyEol(rawContent, "\n"); + let content = applyEol(baselineContent, "\n"); // Idempotent clean-up of any prior injection: drop the provider table (marker-based) and every // stray/mis-nested model_provider line, so re-injecting can't duplicate keys or leave the buggy @@ -526,6 +565,31 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option keptUserBaseUrl = result.keptUserBaseUrl; } + const desiredSubagentDefaults = configuredManagedSubagentDefaults(config); + const routingOwnershipWarning = keptUserBaseUrl && desiredSubagentDefaults + ? "Native Codex sub-agent defaults were not injected: a user-owned root openai_base_url prevents OpenCodex from managing active Codex routing." + : undefined; + const managedDefaults = transformManagedSubagentDefaults( + content, + keptUserBaseUrl ? null : desiredSubagentDefaults, + ); + let nativeSubagentDefaultsWarning = routingOwnershipWarning; + let managedDefaultsMessage = routingOwnershipWarning ? ` ⚠️ ${routingOwnershipWarning}\n` : ""; + if (managedDefaults.ok) { + content = managedDefaults.content; + if (desiredSubagentDefaults && managedDefaults.conflicts.length > 0) { + const keys = managedDefaults.conflicts.map(conflict => `agents.${conflict.key}`).join(", "); + nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults were not injected: user-owned ${keys} preserved.`; + managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; + } + } else { + const action = desiredSubagentDefaults && !keptUserBaseUrl + ? "were not injected" + : "could not be safely removed"; + nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults ${action}: ${managedDefaults.error}.`; + managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; + } + const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), legacyMode, config?.hostname); content = applyEol(content, eol); atomicWriteFile(CODEX_CONFIG_PATH, content); @@ -560,9 +624,11 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option if (keptUserBaseUrl) { return { success: true, + ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), message: `⚠️ Codex routing NOT injected: your config already sets a root openai_base_url, and opencodex never overwrites a user-owned override.\n` + catalogMessage + historyMessage + + managedDefaultsMessage + ` To route plain codex through the proxy, remove your openai_base_url line from ~/.codex/config.toml and rerun 'ocx start'.\n` + ` Reference config: ${CODEX_PROFILE_PATH}`, }; @@ -572,9 +638,11 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option : `Pointed Codex's built-in openai provider at the opencodex proxy (openai_base_url).\n`; return { success: true, + ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), message: headline + catalogMessage + historyMessage + + managedDefaultsMessage + ` All models now route through opencodex proxy (like OpenRouter).\n` + ` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` + ` Custom models route to their configured providers.\n` + @@ -607,8 +675,17 @@ function removeOcxSection(content: string): string { return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; } -/** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */ -export function stripOpencodexConfig(content: string): string { +interface StripOpencodexConfigResult { + content: string; + managedDefaultsError: string | null; +} + +/** + * Detailed form used by the on-disk restore path. A damaged ownership marker is + * ambiguous: keep the associated value, but return the transform error so the + * caller cannot report a complete restore. + */ +function stripOpencodexConfigResult(content: string): StripOpencodexConfigResult { let out = content; const hadRootOcxProvider = readRootTomlString(out, "model_provider") === "opencodex"; const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out); @@ -623,8 +700,18 @@ export function stripOpencodexConfig(content: string): string { // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves // them — strip on both the legacy re-tag form and the Design B injected-base-url form. if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out); + const managedDefaults = transformManagedSubagentDefaults(out, null); + if (managedDefaults.ok) out = managedDefaults.content; out = stripOpencodexCatalogPath(out); - return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; + return { + content: out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n", + managedDefaultsError: !managedDefaults.ok ? managedDefaults.error : null, + }; +} + +/** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */ +export function stripOpencodexConfig(content: string): string { + return stripOpencodexConfigResult(content).content; } function hasOpencodexRouting(content: string): boolean { @@ -635,7 +722,11 @@ function hasOpencodexRouting(content: string): boolean { export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): { success: boolean; message: string } { if (!existsSync(CODEX_CONFIG_PATH)) { - return { success: false, message: "Codex config not found." }; + if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH); + return { + success: true, + message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`, + }; } const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); // Same EOL boundary as inject: strip in LF space, write back in the file's own ending. @@ -643,16 +734,25 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): const eol = dominantEol(rawContent); const content = applyEol(rawContent, "\n"); const had = hasOpencodexRouting(content); - const stripped = stripOpencodexConfig(content); - if (had || stripped !== content) { - atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped, eol)); + const stripped = stripOpencodexConfigResult(content); + if (had || stripped.content !== content) { + atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol)); } if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH); + const removedMessage = had + ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}` + : "opencodex not present in Codex config."; + if (stripped.managedDefaultsError) { + const routingMessage = had ? removedMessage : "No opencodex routing was present in Codex config."; + return { + success: false, + message: `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` + + "The ambiguous marker and adjacent value were preserved; inspect $CODEX_HOME/config.toml before using native Codex.", + }; + } return { success: true, - message: had - ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}` - : "opencodex not present in Codex config.", + message: removedMessage, }; } diff --git a/src/codex/subagent-defaults.ts b/src/codex/subagent-defaults.ts new file mode 100644 index 000000000..060ee0cd3 --- /dev/null +++ b/src/codex/subagent-defaults.ts @@ -0,0 +1,550 @@ +/** + * Pure, ownership-aware edits for Codex's native `[agents]` defaults. + * + * This intentionally does not parse and re-serialize the whole TOML document: + * callers can write the returned content atomically while comments, ordering, + * unknown keys, and line endings remain untouched. Every value and table that + * this transform may later remove has its own immediately preceding marker. + */ + +export const MANAGED_SUBAGENT_DEFAULT_MARKER = "# Managed by opencodex: native subagent default"; +export const MANAGED_AGENTS_TABLE_MARKER = "# Managed by opencodex: native subagent defaults table"; + +export type ManagedSubagentDefaultKey = + | "default_subagent_model" + | "default_subagent_reasoning_effort"; + +export interface ManagedSubagentDefaults { + model: string; + reasoningEffort?: string; +} + +export interface ManagedSubagentDefaultsConflict { + key: ManagedSubagentDefaultKey; + line: number; + reason: "user-owned"; +} + +export type ManagedSubagentDefaultsTransformResult = + | { + ok: true; + changed: boolean; + content: string; + conflicts: ManagedSubagentDefaultsConflict[]; + } + | { + ok: false; + changed: false; + content: string; + conflicts: []; + error: string; + }; + +interface SourceLine { + text: string; + eol: "\r\n" | "\n" | ""; + /** False when this physical line began inside a TOML multiline string. */ + structural: boolean; +} + +interface TargetDefinition { + key: ManagedSubagentDefaultKey; + index: number; + owned: boolean; +} + +interface TomlShape { + agentsHeader: number | null; + agentsEnd: number | null; + tableOwned: boolean; + definitions: Map; +} + +const TARGET_KEYS: readonly ManagedSubagentDefaultKey[] = [ + "default_subagent_model", + "default_subagent_reasoning_effort", +]; + +function splitSourceLines(content: string): SourceLine[] { + const lines: SourceLine[] = []; + let offset = 0; + while (offset < content.length) { + const lf = content.indexOf("\n", offset); + if (lf === -1) { + lines.push({ text: content.slice(offset), eol: "", structural: true }); + break; + } + const crlf = lf > offset && content[lf - 1] === "\r"; + lines.push({ + text: content.slice(offset, crlf ? lf - 1 : lf), + eol: crlf ? "\r\n" : "\n", + structural: true, + }); + offset = lf + 1; + } + markStructuralLines(lines); + return lines; +} + +type MultilineStringKind = "basic" | "literal" | null; + +/** + * TOML table-looking text inside a multiline string is data, not syntax. Keep + * a deliberately small lexical state machine so the format-preserving editor + * never treats those physical lines as headers, keys, or ownership markers. + */ +function markStructuralLines(lines: SourceLine[]): void { + let multiline: MultilineStringKind = null; + let squareDepth = 0; + let curlyDepth = 0; + + for (const line of lines) { + line.structural = multiline === null && squareDepth === 0 && curlyDepth === 0; + let single: "basic" | "literal" | null = null; + + for (let index = 0; index < line.text.length;) { + if (multiline === "basic") { + if (line.text.startsWith('"""', index)) { + multiline = null; + index += 3; + } else if (line.text[index] === "\\") { + index += 2; + } else { + index += 1; + } + continue; + } + if (multiline === "literal") { + if (line.text.startsWith("'''", index)) { + multiline = null; + index += 3; + } else { + index += 1; + } + continue; + } + if (single === "basic") { + if (line.text[index] === "\\") index += 2; + else if (line.text[index] === '"') { + single = null; + index += 1; + } else index += 1; + continue; + } + if (single === "literal") { + if (line.text[index] === "'") single = null; + index += 1; + continue; + } + + if (line.text[index] === "#") break; + if (line.text.startsWith('"""', index)) { + multiline = "basic"; + index += 3; + } else if (line.text.startsWith("'''", index)) { + multiline = "literal"; + index += 3; + } else if (line.text[index] === '"') { + single = "basic"; + index += 1; + } else if (line.text[index] === "'") { + single = "literal"; + index += 1; + } else if (line.text[index] === "[") { + squareDepth += 1; + index += 1; + } else if (line.text[index] === "]") { + squareDepth = Math.max(0, squareDepth - 1); + index += 1; + } else if (line.text[index] === "{") { + curlyDepth += 1; + index += 1; + } else if (line.text[index] === "}") { + curlyDepth = Math.max(0, curlyDepth - 1); + index += 1; + } else { + index += 1; + } + } + } +} + +function joinSourceLines(lines: readonly SourceLine[]): string { + return lines.map(line => `${line.text}${line.eol}`).join(""); +} + +function dominantEol(lines: readonly SourceLine[]): "\r\n" | "\n" { + let crlf = 0; + let lf = 0; + for (const line of lines) { + if (line.eol === "\r\n") crlf += 1; + else if (line.eol === "\n") lf += 1; + } + return crlf > 0 && crlf >= lf ? "\r\n" : "\n"; +} + +/** Decode a TOML basic-string key body, including Unicode escapes. */ +function decodeTomlBasicKey(body: string): string { + return body.replace( + /\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|.)/g, + (whole, escape: string) => { + if (escape[0] === "x") return String.fromCharCode(Number.parseInt(escape.slice(1), 16)); + if (escape[0] === "u") return String.fromCharCode(Number.parseInt(escape.slice(1), 16)); + if (escape[0] === "U") { + const codePoint = Number.parseInt(escape.slice(1), 16); + return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : whole; + } + switch (escape) { + case "b": return "\b"; + case "t": return "\t"; + case "n": return "\n"; + case "f": return "\f"; + case "r": return "\r"; + case '"': return '"'; + case "\\": return "\\"; + default: return whole; + } + }, + ); +} + +function canonicalKeySegment(raw: string): string { + if (raw.startsWith('"')) return decodeTomlBasicKey(raw.slice(1, -1)); + if (raw.startsWith("'")) return raw.slice(1, -1); + return raw; +} + +const KEY_SEGMENT = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`; +const EXACT_TABLE_HEADER = new RegExp(`^\\s*\\[\\s*(${KEY_SEGMENT})\\s*\\]\\s*(?:#.*)?$`); +const ARRAY_TABLE_HEADER = new RegExp(`^\\s*\\[\\[\\s*(${KEY_SEGMENT})\\s*\\]\\]\\s*(?:#.*)?$`); +const DOTTED_TABLE_HEADER = new RegExp(`^\\s*\\[\\[?\\s*(${KEY_SEGMENT})\\s*\\.\\s*(${KEY_SEGMENT})(?:\\s*\\.|\\s*\\]\\]?)`); +const KEY_ASSIGNMENT = new RegExp(`^\\s*(${KEY_SEGMENT})\\s*=`); +const DOTTED_ASSIGNMENT = new RegExp(`^\\s*(${KEY_SEGMENT})\\s*\\.\\s*(${KEY_SEGMENT})(?:\\s*\\.|\\s*=)`); +const ANY_TABLE_HEADER = /^\s*\[{1,2}/; + +function exactAgentsHeader(line: SourceLine | undefined): boolean { + if (!line?.structural) return false; + const match = line.text.match(EXACT_TABLE_HEADER); + return match !== null && canonicalKeySegment(match[1]!) === "agents"; +} + +function arrayAgentsHeader(line: SourceLine): boolean { + if (!line.structural) return false; + const match = line.text.match(ARRAY_TABLE_HEADER); + return match !== null && canonicalKeySegment(match[1]!) === "agents"; +} + +function dottedAgentsHeader(line: SourceLine): { second: string } | null { + if (!line.structural) return null; + const match = line.text.match(DOTTED_TABLE_HEADER); + if (!match || canonicalKeySegment(match[1]!) !== "agents") return null; + return { second: canonicalKeySegment(match[2]!) }; +} + +function isAnyTableHeader(line: SourceLine): boolean { + return line.structural && ANY_TABLE_HEADER.test(line.text); +} + +function assignmentKeyAt(line: SourceLine): string | null { + if (!line.structural) return null; + const match = line.text.match(KEY_ASSIGNMENT); + return match ? canonicalKeySegment(match[1]!) : null; +} + +function markerLine(line: SourceLine | undefined, marker: string): boolean { + return line?.structural === true && line.text.trim() === marker; +} + +function targetKeyAt(line: SourceLine | undefined): ManagedSubagentDefaultKey | null { + if (!line) return null; + const key = assignmentKeyAt(line); + return TARGET_KEYS.includes(key as ManagedSubagentDefaultKey) + ? key as ManagedSubagentDefaultKey + : null; +} + +function dottedAssignmentAt(line: SourceLine): { first: string; second: string } | null { + if (!line.structural) return null; + const match = line.text.match(DOTTED_ASSIGNMENT); + if (!match) return null; + return { + first: canonicalKeySegment(match[1]!), + second: canonicalKeySegment(match[2]!), + }; +} + +function dottedTargetAt(line: SourceLine): ManagedSubagentDefaultKey | null { + const dotted = dottedAssignmentAt(line); + if (!dotted) return null; + return TARGET_KEYS.includes(dotted.first as ManagedSubagentDefaultKey) + ? dotted.first as ManagedSubagentDefaultKey + : null; +} + +function analyzeToml(lines: readonly SourceLine[]): { shape: TomlShape } | { error: string } { + const exactHeaders: number[] = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + if (exactAgentsHeader(line)) exactHeaders.push(index); + else if (arrayAgentsHeader(line)) { + return { error: "array [[agents]] tables are not supported for managed subagent defaults" }; + } else if (TARGET_KEYS.includes(dottedAgentsHeader(line)?.second as ManagedSubagentDefaultKey)) { + return { error: "agents default keys cannot be represented as nested tables" }; + } + } + if (exactHeaders.length > 1) { + return { error: "duplicate [agents] tables cannot be updated safely" }; + } + + const firstTable = lines.findIndex(isAnyTableHeader); + const rootEnd = firstTable === -1 ? lines.length : firstTable; + for (let index = 0; index < rootEnd; index += 1) { + const line = lines[index]!; + const dotted = dottedAssignmentAt(line); + if (dotted?.first === "agents" + && TARGET_KEYS.includes(dotted.second as ManagedSubagentDefaultKey)) { + return { error: "dotted agents default keys are not supported for managed subagent defaults" }; + } + if (assignmentKeyAt(line) === "agents") { + return { error: "inline agents definitions cannot be updated safely" }; + } + } + + const agentsHeader = exactHeaders[0] ?? null; + let agentsEnd: number | null = null; + const definitions = new Map(); + if (agentsHeader !== null) { + agentsEnd = lines.length; + for (let index = agentsHeader + 1; index < lines.length; index += 1) { + if (isAnyTableHeader(lines[index]!)) { + agentsEnd = index; + break; + } + const dotted = dottedTargetAt(lines[index]!); + if (dotted) { + return { error: `dotted agents.${dotted} fields are not supported` }; + } + const key = targetKeyAt(lines[index]); + if (!key) continue; + if (definitions.has(key)) { + return { error: `duplicate agents.${key} definitions cannot be updated safely` }; + } + definitions.set(key, { + key, + index, + owned: markerLine(lines[index - 1], MANAGED_SUBAGENT_DEFAULT_MARKER), + }); + } + } + + for (let index = 0; index < lines.length; index += 1) { + if (markerLine(lines[index], MANAGED_SUBAGENT_DEFAULT_MARKER)) { + const nextKey = targetKeyAt(lines[index + 1]); + const insideAgents = agentsHeader !== null + && agentsEnd !== null + && index > agentsHeader + && index + 1 < agentsEnd; + if (!nextKey || !insideAgents) { + return { error: "orphaned managed subagent default marker cannot be updated safely" }; + } + } + if (markerLine(lines[index], MANAGED_AGENTS_TABLE_MARKER)) { + if (index + 1 !== agentsHeader) { + return { error: "orphaned managed agents table marker cannot be updated safely" }; + } + } + } + + return { + shape: { + agentsHeader, + agentsEnd, + tableOwned: agentsHeader !== null && markerLine(lines[agentsHeader - 1], MANAGED_AGENTS_TABLE_MARKER), + definitions, + }, + }; +} + +function quotedTomlString(value: string): string { + // JSON basic strings are valid TOML basic strings and cover quotes, + // backslashes, control characters, and newlines. JSON leaves DEL literal, + // while TOML requires it escaped. + return JSON.stringify(value).replace(/\u007f/g, "\\u007F"); +} + +function containsLoneSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return true; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true; + } + } + return false; +} + +function replaceManagedString(line: string, key: ManagedSubagentDefaultKey, value: string): string | null { + const stringValue = `(?:"(?:\\\\.|[^"\\\\])*"|'[^']*')`; + const match = line.match(new RegExp(`^(\\s*)(${KEY_SEGMENT})(\\s*=\\s*)(${stringValue})(\\s*(?:#.*)?)$`)); + if (!match || canonicalKeySegment(match[2]!) !== key) return null; + const quoted = quotedTomlString(value); + if (match[4] === quoted) return line; + return `${match[1]}${match[2]}${match[3]}${quoted}${match[5]}`; +} + +function insertedLines( + key: ManagedSubagentDefaultKey, + value: string, + eol: "\r\n" | "\n", +): SourceLine[] { + return [ + { text: MANAGED_SUBAGENT_DEFAULT_MARKER, eol, structural: true }, + { text: `${key} = ${quotedTomlString(value)}`, eol, structural: true }, + ]; +} + +function invalidInput(content: string, error: string): ManagedSubagentDefaultsTransformResult { + return { ok: false, changed: false, content, conflicts: [], error }; +} + +/** + * Add/update opencodex-owned native subagent defaults, or remove them with + * `defaults = null`. Unmarked target keys are user-owned: they are retained and + * returned as conflicts rather than overwritten. Ambiguous TOML is rejected + * byte-for-byte so callers never have to guess what was changed. + */ +export function transformManagedSubagentDefaults( + content: string, + defaults: ManagedSubagentDefaults | null, +): ManagedSubagentDefaultsTransformResult { + if (defaults !== null) { + if (typeof defaults.model !== "string" || defaults.model.length === 0) { + return invalidInput(content, "managed subagent model must be a non-empty string"); + } + if (containsLoneSurrogate(defaults.model)) { + return invalidInput(content, "managed subagent model must contain valid Unicode scalar values"); + } + if (defaults.reasoningEffort !== undefined + && (typeof defaults.reasoningEffort !== "string" || defaults.reasoningEffort.length === 0)) { + return invalidInput(content, "managed subagent reasoning effort must be a non-empty string when supplied"); + } + if (defaults.reasoningEffort !== undefined && containsLoneSurrogate(defaults.reasoningEffort)) { + return invalidInput(content, "managed subagent reasoning effort must contain valid Unicode scalar values"); + } + } + + const lines = splitSourceLines(content); + if (defaults === null && !lines.some(line => + markerLine(line, MANAGED_SUBAGENT_DEFAULT_MARKER) + || markerLine(line, MANAGED_AGENTS_TABLE_MARKER))) { + return { ok: true, changed: false, content, conflicts: [] }; + } + const analysis = analyzeToml(lines); + if ("error" in analysis) return invalidInput(content, analysis.error); + const { shape } = analysis; + const eol = dominantEol(lines); + const desired = new Map(); + if (defaults !== null) { + desired.set("default_subagent_model", defaults.model); + if (defaults.reasoningEffort !== undefined) { + desired.set("default_subagent_reasoning_effort", defaults.reasoningEffort); + } + } + + const conflicts: ManagedSubagentDefaultsConflict[] = []; + const missing: Array<[ManagedSubagentDefaultKey, string]> = []; + const removals: number[] = []; + + if (defaults !== null) { + for (const key of TARGET_KEYS) { + const definition = shape.definitions.get(key); + if (definition && !definition.owned) { + conflicts.push({ key, line: definition.index + 1, reason: "user-owned" }); + } + } + // Model and effort are one effective native-default pair. A partial write + // could apply a newly managed effort to a user's model (or vice versa), so + // any desired-key ownership conflict makes the whole enable/update a no-op. + if (conflicts.length > 0) { + return { ok: true, changed: false, content, conflicts }; + } + } + + for (const key of TARGET_KEYS) { + const definition = shape.definitions.get(key); + const value = desired.get(key); + if (!definition) { + if (value !== undefined) missing.push([key, value]); + continue; + } + if (!definition.owned) { + continue; + } + if (replaceManagedString(lines[definition.index]!.text, key, value ?? "__opencodex_validation__") === null) { + return invalidInput(content, `managed agents.${key} is not a supported single-line TOML string`); + } + if (value === undefined) { + removals.push(definition.index - 1, definition.index); + continue; + } + const replacement = replaceManagedString(lines[definition.index]!.text, key, value); + if (replacement === null) { + return invalidInput(content, `managed agents.${key} is not a supported single-line TOML string`); + } + lines[definition.index]!.text = replacement; + } + + removals.sort((a, b) => b - a); + for (const index of removals) lines.splice(index, 1); + + if (missing.length > 0) { + const pairs = missing.flatMap(([key, value]) => insertedLines(key, value, eol)); + const currentHeader = lines.findIndex(exactAgentsHeader); + if (currentHeader !== -1) { + if (lines[currentHeader]!.eol === "") lines[currentHeader]!.eol = eol; + lines.splice(currentHeader + 1, 0, ...pairs); + } else { + const table = [ + { text: MANAGED_AGENTS_TABLE_MARKER, eol, structural: true }, + { text: "[agents]", eol, structural: true }, + ...pairs, + ]; + const nestedHeader = lines.findIndex(line => dottedAgentsHeader(line) !== null); + if (nestedHeader !== -1) { + lines.splice(nestedHeader, 0, ...table); + } else { + if (lines.length > 0 && lines[lines.length - 1]!.eol === "") lines[lines.length - 1]!.eol = eol; + lines.push(...table); + } + } + } + + // A table created by this transform is removable only after every body line + // is blank. Comments or unknown keys make it user-extended and preserve it. + if (shape.tableOwned) { + const currentHeader = lines.findIndex(exactAgentsHeader); + if (currentHeader !== -1 && markerLine(lines[currentHeader - 1], MANAGED_AGENTS_TABLE_MARKER)) { + let currentEnd = lines.length; + for (let index = currentHeader + 1; index < lines.length; index += 1) { + if (isAnyTableHeader(lines[index]!)) { + currentEnd = index; + break; + } + } + if (lines.slice(currentHeader + 1, currentEnd).every(line => line.text.trim() === "")) { + lines.splice(currentHeader - 1, currentEnd - currentHeader + 1); + } else if (defaults === null) { + // The table outlived the defaults because a user added content. Drop + // only our ownership claim; the surviving table and extensions are now + // wholly user-owned. + lines.splice(currentHeader - 1, 1); + } + } + } + + const output = joinSourceLines(lines); + return { ok: true, changed: output !== content, content: output, conflicts }; +} diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 27040f92d..219d3f6a2 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -16,6 +16,7 @@ export interface CodexSyncResult { message: string; warning?: string; comboOmissions?: ComboCatalogOmission[]; + nativeSubagentDefaultsWarning?: string; projectConfigWarnings?: ProjectCodexConfigWarning[]; projectConfigGrouped?: { path: string; issues: string[]; bypass: string }[]; } @@ -65,6 +66,7 @@ export async function syncModelsToCodex( catalogWritten: false, cacheSynced: false, message: result.message, + ...(result.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: result.nativeSubagentDefaultsWarning } : {}), }; } @@ -119,6 +121,7 @@ export async function syncModelsToCodex( message: result.message, ...(warning ? { warning } : {}), ...(comboOmissions.length > 0 ? { comboOmissions } : {}), + ...(result.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: result.nativeSubagentDefaultsWarning } : {}), ...(projectConfigWarnings.length > 0 ? { projectConfigWarnings, projectConfigGrouped: groupProjectCodexConfigWarningsByPath(projectConfigWarnings), diff --git a/src/config.ts b/src/config.ts index c056e1954..68adef296 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,7 +17,7 @@ import { } from "./types"; import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers"; import { parseDesktopProfile } from "./claude/desktop-profile"; -import { modelRecordValue } from "./reasoning-effort"; +import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; let _atomicSeq = 0; @@ -515,6 +515,13 @@ const configSchema = z.object({ providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), + // These selections pre-date schema validation and used to pass through as + // unknown fields. Invalid hand edits must disable only the optional + // delegation/native-default feature, not reject the whole config and hide + // otherwise valid providers, accounts, or the configured listen port. + injectionModel: z.string().optional().catch(undefined), + injectionEffort: z.string().optional().catch(undefined), + syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), @@ -773,6 +780,64 @@ function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void } } +type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"; + +function rawConfigRecord(rawParsed: unknown): Record | null { + return rawParsed !== null && typeof rawParsed === "object" && !Array.isArray(rawParsed) + ? rawParsed as Record + : null; +} + +function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] { + const raw = rawConfigRecord(rawParsed); + if (!raw) return []; + const malformed: NativeSubagentPersistedField[] = []; + if (Object.hasOwn(raw, "injectionModel") && typeof raw.injectionModel !== "string") { + malformed.push("injectionModel"); + } + if (Object.hasOwn(raw, "injectionEffort") && typeof raw.injectionEffort !== "string") { + malformed.push("injectionEffort"); + } + if (Object.hasOwn(raw, "syncCodexSubagentDefaults") && typeof raw.syncCodexSubagentDefaults !== "boolean") { + malformed.push("syncCodexSubagentDefaults"); + } + return malformed; +} + +function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField): string { + const expected = field === "syncCodexSubagentDefaults" ? "a boolean" : "a string"; + return `${field} ignored: expected ${expected}`; +} + +function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null { + if (config.syncCodexSubagentDefaults !== true) return null; + const malformed = malformedNativeSubagentFields(rawParsed); + if (malformed.includes("injectionModel")) return "injectionModel must be a string"; + if (!config.injectionModel?.trim()) return "a nonblank injectionModel is required"; + if (malformed.includes("injectionEffort")) return "injectionEffort must be a string or omitted"; + if (config.injectionEffort !== undefined && !isCodexReasoningEffort(config.injectionEffort)) { + return "injectionEffort must be a supported Codex reasoning effort"; + } + return null; +} + +function normalizeNativeSubagentSync(config: OcxConfig, rawParsed?: unknown): OcxConfig { + if (!nativeSubagentSyncDisabledReason(config, rawParsed)) return config; + const normalized = { ...config }; + delete normalized.syncCodexSubagentDefaults; + return normalized; +} + +function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig): void { + for (const field of malformedNativeSubagentFields(rawParsed)) { + console.warn(`⚠️ config.json ${malformedNativeSubagentFieldWarning(field)}. Other settings were preserved.`); + } + const reason = nativeSubagentSyncDisabledReason(config, rawParsed); + if (reason) { + console.warn(`⚠️ config.json syncCodexSubagentDefaults was disabled: ${reason}. Other settings were preserved.`); + } +} + export function loadConfig(): OcxConfig { const dir = getConfigDir(); const configPath = getConfigPath(); @@ -787,8 +852,10 @@ export function loadConfig(): OcxConfig { const parsed = JSON.parse(raw); const result = configSchema.safeParse(parsed); if (result.success) { - warnDegradedStreamMode(parsed, result.data as OcxConfig); - return result.data as OcxConfig; + const config = result.data as OcxConfig; + warnDegradedStreamMode(parsed, config); + warnDegradedNativeSubagentConfig(parsed, config); + return normalizeNativeSubagentSync(config, parsed); } // Schema validation failed — merge defaults into the raw object instead of // discarding it entirely, so pool accounts and providers survive a missing @@ -802,7 +869,9 @@ export function loadConfig(): OcxConfig { const retryResult = configSchema.safeParse(merged); if (retryResult.success) { warnConfigRepaired(configPath, result.error); - return retryResult.data as OcxConfig; + const config = retryResult.data as OcxConfig; + warnDegradedNativeSubagentConfig(parsed, config); + return normalizeNativeSubagentSync(config, parsed); } // Merge couldn't fix it — truly broken config warnAndBackupInvalidConfig(configPath, result.error); @@ -832,16 +901,31 @@ function configPlaceholderWarnings(config: OcxConfig): string[] { return warnings; } -function validFileConfigDiagnostics(config: OcxConfig): ConfigDiagnostics { - const warnings = configPlaceholderWarnings(config); +function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): ConfigDiagnostics { + // An unsafe hand-edited opt-in is disabled in memory instead of rejecting + // the entire config, which would hide unrelated providers/accounts. The next + // ordinary save persists the normalized absence. + const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed); + const normalized = normalizeNativeSubagentSync(config, rawParsed); + const warnings = configPlaceholderWarnings(normalized); + warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning)); + if (syncDisabledReason) { + warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); + } return { - config, + config: normalized, source: "file", error: null, ...(warnings.length > 0 ? { warnings } : {}), }; } +export function subagentDefaultSyncEffective( + config: Pick, +): boolean { + return config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim()); +} + function mergeConfigDefaults(parsed: unknown): unknown { if (!parsed || typeof parsed !== "object") return parsed; const defaults = getDefaultConfig(); @@ -878,12 +962,12 @@ export function readConfigDiagnostics(): ConfigDiagnostics { const parsed = JSON.parse(raw); const result = configSchema.safeParse(parsed); if (result.success) { - return validFileConfigDiagnostics(result.data as OcxConfig); + return validFileConfigDiagnostics(result.data as OcxConfig, parsed); } const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed)); if (retryResult.success) { - return validFileConfigDiagnostics(retryResult.data as OcxConfig); + return validFileConfigDiagnostics(retryResult.data as OcxConfig, parsed); } return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) }; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 4108a3bb7..96bb39c99 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -11,6 +11,7 @@ import { providerBaseUrlConfigError, providerHeadersConfigError, saveConfigPreservingClaudeCode, + subagentDefaultSyncEffective, } from "../../config"; import { clearLoginState, @@ -190,6 +191,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise ))); return jsonResponse({ multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config), + syncCodexSubagentDefaults: subagentDefaultSyncEffective(config), model: config.injectionModel ?? null, effort: config.injectionEffort ?? null, prompt: config.injectionPrompt ?? null, @@ -207,6 +209,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } const body = parsedBody as { multiAgentGuidanceEnabled?: unknown; + syncCodexSubagentDefaults?: unknown; model?: unknown; effort?: unknown; prompt?: unknown; @@ -214,6 +217,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const { isCodexReasoningEffort } = await import("../../reasoning-effort"); let nextEnabled = config.multiAgentGuidanceEnabled; + // Start from the effective state reported by GET. A stale hand-edited + // `true` without a model must not spring back on during a model-only PUT. + let nextSyncCodexSubagentDefaults = subagentDefaultSyncEffective(config); let nextModel = config.injectionModel; let nextEffort = config.injectionEffort; let nextPrompt = config.injectionPrompt; @@ -224,10 +230,16 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } nextEnabled = body.multiAgentGuidanceEnabled; } + if ("syncCodexSubagentDefaults" in body) { + if (typeof body.syncCodexSubagentDefaults !== "boolean") { + return jsonResponse({ error: "syncCodexSubagentDefaults must be a boolean" }, 400); + } + nextSyncCodexSubagentDefaults = body.syncCodexSubagentDefaults; + } if ("model" in body) { if (body.model === null || body.model === "") nextModel = undefined; - else if (typeof body.model === "string" && body.model.length > 0) nextModel = body.model; - else return jsonResponse({ error: "model must be a non-empty string or null" }, 400); + else if (typeof body.model === "string" && body.model.trim().length > 0) nextModel = body.model; + else return jsonResponse({ error: "model must be a nonblank string or null" }, 400); } if ("effort" in body) { if (body.effort === null || body.effort === "") nextEffort = undefined; @@ -242,10 +254,21 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise else if (body.prompt === null || body.prompt === "") nextPrompt = undefined; else return jsonResponse({ error: "prompt must be a string or null" }, 400); } - // Clearing the model always clears the effort (it is meaningless alone). - if (!nextModel) nextEffort = undefined; + // Clearing the model always clears model-dependent settings before sync/effort gates. + if (!nextModel) { + nextEffort = undefined; + nextSyncCodexSubagentDefaults = false; + } + if (body.syncCodexSubagentDefaults === true && !nextModel?.trim()) { + return jsonResponse({ error: "syncCodexSubagentDefaults requires an injection model" }, 400); + } + if (nextSyncCodexSubagentDefaults && nextEffort !== undefined && !isCodexReasoningEffort(nextEffort)) { + return jsonResponse({ error: "syncCodexSubagentDefaults requires a supported Codex reasoning effort" }, 400); + } config.multiAgentGuidanceEnabled = nextEnabled; + if (nextSyncCodexSubagentDefaults) config.syncCodexSubagentDefaults = true; + else delete config.syncCodexSubagentDefaults; if (nextModel) config.injectionModel = nextModel; else delete config.injectionModel; if (nextEffort) config.injectionEffort = nextEffort; @@ -257,6 +280,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ ok: true, multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config), + syncCodexSubagentDefaults: subagentDefaultSyncEffective(config), model: config.injectionModel ?? null, effort: config.injectionEffort ?? null, prompt: config.injectionPrompt ?? null, diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index db829c13e..c7a119771 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -228,6 +228,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise`. | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. Login accepts `addAccount: true` to force a fresh browser identity. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | Expose API-key provider presets for setup and dashboard flows. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. | -| Subagents | Read/write the featured `subagentModels` list capped at five ids. | +| Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), and the logical maximum thread count. Selecting `v2` enables the native flag and migrates `[agents] max_threads` to the v2 key; selecting `v1` disables it and migrates the same value back. `default` leaves the native flag unchanged. PUT accepts `enabled`, `multiAgentMode`, and/or the compatibility-named `maxConcurrentThreadsPerSession`; contradictory mode/flag pairs are rejected before writes. Every transition is rollback-safe and resyncs the catalog. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from `~/.opencodex/usage.jsonl`; measured / reported / unreported / unsupported / estimated counts, daily zero-filled grid, model and provider breakdowns. Never exposes prompts. | diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index 209661179..42a2fc2f6 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); const helpSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "help.ts"), "utf8"); +const repoRoot = join(import.meta.dir, ".."); describe("ocx restore back", () => { test("restore/eject accept `back` to re-point codex at the RUNNING proxy only", () => { @@ -15,9 +18,54 @@ describe("ocx restore back", () => { expect(restoreCase).toContain("await findLiveProxy()"); expect(restoreCase).toContain("await syncModelsToCodex(live.port)"); expect(restoreCase.indexOf("findLiveProxy()")).toBeLessThan(restoreCase.indexOf("syncModelsToCodex(live.port)")); + expect(restoreCase).toContain("if (!synced.ok)"); + expect(restoreCase.indexOf("if (!synced.ok)")).toBeLessThan(restoreCase.indexOf("target.effectiveCodexHome")); expect(restoreCase).toContain("target.effectiveCodexHome"); - // The forward switch (plain `ocx restore`) is unchanged. + // The forward switch reports incomplete marker cleanup instead of claiming native success. expect(restoreCase).toContain("restoreNativeCodex()"); + expect(restoreCase).toContain("process.exitCode = 1"); + expect(restoreCase).toContain("was not fully restored"); + }); + + test("sync propagates injection refusal as a nonzero CLI result", () => { + const syncCase = cliSource.slice(cliSource.indexOf('case "sync":'), cliSource.indexOf('case "v2":')); + + expect(syncCase).toContain("await syncModelsToCodex"); + expect(syncCase).toContain("if (!synced.ok)"); + expect(syncCase).toContain("process.exitCode = 1"); + }); + + test("sync exits nonzero when managed-default cleanup is ambiguous", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-cli-sync-codex-")); + const ocxHome = mkdtempSync(join(tmpdir(), "ocx-cli-sync-home-")); + try { + writeFileSync(join(codexHome, "config.toml"), [ + "# Managed by opencodex: native subagent defaults table", + "[agents]", + "# Managed by opencodex: native subagent default", + "", + 'default_subagent_model = "gpt-5.6-sol"', + "", + ].join("\n"), "utf8"); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + providers: {}, + defaultProvider: "openai", + checkForUpdates: false, + }), "utf8"); + + const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "sync"], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, + encoding: "utf8", + }); + + expect(result.status).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("Codex config injection refused"); + expect(result.stderr).toContain("Codex sync did not complete"); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + rmSync(ocxHome, { recursive: true, force: true }); + } }); test("help documents both directions of the switch", () => { diff --git a/tests/codex-inject-integration.test.ts b/tests/codex-inject-integration.test.ts index 51a2da347..d66de5636 100644 --- a/tests/codex-inject-integration.test.ts +++ b/tests/codex-inject-integration.test.ts @@ -5,6 +5,10 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Database } from "bun:sqlite"; +import { + MANAGED_AGENTS_TABLE_MARKER, + MANAGED_SUBAGENT_DEFAULT_MARKER, +} from "../src/codex/subagent-defaults"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); @@ -96,6 +100,124 @@ describe("injectCodexConfig integration (Design B)", () => { expect(second).toBe(first); }); + test("opt-in injects native subagent defaults, removes them when disabled, and restores the native config", () => { + const original = [ + 'model = "gpt-5.5"', + "", + "[notice]", + "hide = true", + "", + ].join("\n"); + writeFileSync(join(codexHome, "config.toml"), original, "utf8"); + const enabled = JSON.stringify({ + syncCodexSubagentDefaults: true, + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + }); + + expect(runInject(codexHome, ocxHome, enabled).status).toBe(0); + const injected = readFileSync(join(codexHome, "config.toml"), "utf8"); + const profile = readFileSync(join(codexHome, "opencodex.config.toml"), "utf8"); + expect(injected).toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(injected).toContain('default_subagent_model = "gpt-5.6-sol"'); + expect(injected).toContain('default_subagent_reasoning_effort = "high"'); + expect(injected).toContain(MANAGED_AGENTS_TABLE_MARKER); + expect(profile).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(profile).not.toContain("default_subagent_model"); + + expect(runInject(codexHome, ocxHome, "{}").status).toBe(0); + const disabled = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(disabled).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(disabled).not.toContain("default_subagent_model"); + expect(disabled).not.toContain("default_subagent_reasoning_effort"); + expect(disabled).toContain("[notice]\nhide = true"); + + expect(runInject(codexHome, ocxHome, enabled).status).toBe(0); + expect(runRestore(codexHome, ocxHome).status).toBe(0); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + }); + + test("opt-in preserves a user-owned native default pair and reports the conflict", () => { + const original = [ + 'model = "gpt-5.5"', + "", + "[agents]", + 'default_subagent_model = "user/model" # owned by user', + 'default_subagent_reasoning_effort = "medium"', + "max_threads = 6", + "", + ].join("\n"); + writeFileSync(join(codexHome, "config.toml"), original, "utf8"); + + const result = runInject(codexHome, ocxHome, JSON.stringify({ + syncCodexSubagentDefaults: true, + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + })); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).message).toContain("user-owned agents.default_subagent_model"); + + const injected = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(injected).toContain('default_subagent_model = "user/model" # owned by user'); + expect(injected).toContain('default_subagent_reasoning_effort = "medium"'); + expect(injected).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(injected).not.toContain('default_subagent_model = "gpt-5.6-sol"'); + }); + + test("sync-disabled injection cleans managed-default residue before journaling and restore", () => { + const residue = [ + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "stale/routed-model"', + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_reasoning_effort = "high"', + "", + "[features]", + "fast_mode = true", + "", + ].join("\n"); + writeFileSync(join(codexHome, "config.toml"), residue, "utf8"); + + const injectedResult = runInject(codexHome, ocxHome, "{}"); + expect(injectedResult.status).toBe(0); + expect(JSON.parse(injectedResult.stdout).success).toBe(true); + const injected = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(injected).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(injected).not.toContain("default_subagent_model"); + expect(() => Bun.TOML.parse(injected)).not.toThrow(); + + const restoredResult = runRestore(codexHome, ocxHome); + expect(restoredResult.status).toBe(0); + expect(JSON.parse(restoredResult.stdout).success).toBe(true); + const restored = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(restored).not.toContain(MANAGED_AGENTS_TABLE_MARKER); + expect(restored).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(restored).not.toContain("default_subagent_model"); + expect(restored).toContain("[features]\nfast_mode = true"); + }); + + test("ambiguous managed-default residue refuses injection without changing files", () => { + const ambiguous = [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + "", + 'default_subagent_model = "stale/routed-model"', + "", + ].join("\n"); + writeFileSync(join(codexHome, "config.toml"), ambiguous, "utf8"); + + const result = runInject(codexHome, ocxHome, "{}"); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.success).toBe(false); + expect(payload.message).toContain("injection refused"); + expect(payload.message).toContain("orphaned managed subagent default marker"); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(ambiguous); + expect(existsSync(join(codexHome, "opencodex.config.toml"))).toBe(false); + expect(existsSync(join(codexHome, "opencodex-journal.json"))).toBe(false); + }); + test("kept-user-base-url: reports routing NOT injected and leaves the user's override alone", () => { writeFileSync(join(codexHome, "config.toml"), [ 'openai_base_url = "https://my-own-gateway.example/v1"', @@ -103,16 +225,23 @@ describe("injectCodexConfig integration (Design B)", () => { "", ].join("\n"), "utf8"); - const r = runInject(codexHome, ocxHome); + const r = runInject(codexHome, ocxHome, JSON.stringify({ + syncCodexSubagentDefaults: true, + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + })); expect(r.status).toBe(0); const result = JSON.parse(r.stdout); expect(result.success).toBe(true); expect(result.message).toContain("routing NOT injected"); expect(result.message).not.toContain("All models now route through opencodex proxy"); + expect(result.nativeSubagentDefaultsWarning).toContain("user-owned root openai_base_url"); const config = readFileSync(join(codexHome, "config.toml"), "utf8"); expect(config).toContain('openai_base_url = "https://my-own-gateway.example/v1"'); expect(config).not.toContain("# Auto-injected by opencodex\nopenai_base_url"); + expect(config).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(config).not.toContain("default_subagent_model"); }); test("external model provider stays byte-for-byte unchanged so its session history remains visible", () => { @@ -158,7 +287,11 @@ describe("injectCodexConfig integration (Design B)", () => { timestamp: new Date().toISOString(), }), "utf8"); - const r = runInject(codexHome, ocxHome); + const r = runInject(codexHome, ocxHome, JSON.stringify({ + syncCodexSubagentDefaults: true, + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + })); expect(r.status).toBe(0); const result = JSON.parse(r.stdout); expect(result.success).toBe(true); @@ -166,6 +299,7 @@ describe("injectCodexConfig integration (Design B)", () => { expect(result.message).toContain('external model_provider "custom"'); expect(result.message).toContain("http://127.0.0.1:10100/v1"); expect(result.message).toContain("Responses passthrough"); + expect(result.nativeSubagentDefaultsWarning).toContain("external model_provider"); expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); expect(readFileSync(profilePath, "utf8")).toBe(profile); diff --git a/tests/codex-inject.test.ts b/tests/codex-inject.test.ts index d912f8d53..d8ab1f0cf 100644 --- a/tests/codex-inject.test.ts +++ b/tests/codex-inject.test.ts @@ -11,6 +11,10 @@ import { stripOpencodexConfig, stripRootContextWindowOverrides, } from "../src/codex/inject"; +import { + MANAGED_AGENTS_TABLE_MARKER, + MANAGED_SUBAGENT_DEFAULT_MARKER, +} from "../src/codex/subagent-defaults"; describe("Codex config injection", () => { test("omits provider-level Responses WebSocket support by default", () => { @@ -184,6 +188,26 @@ describe("Codex config injection", () => { expect(stripped).not.toContain("[model_providers.opencodex]"); expect(stripped).not.toContain("[profiles.opencodex]"); }); + + test("strip removes only marker-owned native subagent defaults", () => { + const stripped = stripOpencodexConfig([ + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "gpt-5.6-sol"', + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_reasoning_effort = "high"', + "max_threads = 8", + "", + ].join("\n")); + + expect(stripped).toContain("[agents]"); + expect(stripped).toContain("max_threads = 8"); + expect(stripped).not.toContain(MANAGED_AGENTS_TABLE_MARKER); + expect(stripped).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(stripped).not.toContain("default_subagent_model"); + expect(stripped).not.toContain("default_subagent_reasoning_effort"); + }); }); describe("Design B openai_base_url injection", () => { diff --git a/tests/codex-journal.test.ts b/tests/codex-journal.test.ts index 6280927c8..a496a4163 100644 --- a/tests/codex-journal.test.ts +++ b/tests/codex-journal.test.ts @@ -4,6 +4,10 @@ import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { + MANAGED_AGENTS_TABLE_MARKER, + MANAGED_SUBAGENT_DEFAULT_MARKER, +} from "../src/codex/subagent-defaults"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); @@ -132,6 +136,140 @@ describe("codex-journal", () => { expect(JSON.parse(r.stdout).exists).toBe(false); }); + test("removeCodexConfig is a successful no-op when Codex is not installed", () => { + writeFileSync(join(testDir, "opencodex.config.toml"), 'openai_base_url = "http://127.0.0.1:10100/v1"\n', "utf8"); + rmSync(join(testDir, "config.toml")); + const r = runScript(testDir, ` + const { removeCodexConfig, restoreNativeCodex } = require("./src/codex/inject"); + console.log(JSON.stringify({ remove: removeCodexConfig(), restore: restoreNativeCodex() })); + `); + + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.remove.success).toBe(true); + expect(result.remove.message).toContain("no native restore was needed"); + expect(result.restore.success).toBe(true); + expect(existsSync(join(testDir, "opencodex.config.toml"))).toBe(false); + }); + + test("removeCodexConfig reports damaged managed-default cleanup and preserves the ambiguous value", () => { + writeFileSync(join(testDir, "config.toml"), [ + "# Auto-injected by opencodex", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + "", + 'default_subagent_model = "gpt-5.6-sol"', + "", + ].join("\n"), "utf8"); + + const r = runScript(testDir, ` + const { removeCodexConfig } = require("./src/codex/inject"); + console.log(JSON.stringify(removeCodexConfig())); + `); + + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(false); + expect(result.message).toContain("could not be safely removed"); + expect(result.message).toContain("orphaned managed subagent default marker"); + const after = readFileSync(join(testDir, "config.toml"), "utf8"); + expect(after).not.toContain("openai_base_url"); + expect(after).toContain("# Managed by opencodex: native subagent default"); + expect(after).toContain('default_subagent_model = "gpt-5.6-sol"'); + }); + + test("removeCodexConfig ignores unsupported user-owned agents syntax when no managed marker exists", () => { + const userAgents = 'agents = { default_subagent_model = "user/model" }'; + writeFileSync(join(testDir, "config.toml"), [ + "# Auto-injected by opencodex", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + userAgents, + "", + ].join("\n"), "utf8"); + + const r = runScript(testDir, ` + const { removeCodexConfig } = require("./src/codex/inject"); + console.log(JSON.stringify(removeCodexConfig())); + `); + + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(true); + const after = readFileSync(join(testDir, "config.toml"), "utf8"); + expect(after).not.toContain("openai_base_url"); + expect(after).toContain(userAgents); + }); + + test("restoreNativeCodex restores an exact unchanged journal snapshot with managed defaults", () => { + const original = '# original config\nmodel_provider = "openai"\n'; + writeFileSync(join(testDir, "config.toml"), original, "utf8"); + + const r = runScript(testDir, ` + const { injectCodexConfig, restoreNativeCodex } = require("./src/codex/inject"); + (async () => { + await injectCodexConfig(10100, { + port: 10100, + providers: {}, + defaultProvider: "openai", + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + syncCodexSubagentDefaults: true, + }, { catalogPath: null }); + console.log(JSON.stringify(restoreNativeCodex())); + })(); + `); + + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(true); + expect(result.message).toContain("restored from opencodex journal"); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(false); + }); + + test("restoreNativeCodex reports damaged managed-default cleanup during fallback restore", () => { + const original = '# original config\nmodel_provider = "openai"\n'; + writeFileSync(join(testDir, "config.toml"), original, "utf8"); + + const r = runScript(testDir, ` + const fs = require("fs"); + const path = require("path"); + const { injectCodexConfig, restoreNativeCodex } = require("./src/codex/inject"); + (async () => { + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + await injectCodexConfig(10100, { + port: 10100, + providers: {}, + defaultProvider: "openai", + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + syncCodexSubagentDefaults: true, + }, { catalogPath: null }); + const marker = ${JSON.stringify(MANAGED_SUBAGENT_DEFAULT_MARKER)}; + const injected = fs.readFileSync(configPath, "utf8"); + fs.writeFileSync(configPath, injected.replace( + marker + '\\ndefault_subagent_model', + marker + '\\n\\ndefault_subagent_model', + ), "utf8"); + console.log(JSON.stringify(restoreNativeCodex())); + })(); + `); + + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(false); + expect(result.message).toContain("could not be safely removed"); + expect(result.message).toContain("orphaned managed subagent default marker"); + const after = readFileSync(join(testDir, "config.toml"), "utf8"); + expect(after).not.toContain("openai_base_url"); + expect(after).toContain("# Managed by opencodex: native subagent default"); + expect(after).toContain('default_subagent_model = "gpt-5.6-sol"'); + expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(true); + }); + test("restoreNativeCodex uses journal snapshot for normal stop without losing custom defaults", () => { const originalConfig = [ 'model = "openrouter/foo"', @@ -214,7 +352,14 @@ describe("codex-journal", () => { const path = require("path"); const { injectCodexConfig, restoreNativeCodex } = require("./src/codex/inject"); (async () => { - await injectCodexConfig(10100, { port: 10100, providers: {}, defaultProvider: "openai" }, { catalogPath: null }); + await injectCodexConfig(10100, { + port: 10100, + providers: {}, + defaultProvider: "openai", + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + syncCodexSubagentDefaults: true, + }, { catalogPath: null }); fs.appendFileSync(path.join(process.env.CODEX_HOME, "config.toml"), "\\n[tools]\\nweb_search = true\\n", "utf8"); const result = restoreNativeCodex(); console.log(JSON.stringify({ success: result.success, message: result.message })); @@ -226,6 +371,9 @@ describe("codex-journal", () => { expect(restored).toContain("[tools]"); expect(restored).toContain("web_search = true"); expect(restored).not.toContain("[model_providers.opencodex]"); + expect(restored).not.toContain("Managed by opencodex: native subagent"); + expect(restored).not.toContain("default_subagent_model"); + expect(restored).not.toContain("default_subagent_reasoning_effort"); expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(true); }); diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 31d23ff4b..24f3cd58e 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { syncModelsToCodex } from "../src/codex/sync"; +import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER } from "../src/codex/subagent-defaults"; import type { OcxConfig } from "../src/types"; import type { OrcaCodexHomeDiagnostic } from "../src/codex/home"; @@ -163,6 +165,65 @@ describe("GUI/CLI Codex sync backend", () => { expect(result.warning).toContain("catalog boom"); }); + test("returns native subagent default conflicts as structured warnings", async () => { + const result = await syncModelsToCodex(10100, config, null, { + refreshCodexModelCatalog: async () => ({ + added: 0, + path: "/tmp/opencodex-catalog.json", + catalogExists: true, + cacheSynced: true, + }), + injectCodexConfig: async () => ({ + success: true, + message: "injected with a preserved user setting", + nativeSubagentDefaultsWarning: "Native Codex sub-agent defaults were not injected: user-owned agents.default_subagent_model preserved.", + }), + currentExternalCodexModelProvider: () => null, + }); + + expect(result.ok).toBe(true); + expect(result.nativeSubagentDefaultsWarning).toContain("user-owned agents.default_subagent_model preserved"); + }); + + test("POST /api/sync exposes an actionable error when native defaults are ambiguous", () => { + const ocxHome = join(TEST_DIR, "opencodex"); + mkdirSync(ocxHome, { recursive: true }); + writeFileSync(join(TEST_CODEX_HOME, "config.toml"), [ + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + "", + 'default_subagent_model = "gpt-5.6-sol"', + "", + ].join("\n"), "utf8"); + + const child = spawnSync(process.execPath, ["-e", ` + const { handleManagementAPI } = await import("./src/server/management-api.ts"); + const config = { port: 10100, defaultProvider: "openai", providers: {} }; + const response = await handleManagementAPI( + new Request("http://localhost/api/sync", { method: "POST" }), + new URL("http://localhost/api/sync"), + config, + ); + console.log(JSON.stringify({ status: response.status, body: await response.json() })); + `], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env, CODEX_HOME: TEST_CODEX_HOME, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + }); + + expect(child.status).toBe(0); + const payload = JSON.parse(child.stdout.trim()) as { + status: number; + body: { ok: boolean; error?: string; message: string }; + }; + expect(payload.status).toBe(500); + expect(payload.body.ok).toBe(false); + expect(payload.body.error).toBe(payload.body.message); + expect(payload.body.error).toContain("inspect"); + expect(payload.body.error).toContain(join(TEST_CODEX_HOME, "config.toml")); + }); + test("skips catalog refresh before preserving an external provider", async () => { let refreshed = false; let injectedCatalogPath: string | null | undefined = "unset"; diff --git a/tests/config.test.ts b/tests/config.test.ts index 1224c15f4..a53ae79b7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -174,6 +174,144 @@ describe("opencodex config defaults", () => { } }); + test("native subagent-default sync is opt-in and ignores malformed opt-ins without falling back", () => { + const base = { + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + }, + }, + defaultProvider: "custom", + codexAccounts: [{ id: "account-1", email: "owner@example.test", isMain: true }], + injectionModel: "gpt-5.6-terra", + }; + expect(getDefaultConfig().syncCodexSubagentDefaults).toBeUndefined(); + + for (const enabled of [true, false]) { + writeConfig({ ...base, syncCodexSubagentDefaults: enabled }); + expect(loadConfig().syncCodexSubagentDefaults).toBe(enabled); + } + + for (const invalid of [null, "true", 1]) { + writeConfig({ ...base, syncCodexSubagentDefaults: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics).toMatchObject({ + source: "file", + error: null, + config: { + port: 12345, + defaultProvider: "custom", + providers: { custom: { baseUrl: "https://example.test/v1" } }, + codexAccounts: [{ id: "account-1", email: "owner@example.test", isMain: true }], + injectionModel: "gpt-5.6-terra", + }, + }); + expect(diagnostics.config.syncCodexSubagentDefaults).toBeUndefined(); + expect(diagnostics.warnings).toContain("syncCodexSubagentDefaults ignored: expected a boolean"); + expect(loadConfig()).toMatchObject({ + port: 12345, + defaultProvider: "custom", + providers: { custom: { baseUrl: "https://example.test/v1" } }, + codexAccounts: [{ id: "account-1", email: "owner@example.test", isMain: true }], + }); + expect(backupNames()).toEqual([]); + } + }); + + test("validates disk injection selections and safely normalizes a model-less sync opt-in", () => { + const base = { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + }; + + writeConfig({ + ...base, + injectionModel: "gpt-5.6-terra", + injectionEffort: "ultra", + syncCodexSubagentDefaults: true, + }); + expect(loadConfig()).toMatchObject({ + injectionModel: "gpt-5.6-terra", + injectionEffort: "ultra", + syncCodexSubagentDefaults: true, + }); + + for (const invalid of ["", " "]) { + writeConfig({ ...base, injectionModel: invalid, syncCodexSubagentDefaults: true }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.injectionModel).toBe(invalid); + expect(diagnostics.config.syncCodexSubagentDefaults).toBeUndefined(); + expect(diagnostics.warnings).toContain("syncCodexSubagentDefaults ignored: a nonblank injectionModel is required"); + } + + for (const invalid of ["", "turbo"]) { + writeConfig({ + ...base, + injectionModel: "gpt-5.6-terra", + injectionEffort: invalid, + syncCodexSubagentDefaults: true, + }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.injectionEffort).toBe(invalid); + expect(diagnostics.config.syncCodexSubagentDefaults).toBeUndefined(); + expect(diagnostics.warnings).toContain("syncCodexSubagentDefaults ignored: injectionEffort must be a supported Codex reasoning effort"); + } + + for (const [field, invalid] of [["injectionModel", 1], ["injectionEffort", 1]] as const) { + writeConfig({ + ...base, + injectionModel: "gpt-5.6-terra", + syncCodexSubagentDefaults: true, + [field]: invalid, + }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.port).toBe(10100); + expect(diagnostics.config.defaultProvider).toBe("openai"); + expect(diagnostics.config.providers.openai.baseUrl).toBe("https://chatgpt.com/backend-api/codex"); + expect(diagnostics.config[field]).toBeUndefined(); + expect(diagnostics.config.syncCodexSubagentDefaults).toBeUndefined(); + expect(diagnostics.warnings).toContain(`${field} ignored: expected a string`); + expect(diagnostics.warnings?.some(warning => warning.startsWith("syncCodexSubagentDefaults ignored:"))).toBe(true); + expect(loadConfig()).toMatchObject({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { baseUrl: "https://chatgpt.com/backend-api/codex" } }, + }); + expect(backupNames()).toEqual([]); + } + + // Guidance-only values retain their pre-existing compatibility. They are + // constrained only when the native Codex config mutation is opted into. + writeConfig({ ...base, injectionModel: "legacy/model", injectionEffort: "provider-specific" }); + expect(readConfigDiagnostics()).toMatchObject({ + source: "file", + error: null, + config: { injectionModel: "legacy/model", injectionEffort: "provider-specific" }, + }); + + writeConfig({ ...base, syncCodexSubagentDefaults: true }); + const normalized = readConfigDiagnostics(); + expect(normalized.source).toBe("file"); + expect(normalized.error).toBeNull(); + expect(normalized.config.syncCodexSubagentDefaults).toBeUndefined(); + expect(loadConfig().syncCodexSubagentDefaults).toBeUndefined(); + }); + test("loads valid config from OPENCODEX_HOME", () => { writeConfig({ port: 12345, diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index 8e24a7c2e..331ca1112 100644 --- a/tests/grok-lifecycle.test.ts +++ b/tests/grok-lifecycle.test.ts @@ -91,11 +91,26 @@ describe("Grok fence lifecycle wiring", () => { expect(restartCase).toContain("if (await handleStop()) await handleEnsure()"); }); + test("handleStop treats an incomplete native Codex restore as a stop failure", () => { + const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); + expect(stopFn).toContain("if (r.success) console.log"); + expect(stopFn).toContain("stopFailed = true"); + expect(stopFn).toContain("console.error(`⚠️ ${r.message}`)"); + }); + test("the daemon's exit cleanup keeps the OCX_SERVICE exclusion and adds the ownership check", () => { const startFn = sliceFn(CLI_SOURCE, "const syncCleanup = () => {", "let shuttingDown = false;"); // Crash/respawn under a service manager must still keep the fence. expect(startFn).toContain("!process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()"); }); + + test("signal shutdown reports and exits nonzero when native Codex restore is incomplete", () => { + const startFn = sliceFn(CLI_SOURCE, "async function handleStart(", "async function handleStop("); + expect(startFn).toContain("if (!restored.success)"); + expect(startFn).toContain("cleanupSucceeded = false"); + expect(startFn).toContain("Native Codex restore failed during shutdown"); + expect(startFn).toContain("process.exit(restored ? 0 : 1)"); + }); }); describe("service teardown owns both managed configs", () => { diff --git a/tests/injection-model-api.test.ts b/tests/injection-model-api.test.ts index 3a555b3d2..e324ea625 100644 --- a/tests/injection-model-api.test.ts +++ b/tests/injection-model-api.test.ts @@ -47,7 +47,7 @@ describe("/api/injection-model reasoning effort", () => { isolatedHome(); const config = makeConfig(); const putRes = await put(config, { model: "openai/gpt-5.6-sol", effort: "xhigh" }); - expect(await putRes.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, model: "openai/gpt-5.6-sol", effort: "xhigh", prompt: null }); + expect(await putRes.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, syncCodexSubagentDefaults: false, model: "openai/gpt-5.6-sol", effort: "xhigh", prompt: null }); expect(config.injectionEffort).toBe("xhigh"); const getRes = await handleManagementAPI( @@ -89,7 +89,7 @@ describe("/api/injection-model reasoning effort", () => { isolatedHome(); const config = makeConfig({ injectionModel: "openai/gpt-5.6-sol", injectionEffort: "max" }); const res = await put(config, { model: "openai/gpt-5.6-sol", effort: null }); - expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, model: "openai/gpt-5.6-sol", effort: null, prompt: null }); + expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, syncCodexSubagentDefaults: false, model: "openai/gpt-5.6-sol", effort: null, prompt: null }); expect(config.injectionEffort).toBeUndefined(); }); @@ -97,7 +97,7 @@ describe("/api/injection-model reasoning effort", () => { isolatedHome(); const config = makeConfig({ injectionModel: "openai/gpt-5.6-sol", injectionEffort: "max" }); const res = await put(config, { model: null }); - expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, model: null, effort: null, prompt: null }); + expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, syncCodexSubagentDefaults: false, model: null, effort: null, prompt: null }); expect(config.injectionModel).toBeUndefined(); expect(config.injectionEffort).toBeUndefined(); }); @@ -106,7 +106,7 @@ describe("/api/injection-model reasoning effort", () => { isolatedHome(); const config = makeConfig({ injectionModel: "openai/gpt-5.6-sol", injectionEffort: "ultra" }); const res = await put(config, { model: "anthropic/claude-sonnet-5" }); - expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, model: "anthropic/claude-sonnet-5", effort: "ultra", prompt: null }); + expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, syncCodexSubagentDefaults: false, model: "anthropic/claude-sonnet-5", effort: "ultra", prompt: null }); }); test("GET round-trips combo aliases and excludes an alias-disabled combo", async () => { @@ -163,6 +163,7 @@ describe("/api/injection-model guidance kill switch + partial update", () => { expect(await response.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: false, + syncCodexSubagentDefaults: false, model: "gpt-5.6-terra", effort: "max", prompt: "RULES {{model}} {{roster}}", @@ -195,6 +196,7 @@ describe("/api/injection-model guidance kill switch + partial update", () => { expect(await response.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: false, + syncCodexSubagentDefaults: false, model: null, effort: null, prompt: "RULES {{roster}}", @@ -205,6 +207,148 @@ describe("/api/injection-model guidance kill switch + partial update", () => { expect(config.multiAgentGuidanceEnabled).toBe(false); }); + test("subagent-default sync is opt-in, model-bound, partial, and normalized on disk", async () => { + isolatedHome(); + const config = makeConfig(); + + let response = await handleManagementAPI( + new Request("http://localhost/api/injection-model"), + new URL("http://localhost/api/injection-model"), + config, + ); + expect(await response!.json()).toMatchObject({ + syncCodexSubagentDefaults: false, + model: null, + }); + + response = await put(config, { syncCodexSubagentDefaults: true }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "syncCodexSubagentDefaults requires an injection model" }); + expect(config.syncCodexSubagentDefaults).toBeUndefined(); + expect(existsSync(getConfigPath())).toBe(false); + + response = await put(config, { syncCodexSubagentDefaults: "true" }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "syncCodexSubagentDefaults must be a boolean" }); + + response = await put(config, { + model: "gpt-5.6-terra", + effort: "max", + syncCodexSubagentDefaults: true, + }); + expect(await response.json()).toMatchObject({ + syncCodexSubagentDefaults: true, + model: "gpt-5.6-terra", + effort: "max", + }); + expect(config.syncCodexSubagentDefaults).toBe(true); + + response = await put(config, { effort: "high" }); + expect(await response.json()).toMatchObject({ syncCodexSubagentDefaults: true, effort: "high" }); + expect(config.syncCodexSubagentDefaults).toBe(true); + + response = await put(config, { syncCodexSubagentDefaults: false }); + expect(await response.json()).toMatchObject({ syncCodexSubagentDefaults: false, model: "gpt-5.6-terra" }); + expect(config.syncCodexSubagentDefaults).toBeUndefined(); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).not.toHaveProperty("syncCodexSubagentDefaults"); + }); + + test("clearing the injection model also clears subagent-default sync", async () => { + isolatedHome(); + const config = makeConfig({ + injectionModel: "gpt-5.6-terra", + injectionEffort: "max", + syncCodexSubagentDefaults: true, + }); + + const response = await put(config, { model: null }); + expect(await response.json()).toMatchObject({ + syncCodexSubagentDefaults: false, + model: null, + effort: null, + }); + expect(config.injectionModel).toBeUndefined(); + expect(config.injectionEffort).toBeUndefined(); + expect(config.syncCodexSubagentDefaults).toBeUndefined(); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).not.toHaveProperty("syncCodexSubagentDefaults"); + }); + + test("clearing model with sync on and unsupported inherited effort returns 200 and clears all", async () => { + isolatedHome(); + const config = makeConfig({ + injectionModel: "legacy/model", + injectionEffort: "provider-specific", + syncCodexSubagentDefaults: true, + }); + + const response = await put(config, { model: null }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + syncCodexSubagentDefaults: false, + model: null, + effort: null, + }); + expect(config.injectionModel).toBeUndefined(); + expect(config.injectionEffort).toBeUndefined(); + expect(config.syncCodexSubagentDefaults).toBeUndefined(); + }); + + test("a latent model-less sync flag stays off during a model-only partial update", async () => { + isolatedHome(); + const config = makeConfig({ syncCodexSubagentDefaults: true }); + + let response = await handleManagementAPI( + new Request("http://localhost/api/injection-model"), + new URL("http://localhost/api/injection-model"), + config, + ); + expect(await response!.json()).toMatchObject({ + syncCodexSubagentDefaults: false, + model: null, + }); + + response = await put(config, { model: "gpt-5.6-terra" }); + expect(await response.json()).toMatchObject({ + syncCodexSubagentDefaults: false, + model: "gpt-5.6-terra", + }); + expect(config.syncCodexSubagentDefaults).toBeUndefined(); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).not.toHaveProperty("syncCodexSubagentDefaults"); + }); + + test("rejects a whitespace-only model without mutating existing settings", async () => { + isolatedHome(); + const config = makeConfig({ + injectionModel: "gpt-5.6-terra", + injectionEffort: "high", + syncCodexSubagentDefaults: true, + }); + const before = structuredClone(config); + + const response = await put(config, { model: " " }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "model must be a nonblank string or null" }); + expect(config).toEqual(before); + expect(existsSync(getConfigPath())).toBe(false); + }); + + test("rejects native-default opt-in when a legacy stored effort is not Codex-supported", async () => { + isolatedHome(); + const config = makeConfig({ + injectionModel: "legacy/model", + injectionEffort: "provider-specific", + }); + const before = structuredClone(config); + + const response = await put(config, { syncCodexSubagentDefaults: true }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "syncCodexSubagentDefaults requires a supported Codex reasoning effort", + }); + expect(config).toEqual(before); + expect(existsSync(getConfigPath())).toBe(false); + }); + test.each([ ["null", null], ["array", []], diff --git a/tests/subagent-defaults.test.ts b/tests/subagent-defaults.test.ts new file mode 100644 index 000000000..96adcf2ae --- /dev/null +++ b/tests/subagent-defaults.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, test } from "bun:test"; +import { + MANAGED_AGENTS_TABLE_MARKER, + MANAGED_SUBAGENT_DEFAULT_MARKER, + transformManagedSubagentDefaults, +} from "../src/codex/subagent-defaults"; + +function apply(content: string, model = "openai/gpt-5.6-sol", reasoningEffort?: string) { + return transformManagedSubagentDefaults(content, { model, reasoningEffort }); +} + +describe("managed native subagent defaults TOML transform", () => { + test("creates an owned [agents] table and TOML-escapes supplied strings", () => { + const input = 'model = "gpt-5.6"\n'; + const result = apply(input, 'provider/a"b\\c\nnext\u007f', "xhigh"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.conflicts).toEqual([]); + expect(result.content).toBe([ + 'model = "gpt-5.6"', + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "provider/a\\"b\\\\c\\nnext\\u007F"', + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_reasoning_effort = "xhigh"', + "", + ].join("\n")); + expect((Bun.TOML.parse(result.content).agents as Record).default_subagent_model) + .toBe('provider/a"b\\c\nnext\u007f'); + expect(apply(result.content, 'provider/a"b\\c\nnext\u007f', "xhigh")).toEqual({ + ok: true, + changed: false, + content: result.content, + conflicts: [], + }); + }); + + test("updates only marked values while preserving comments, siblings, and table order", () => { + const input = [ + "# before", + "[agents] # native settings", + "max_threads = 12 # user", + MANAGED_SUBAGENT_DEFAULT_MARKER, + "default_subagent_model = 'old/model' # keep this comment", + "custom = { nested = true }", + "", + "[notice]", + "hide = false", + "", + ].join("\n"); + const result = apply(input, "new/model"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toContain('default_subagent_model = "new/model" # keep this comment'); + expect(result.content).toContain("max_threads = 12 # user\n"); + expect(result.content).toContain("custom = { nested = true }\n\n[notice]\nhide = false\n"); + expect(result.content).not.toContain("default_subagent_reasoning_effort"); + }); + + test("preserves CRLF on insertion and update", () => { + const input = `[agents]\r\n${MANAGED_SUBAGENT_DEFAULT_MARKER}\r\ndefault_subagent_model = "old"\r\nmax_threads = 4\r\n`; + const result = apply(input, "new", "high"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toContain('default_subagent_model = "new"\r\n'); + expect(result.content).toContain('default_subagent_reasoning_effort = "high"\r\n'); + expect(result.content).not.toMatch(/[^\r]\n/); + }); + + test("omitting effort removes only a marked effort and leaves an unmarked sibling", () => { + const input = [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "old"', + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_reasoning_effort = "high" # owned', + "max_depth = 3 # user", + "", + ].join("\n"); + const result = apply(input, "new"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).not.toContain("default_subagent_reasoning_effort"); + expect(result.content).toContain("max_depth = 3 # user"); + }); + + test("reports unmarked target keys as conflicts without overwriting them", () => { + const input = '[agents]\ndefault_subagent_model = "user/model" # user-owned\nmax_threads = 8\n'; + const result = apply(input, "managed/model", "medium"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.conflicts).toEqual([ + { key: "default_subagent_model", line: 2, reason: "user-owned" }, + ]); + expect(result.changed).toBe(false); + expect(result.content).toBe(input); + }); + + test("an effort conflict also prevents updating the managed model half", () => { + const input = [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "old/managed"', + 'default_subagent_reasoning_effort = "user-effort"', + "", + ].join("\n"); + const result = apply(input, "new/managed", "high"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result).toEqual({ + ok: true, + changed: false, + content: input, + conflicts: [ + { key: "default_subagent_reasoning_effort", line: 4, reason: "user-owned" }, + ], + }); + }); + + test("clear removes owned pairs but preserves user-owned targets", () => { + const input = [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "managed/model"', + 'default_subagent_reasoning_effort = "user-effort" # user-owned', + "max_threads = 8", + "", + ].join("\n"); + const result = transformManagedSubagentDefaults(input, null); + expect(result).toEqual({ + ok: true, + changed: true, + content: '[agents]\ndefault_subagent_reasoning_effort = "user-effort" # user-owned\nmax_threads = 8\n', + conflicts: [], + }); + }); + + test("clear ignores unsupported user-owned shapes when no managed marker exists", () => { + const input = [ + 'agents = { default_subagent_model = "user/model" }', + 'message = """', + MANAGED_SUBAGENT_DEFAULT_MARKER, + "[agents]", + 'default_subagent_model = "string/data"', + '"""', + "", + ].join("\n"); + expect(transformManagedSubagentDefaults(input, null)).toEqual({ + ok: true, + changed: false, + content: input, + conflicts: [], + }); + }); + + test("clear removes an empty marker-owned table and is idempotent", () => { + const enabled = apply(""); + expect(enabled.ok).toBe(true); + if (!enabled.ok) return; + const cleared = transformManagedSubagentDefaults(enabled.content, null); + expect(cleared).toEqual({ ok: true, changed: true, content: "", conflicts: [] }); + expect(transformManagedSubagentDefaults(cleared.content, null)).toEqual({ + ok: true, + changed: false, + content: "", + conflicts: [], + }); + }); + + test("a marker-owned table with unknown content survives clear", () => { + const input = [ + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "managed/model"', + "max_threads = 7 # user extension", + "", + ].join("\n"); + const result = transformManagedSubagentDefaults(input, null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toBe("[agents]\nmax_threads = 7 # user extension\n"); + expect(result.content).not.toContain(MANAGED_AGENTS_TABLE_MARKER); + }); + + test("inserts a base table before nested custom-role tables and preserves them", () => { + const input = [ + 'model = "gpt-5.6"', + "", + "[agents.reviewer] # custom role", + 'description = "review only"', + "", + '[agents."executor"]', + 'description = "execute"', + "", + ].join("\n"); + const result = apply(input, "managed/model", "high"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content.indexOf("[agents]")).toBeLessThan(result.content.indexOf("[agents.reviewer]")); + expect(result.content).toContain('[agents.reviewer] # custom role\ndescription = "review only"'); + expect(result.content).toContain('[agents."executor"]\ndescription = "execute"'); + expect(apply(result.content, "managed/model", "high").changed).toBe(false); + }); + + test("supports quoted canonical table and key names", () => { + const input = `["agents"]\n${MANAGED_SUBAGENT_DEFAULT_MARKER}\n"default_subagent_model" = "old"\n`; + const result = apply(input, "new"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toContain('"default_subagent_model" = "new"'); + }); + + test.each([ + ["basic", 'message = """\n[agents]\n# Managed by opencodex: native subagent default\ndefault_subagent_model = "string/data"\n"""\n'], + ["literal", "message = '''\n[agents]\n# Managed by opencodex: native subagent default\ndefault_subagent_model = 'string/data'\n'''\n"], + ])("ignores table, key, and marker text inside %s multiline strings", (_kind, input) => { + const before = Bun.TOML.parse(input).message; + const result = apply(input, "managed/model", "high"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(Bun.TOML.parse(result.content).message).toBe(before); + expect(result.content).toStartWith(input); + expect(result.content.match(/^\[agents\]$/gm)).toHaveLength(2); + const parsedAgents = Bun.TOML.parse(result.content).agents as Record; + expect(parsedAgents.default_subagent_model).toBe("managed/model"); + expect(parsedAgents.default_subagent_reasoning_effort).toBe("high"); + }); + + test("recognizes an escaped quoted user key as the canonical target", () => { + const input = String.raw`[agents] +"default_subagent_\u006dodel" = "user/model" +`; + const result = apply(input, "managed/model", "high"); + expect(result).toEqual({ + ok: true, + changed: false, + content: input, + conflicts: [{ key: "default_subagent_model", line: 2, reason: "user-owned" }], + }); + expect(() => Bun.TOML.parse(result.content)).not.toThrow(); + }); + + test.each([ + ["x", String.raw`[agents] +"default_subagent_\x6dodel" = "user/model" +`], + ["U", String.raw`["\U00000061gents"] +"default_subagent_\U0000006Dodel" = "user/model" +`], + ])("recognizes Codex-supported \\%s escapes in quoted canonical keys", (_escape, input) => { + const result = apply(input, "managed/model"); + expect(result).toEqual({ + ok: true, + changed: false, + content: input, + conflicts: [{ key: "default_subagent_model", line: 2, reason: "user-owned" }], + }); + }); + + test("recognizes an escaped quoted agents table without creating a duplicate", () => { + const input = String.raw`["\u0061gents"] +max_threads = 4 +`; + const result = apply(input, "managed/model"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toContain(String.raw`["\u0061gents"]`); + expect(result.content).not.toContain("\n[agents]\n"); + expect((Bun.TOML.parse(result.content).agents as Record).default_subagent_model) + .toBe("managed/model"); + }); + + test("does not treat nested array elements as table boundaries", () => { + const input = [ + "[agents]", + "matrix = [", + ' ["x"],', + "]", + 'default_subagent_model = "user/model"', + "", + ].join("\n"); + const result = apply(input, "managed/model", "high"); + expect(result).toEqual({ + ok: true, + changed: false, + content: input, + conflicts: [{ key: "default_subagent_model", line: 5, reason: "user-owned" }], + }); + expect(() => Bun.TOML.parse(result.content)).not.toThrow(); + }); + + test("finds root dotted targets after a multiline nested array", () => { + const input = [ + "matrix = [", + ' ["x"],', + "]", + 'agents.default_subagent_model = "user/model"', + "", + ].join("\n"); + const result = apply(input); + expect(result.ok).toBe(false); + expect(result.content).toBe(input); + }); + + test.each([ + ["model", [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = """', + "managed/model", + '"""', + "", + ].join("\n"), null], + ["literal model", [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + "default_subagent_model = '''", + "managed/model", + "'''", + "", + ].join("\n"), null], + ["effort", [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "managed/model"', + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_reasoning_effort = """', + "high", + '"""', + "", + ].join("\n"), { model: "managed/model" }], + ])("refuses to partially remove a marker-owned multiline %s", (_kind, input, defaults) => { + const result = transformManagedSubagentDefaults(input, defaults); + expect(result.ok).toBe(false); + expect(result.changed).toBe(false); + expect(result.content).toBe(input); + expect(() => Bun.TOML.parse(result.content)).not.toThrow(); + }); + + test.each([ + ["duplicate tables", "[agents]\n[agents]\n"], + ["duplicate target keys", '[agents]\ndefault_subagent_model = "a"\ndefault_subagent_model = "b"\n'], + ["root dotted target", 'agents.default_subagent_model = "a"\n'], + ["quoted root dotted target", '"agents"."default_subagent_model" = "a"\n'], + ["escaped quoted root dotted target", String.raw`"\u0061gents"."default_subagent_\u006dodel" = "a" +`], + ["dotted target in table", '[agents]\ndefault_subagent_model.value = "a"\n'], + ["target key as dotted table", "[agents.default_subagent_model]\nvalue = 'a'\n"], + ["target key as deeper dotted table", "[agents.default_subagent_model.metadata]\nvalue = 'a'\n"], + ["array agents table", "[[agents]]\nmodel = 'a'\n"], + ["inline agents definition", 'agents = { default_subagent_model = "a" }\n'], + ["orphaned key marker", `${MANAGED_SUBAGENT_DEFAULT_MARKER}\n[notice]\n`], + ["orphaned table marker", `${MANAGED_AGENTS_TABLE_MARKER}\n[notice]\n`], + ])("rejects %s without changing bytes", (_name, input) => { + const result = apply(input); + expect(result.ok).toBe(false); + expect(result.changed).toBe(false); + expect(result.content).toBe(input); + }); + + test("rejects invalid requested defaults without changing bytes", () => { + const input = "# untouched\r\n"; + expect(transformManagedSubagentDefaults(input, { model: "" })).toMatchObject({ + ok: false, + changed: false, + content: input, + }); + expect(transformManagedSubagentDefaults(input, { model: "m", reasoningEffort: "" })).toMatchObject({ + ok: false, + changed: false, + content: input, + }); + expect(transformManagedSubagentDefaults(input, { model: "bad\ud800" })).toMatchObject({ + ok: false, + changed: false, + content: input, + }); + }); +}); From f92e1073745420c621336738588e83e14ab7c7de Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:54:04 +0900 Subject: [PATCH 018/129] fix(kiro): support browser-based multi-account login (#447) Make Kiro Add-account run a transactional kiro-cli browser login with durable session recovery, account-scoped routing metadata, and generation-safe refresh. Co-authored-by: coseung2 <120152615+coseung2@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 30 +- .../src/content/docs/ja/guides/providers.md | 19 +- .../src/content/docs/ko/guides/providers.md | 19 +- .../src/content/docs/ru/guides/providers.md | 19 +- .../content/docs/zh-cn/guides/providers.md | 19 +- src/adapters/kiro.ts | 4 +- src/oauth/index.ts | 171 +++-- src/oauth/kiro-credentials.ts | 332 +++++++++- src/oauth/kiro.ts | 466 ++++++++++++-- src/oauth/store.ts | 55 +- src/oauth/types.ts | 11 + src/providers/registry.ts | 2 +- src/server/responses/core.ts | 8 + src/types.ts | 4 + tests/kiro-adapter.test.ts | 79 ++- tests/kiro-oauth.test.ts | 592 +++++++++++++++++- tests/kiro-review-regressions.test.ts | 369 +++++++++++ tests/oauth-reauth-bind.test.ts | 87 ++- tests/oauth-refresh.test.ts | 192 +++++- tests/oauth-store-multi.test.ts | 30 +- tests/server-kiro-oauth-401-replay.test.ts | 101 +++ 21 files changed, 2461 insertions(+), 148 deletions(-) create mode 100644 tests/kiro-review-regressions.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 8314f0ddc..6bcb87bf8 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -85,7 +85,7 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Import-first login reuses the installed `kiro-cli` session — requires the Kiro CLI installed (`curl -fsSL https://cli.kiro.dev/install | bash`) and signed in via `kiro-cli login`. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (install with `curl -fsSL https://cli.kiro.dev/install | bash`, then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | @@ -96,8 +96,9 @@ You can also start OAuth from the [web dashboard](/guides/web-dashboard/). OAuth providers whose credentials include a stable account id or email can keep more than one login. The Providers page shows those accounts in a dropdown, lets you add another, and switches the -active account without logging the others out. Identity-less Kimi and Kiro credentials replace their -active slot, while `chatgpt` is always single-slot because Codex pool accounts have a separate ledger. +active account without logging the others out. Only identity-less Kimi credentials replace the +active slot; Kiro accounts are keyed by profile ARN. `chatgpt` is always single-slot because Codex +pool accounts have a separate ledger. Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked metadata only. ### OAuth reliability @@ -143,20 +144,35 @@ and WARN rows that include a recovery Action. When an OAuth provider account nee ### Kiro credential import Kiro login expects the Kiro CLI: install it (`curl -fsSL https://cli.kiro.dev/install | bash`) -and sign in with `kiro-cli login` first. Without a kiro-cli session, `ocx login kiro` falls +and sign in with `kiro-cli login` first. Without a `kiro-cli` session, `ocx login kiro` falls back to a pasted access token or the `KIRO_ACCESS_TOKEN` environment variable. -`ocx login kiro` searches the platform Kiro CLI stores and opens SQLite databases read-only. Two -environment variables make selection explicit without copying credentials into opencodex: +The `ocx login kiro` import path searches the platform Kiro CLI stores and opens SQLite databases +read-only. Two environment variables make the source and token row selection explicit: - `KIROCLI_DB_PATH` selects a nonstandard Kiro CLI SQLite database. The path must already exist; - opencodex does not create it or modify the database, WAL, or SHM files. + during this import path, opencodex does not create or modify the database, WAL, or SHM files. - `KIROCLI_TOKEN_KEY` selects the exact `auth_kv` token key when a database contains multiple otherwise ambiguous token rows. A missing selection fails login instead of guessing. +After a successful import, opencodex persists the imported credential to +`~/.opencodex/auth.json`. + Keep these variables and the selected database private. Do not attach database files or raw login diagnostics to bug reports. +**Add account** is a separate write workflow: it snapshots the current session, logs `kiro-cli` out, +and imports the fresh browser login. If the login is cancelled or fails, including while OpenCodex +persists the credential, rollback replaces the Kiro CLI database and removes its current WAL, SHM, +and journal sidecars before publishing the previous session snapshot. + +Because that rollback is only possible from a snapshot, **Add account** refuses to sign `kiro-cli` +out when a session store is present but cannot be captured (unreadable file, mismatched schema, or +an ambiguous token selection), when `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` redirect import reads away +from the live CLI store, or when an existing primary CLI database has no recognized token row. +Repair or remove the unreadable database under the normal `kiro-cli` data path, unset those import +selectors, then retry. Signing in from a machine with no existing `kiro-cli` session is unaffected. + ## 3. API-key catalog opencodex ships 53 built-in presets: 42 key-based, seven OAuth, three local, and the default diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index f1133444c..72c92baa4 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -80,7 +80,7 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | ライブ一覧を優先し、フォールバックのデフォルトモデルは `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude モデル; ライブモデル一覧は `/v1/models` から取得。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | インストール済み `kiro-cli` ログインを優先取得。Kiro CLI のインストール(`curl -fsSL https://cli.kiro.dev/install | bash`)と `kiro-cli login` が必要。 | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは Kiro CLI をインストール(`curl -fsSL https://cli.kiro.dev/install | bash`)し、`kiro-cli login` でサインインした既存セッションを取り込みます。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | @@ -90,10 +90,23 @@ ocx logout 認証情報に固定アカウント ID やメールがある OAuth プロバイダーはログインを複数保持できます。 Providers ページでアカウントを追加し、別アカウントをログアウトせずにアクティブアカウントだけを切り替えられます。 -アカウント識別情報がない Kimi と Kiro はアクティブスロットを差し替え、`chatgpt` は Codex アカウントプールに別の保存場所が -あり常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、 +アカウント識別情報がない Kimi 認証情報だけがアクティブスロットを差し替え、Kiro アカウントはプロファイル ARN をキーに保存されます。 +`chatgpt` は Codex アカウントプールに別の保存場所があり、常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、 `/api/oauth/accounts` はマスク済みメタデータのみを返します。 +### Kiro 認証情報の取り込み + +Kiro のログインには Kiro CLI が必要です。`curl -fsSL https://cli.kiro.dev/install | bash` でインストールし、先に `kiro-cli login` でサインインしてください。`kiro-cli` セッションがない場合、`ocx login kiro` は貼り付けたアクセストークンまたは `KIRO_ACCESS_TOKEN` 環境変数にフォールバックします。 + +通常の `ocx login kiro` 取り込みは CLI の SQLite データベースを読み取り専用で開き、データベース、WAL、SHM を変更しません。 + +- `KIROCLI_DB_PATH` は標準外の Kiro CLI SQLite データベースを選択します。指定するデータベースは既に存在している必要があります。 +- `KIROCLI_TOKEN_KEY` は複数の曖昧なトークン行がある場合に、取り込む正確な `auth_kv` 行のキーを指定します。選択がない場合、推測せずログインに失敗します。 + +取り込んだ認証情報は `~/.opencodex/auth.json` に保存されます。**アカウントを追加**のロールバックは別処理で、以前のスナップショットを復元する際にデータベースを置き換え、現在の WAL、SHM、journal サイドカーを削除します。 + +ロールバックはスナップショットがある場合にのみ可能なため、セッションストアが存在するのに取得できない場合(ファイルが読めない、スキーマの不一致、トークン選択があいまい)、`KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` が実際の CLI ストアと異なるインポート先を指す場合、またはプライマリ CLI データベースに認識できるトークン行がない場合、**アカウントを追加**は `kiro-cli` のログアウトを拒否します。通常の `kiro-cli` データパス上の壊れたデータベースを修復または削除し、インポート専用セレクタが設定されていれば解除してから再試行してください。既存の `kiro-cli` セッションがまったくない環境には影響しません。 + ## 3. API キーカタログ opencodex v2.7.1 には組み込みプリセットが 50 個含まれています。キー方式 40、OAuth 6、ローカル 3、 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index c2a3f2480..1f9cb66b5 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -80,7 +80,7 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | 실시간 목록을 우선 사용하며, 폴백 기본 모델은 `grok-4.5`입니다. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 모델; 실시간 모델 목록은 `/v1/models`에서 가져옵니다. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 설치된 `kiro-cli` 로그인을 먼저 가져옵니다. Kiro CLI 설치(`curl -fsSL https://cli.kiro.dev/install | bash`)와 `kiro-cli login`이 필요합니다. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 Kiro CLI를 설치(`curl -fsSL https://cli.kiro.dev/install | bash`)하고 `kiro-cli login`으로 로그인한 기존 세션을 가져옵니다. **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | @@ -90,10 +90,23 @@ ocx logout 자격 증명에 고정된 계정 id나 이메일이 있는 OAuth 프로바이더는 로그인을 여러 개 보관할 수 있습니다. Providers 페이지에서 계정을 추가하고, 다른 계정을 로그아웃하지 않은 채 활성 계정만 바꿀 수 있습니다. -계정 식별 정보가 없는 Kimi와 Kiro는 활성 슬롯을 교체하며, `chatgpt`는 Codex 계정 풀에 별도 저장소가 -있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, +계정 식별 정보가 없는 Kimi 자격 증명만 활성 슬롯을 교체하며, Kiro 계정은 프로필 ARN을 키로 저장됩니다. +`chatgpt`는 Codex 계정 풀에 별도 저장소가 있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, `/api/oauth/accounts`는 마스킹된 메타데이터만 반환합니다. +### Kiro 자격 증명 가져오기 + +Kiro 로그인에는 Kiro CLI가 필요합니다. `curl -fsSL https://cli.kiro.dev/install | bash`로 설치하고 먼저 `kiro-cli login`으로 로그인하세요. `kiro-cli` 세션이 없으면 `ocx login kiro`는 붙여 넣은 액세스 토큰이나 `KIRO_ACCESS_TOKEN` 환경 변수로 폴백합니다. + +일반 `ocx login kiro` 가져오기는 CLI SQLite 데이터베이스를 읽기 전용으로 열며 데이터베이스, WAL, SHM을 수정하지 않습니다. + +- `KIROCLI_DB_PATH`는 비표준 Kiro CLI SQLite 데이터베이스를 선택하며, 지정한 데이터베이스는 이미 존재해야 합니다. +- `KIROCLI_TOKEN_KEY`는 모호한 토큰 행이 여러 개일 때 가져올 정확한 `auth_kv` 행의 키를 선택합니다. 선택값이 없으면 추측하지 않고 로그인이 실패합니다. + +가져온 자격 증명은 `~/.opencodex/auth.json`에 저장됩니다. **계정 추가** 롤백은 별도 절차로, 이전 스냅샷을 복원할 때 데이터베이스를 교체하고 현재 WAL, SHM, journal 사이드카를 제거합니다. + +롤백은 스냅샷이 있을 때만 가능하므로, 세션 저장소가 존재하지만 캡처할 수 없는 경우(파일을 읽을 수 없음, 스키마 불일치, 토큰 선택 모호), `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE`이 실제 CLI 저장소와 다른 가져오기 경로를 가리키는 경우, 또는 기본 CLI 데이터베이스에 인식 가능한 토큰 행이 없는 경우 **계정 추가**는 `kiro-cli` 로그아웃을 거부합니다. 일반 `kiro-cli` 데이터 경로의 손상된 데이터베이스를 수리하거나 제거하고, 가져오기 전용 선택자가 설정돼 있으면 해제한 뒤 다시 시도하세요. 기존 `kiro-cli` 세션이 아예 없는 환경에서는 영향이 없습니다. + ## 3. API 키 카탈로그 opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방식 40개, OAuth 6개, 로컬 3개, diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index bd8985942..6ae1a133f 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -88,7 +88,7 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | Каталог Grok загружается в реальном времени; фолбэк по умолчанию — `grok-4.5`. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Модели Claude; актуальный список моделей загружается из `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Вход сначала импортирует и переиспользует сессию установленного `kiro-cli`. Требуется установленный Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`) и вход через `kiro-cli login`. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`) и входа через `kiro-cli login`. **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | @@ -100,10 +100,23 @@ OAuth можно запустить и из [веб-дашборда](/ru/guides OAuth-провайдеры, чьи учётные данные содержат стабильный id аккаунта или email, могут хранить несколько входов. Страница Providers показывает эти аккаунты в выпадающем списке, позволяет добавить ещё один и переключает активный аккаунт, не выполняя выход из остальных. Учётные данные -Kimi и Kiro без идентификатора заменяют свой активный слот, а `chatgpt` всегда занимает один слот, -поскольку у пула аккаунтов Codex отдельный реестр. Токены остаются в `~/.opencodex/auth.json`; +Только учётные данные Kimi без идентификатора заменяют активный слот; аккаунты Kiro сохраняются по ARN профиля. +`chatgpt` всегда занимает один слот, поскольку у пула аккаунтов Codex отдельный реестр. Токены остаются в `~/.opencodex/auth.json`; `/api/oauth/accounts` возвращает только маскированные метаданные. +### Импорт учётных данных Kiro + +Для входа Kiro требуется Kiro CLI: установите его командой `curl -fsSL https://cli.kiro.dev/install | bash` и сначала выполните `kiro-cli login`. Если сессии `kiro-cli` нет, `ocx login kiro` использует вставленный токен доступа или переменную окружения `KIRO_ACCESS_TOKEN`. + +Обычный импорт `ocx login kiro` открывает базу SQLite CLI только для чтения и не изменяет базу, WAL или SHM. + +- `KIROCLI_DB_PATH` выбирает нестандартную базу SQLite Kiro CLI; указанная база должна уже существовать. +- `KIROCLI_TOKEN_KEY` выбирает точный ключ строки `auth_kv`, если найдено несколько неоднозначных строк с токенами. Без выбора вход завершается ошибкой, а не пытается угадать строку. + +Импортированные учётные данные сохраняются в `~/.opencodex/auth.json`. Откат **Добавить аккаунт** — отдельная операция: при восстановлении предыдущего снимка она заменяет базу и удаляет текущие sidecar-файлы WAL, SHM и journal. + +Поскольку откат возможен только при наличии снимка, **Добавить аккаунт** откажется выходить из `kiro-cli`, если хранилище сессии существует, но его нельзя захватить (файл не читается, несовпадение схемы, неоднозначный выбор токена), если `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` направляют импорт не на активное хранилище CLI, или если в основной базе CLI нет распознаваемой строки токена. Исправьте или удалите повреждённую базу по обычному пути данных `kiro-cli`, снимите селекторы только для импорта и повторите попытку. На машины без существующей сессии `kiro-cli` это не влияет. + ## 3. Каталог API-ключей opencodex поставляется с 53 встроенными пресетами: 42 на основе ключей, семь OAuth, три локальных и diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 93caa2a63..390b8972b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -74,7 +74,7 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | 优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 优先复用已安装的 `kiro-cli` 登录。需先安装 Kiro CLI(`curl -fsSL https://cli.kiro.dev/install | bash`)并执行 `kiro-cli login`。 | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(使用 `curl -fsSL https://cli.kiro.dev/install | bash` 安装,然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 | @@ -83,10 +83,23 @@ ocx logout ### 多个 OAuth 账号 OAuth 凭据中带有稳定账号 id 或邮箱的提供商可以保存多个登录。Providers 页面会在下拉列表中显示这些 -账号,允许继续添加,并在不登出其他账号的情况下切换当前账号。没有身份信息的 Kimi 和 Kiro 会替换 -当前 active slot;`chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 +账号,允许继续添加,并在不登出其他账号的情况下切换当前账号。只有没有身份信息的 Kimi 凭据会替换 +当前 active slot;Kiro 账户以配置文件 ARN 为键。`chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 `~/.opencodex/auth.json` 中;`/api/oauth/accounts` 只返回脱敏后的 metadata。 +### Kiro 凭据导入 + +Kiro 登录需要 Kiro CLI:使用 `curl -fsSL https://cli.kiro.dev/install | bash` 安装,并先运行 `kiro-cli login`。如果没有 `kiro-cli` 会话,`ocx login kiro` 会回退到粘贴的访问令牌或 `KIRO_ACCESS_TOKEN` 环境变量。 + +普通的 `ocx login kiro` 导入会以只读方式打开 CLI SQLite 数据库,不修改数据库、WAL 或 SHM。 + +- `KIROCLI_DB_PATH` 用于选择非标准位置的 Kiro CLI SQLite 数据库;指定的数据库必须已经存在。 +- `KIROCLI_TOKEN_KEY` 在存在多个含糊的令牌行时选择确切的 `auth_kv` 行键。缺少选择值时,登录会失败而不会猜测。 + +导入的凭据会保存到 `~/.opencodex/auth.json`。**添加账户**的回滚是独立流程:恢复之前的快照时会替换数据库,并删除当前的 WAL、SHM 和 journal 边车文件。 + +由于回滚依赖快照,当会话存储已存在但无法捕获时(文件不可读、架构不匹配、令牌选择有歧义),当 `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` 将导入路径指向与活动 CLI 存储不同的位置时,或当主 CLI 数据库没有可识别的令牌行时,**添加账户**会拒绝将 `kiro-cli` 登出。请修复或删除常规 `kiro-cli` 数据路径下的损坏数据库,并取消仅用于导入的选择器后重试。对于完全没有现有 `kiro-cli` 会话的机器,不受影响。 + ## 3. API 密钥目录 opencodex v2.7.1 内置 50 个预设:40 个密钥预设、6 个 OAuth 预设、3 个本地预设,以及默认的 diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 117123bcf..8d022c2d7 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -1445,8 +1445,8 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") { throw new Error("kiro token missing — run ocx login kiro"); } - const region = resolveKiroApiRegion(); - const profileArn = resolveKiroProfileArn(); + const region = resolveKiroApiRegion(parsed._kiroAuthContext); + const profileArn = resolveKiroProfileArn(parsed._kiroAuthContext); const fp = fingerprint().slice(0, 64); const headers: Record = { authorization: `Bearer ${provider.apiKey}`, diff --git a/src/oauth/index.ts b/src/oauth/index.ts index da6fbc858..2eb48de77 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1,13 +1,13 @@ -import type { OAuthController, OAuthCredentials } from "./types"; +import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types"; import { parseCallbackInput } from "./callback-server"; import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types"; import { loadConfig, resolveEnvValue, saveConfig } from "../config"; import { maskEmail } from "../lib/privacy"; -import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store"; +import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro"; +import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store"; import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai"; import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic"; import { loginKimi, refreshKimiToken } from "./kimi"; -import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro"; import { loginChatGPT, refreshChatGPTToken } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; @@ -45,6 +45,8 @@ export interface OAuthAccessSnapshot { accountId: string; generation: string; accessToken: string; + /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */ + kiro?: Pick; } const tokenRefreshes = new Map>(); @@ -60,7 +62,11 @@ export interface LoginOpts { forceLogin?: boolean; /** When set, persist into th interface OAuthProviderDef { login(ctrl: OAuthController, opts?: LoginOpts): Promise; - refresh(refreshToken: string, signal?: AbortSignal): Promise; + refresh( + refreshToken: string, + signal?: AbortSignal, + credential?: OAuthCredentials, + ): Promise; /** provider entry written into config.json on first login. */ providerConfig: OcxProviderConfig; defaultModel: string; @@ -108,8 +114,8 @@ export const OAUTH_PROVIDERS: Record = { defaultModel: oauthDefaultModel("kimi"), }, kiro: { - login: (ctrl) => loginKiro(ctrl), - refresh: (rt, signal) => refreshKiroToken(rt, signal), + login: (ctrl, opts) => loginKiro(ctrl, { forceLogin: opts?.forceLogin }), + refresh: (rt, signal, credential) => refreshKiroToken(rt, signal, credential), providerConfig: oauthConfig("kiro"), defaultModel: oauthDefaultModel("kiro"), }, @@ -195,11 +201,25 @@ export class OAuthLoginRequiredError extends Error { } function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot { + const storedKiroRouting = { + ...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}), + ...(cred.kiro?.apiRegion ? { apiRegion: cred.kiro.apiRegion } : {}), + ...(cred.kiro?.ssoRegion ? { ssoRegion: cred.kiro.ssoRegion } : {}), + }; return { provider, accountId, generation: credentialGeneration(cred), accessToken: cred.access, + // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials + // may use explicit environment routing, but never borrow the currently signed-in local CLI account. + ...(provider === "kiro" + ? { + kiro: Object.keys(storedKiroRouting).length > 0 + ? storedKiroRouting + : environmentKiroRoutingMetadata() ?? {}, + } + : {}), }; } @@ -268,12 +288,6 @@ export async function getValidAccessTokenForAccount(provider: string, accountId: return (await resolveAccessSnapshotForAccount(provider, accountId)).accessToken; } -function readFreshKiroCliCredential(): OAuthCredentials | undefined { - const imported = readKiroCliSqlite(); - if (!imported || imported.expires <= Date.now() + REFRESH_SKEW_MS) return undefined; - return { access: imported.access, refresh: imported.refresh, expires: imported.expires, source: "local-cli" }; -} - /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */ function isTerminalRefreshError(err: unknown): boolean { const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); @@ -287,6 +301,7 @@ function isTerminalRefreshError(err: unknown): boolean { function terminal(error:unknown):boolean{ if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""); if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??""); + if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined; return isTerminalRefreshError(error); } function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;} @@ -298,6 +313,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede ...(fresh.apiBaseUrl === undefined && previous.apiBaseUrl ? { apiBaseUrl: previous.apiBaseUrl } : {}), ...(fresh.email === undefined && previous.email ? { email: previous.email } : {}), ...(fresh.accountId === undefined && previous.accountId ? { accountId: previous.accountId } : {}), + ...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}), }; } export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise{const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(!terminal(error))throw error;permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),now()+XAI_PERMANENT_FAILURE_TTL_MS);await markAccountNeedsReauthIfGeneration(provider,accountId,generation);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}} @@ -396,7 +412,7 @@ export async function refreshGenericAccountWithLock( } const generation = credentialGeneration(stored); try { - const fresh = merged(await def.refresh(stored.refresh), stored); + const fresh = merged(await def.refresh(stored.refresh, undefined, stored), stored); const outcome = await mergeAccountCredential(provider, accountId, fresh, { expectedGeneration: generation, afterPrePersistRead: deps.afterPrePersistRead, @@ -408,7 +424,7 @@ export async function refreshGenericAccountWithLock( logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId }); return fresh.access; } catch (error) { - if (!isTerminalRefreshError(error)) throw error; + if (!terminal(error)) throw error; await markAccountNeedsReauthIfGeneration(provider, accountId, generation); throw new OAuthLoginRequiredError(provider); } @@ -423,30 +439,9 @@ async function refreshAndPersistAccessToken( def: OAuthProviderDef, cred: OAuthCredentials, ): Promise { - // Local-CLI import fallback only for the ACTIVE account: importing another identity's - // token under a background account id would silently contaminate that account. - const isActive = getAccountSet(provider)?.activeAccountId === accountId; - if (provider === "kiro" && isActive) { - const imported = readFreshKiroCliCredential(); - if (imported) { - await saveCredential(provider, imported); - return imported.access; - } - } if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred); if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred); - try { - return await refreshGenericAccountWithLock(provider, accountId, def, cred); - } catch (err) { - if (provider === "kiro" && isActive) { - const imported = readFreshKiroCliCredential(); - if (imported) { - await saveCredential(provider, imported); - return imported.access; - } - } - throw err; - } + return refreshGenericAccountWithLock(provider, accountId, def, cred); } /** @@ -646,27 +641,101 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { config.providers[provider] = next; } +interface RunLoginDeps { + saveCredential?: typeof saveCredential; + saveAccountCredential?: typeof saveAccountCredential; + loadConfig?: typeof loadConfig; + saveConfig?: typeof saveConfig; + settleKiroLoginTransaction?: typeof settleKiroLoginTransaction; + removeAccount?: typeof removeAccount; + setActiveAccount?: typeof setActiveAccount; +} + +/** Roll back only accounts created by this forced login, preserving concurrent refreshes of others. */ +async function rollbackForcedKiroAccountWrite( + provider: string, + previousActiveId: string | undefined, + previousAccountIds: ReadonlySet, + deps: Pick, +): Promise { + const set = getAccountSet(provider); + if (!set) return; + for (const account of [...set.accounts]) { + if (previousAccountIds.has(account.id)) continue; + await (deps.removeAccount ?? removeAccount)(provider, account.id); + } + if (previousActiveId && getAccountCredential(provider, previousActiveId)) { + await (deps.setActiveAccount ?? setActiveAccount)(provider, previousActiveId); + } +} + /** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */ -export async function runLogin(provider: string, ctrl: OAuthController, opts?: LoginOpts): Promise { +export async function runLogin( + provider: string, + ctrl: OAuthController, + opts?: LoginOpts, + deps: RunLoginDeps = {}, +): Promise { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); + // loginKiro keys its pending CLI-session transaction by object identity. Keep this exact object + // for settlement even when source normalization below creates a derived credential object. + const shouldRollbackKiroAccounts = provider === "kiro" && opts?.forceLogin === true; + const previousKiroAccounts = shouldRollbackKiroAccounts ? getAccountSet(provider) : undefined; + const previousKiroActiveId = previousKiroAccounts?.activeAccountId; + const previousKiroAccountIds = new Set(previousKiroAccounts?.accounts.map(account => account.id) ?? []); const rawCred = await def.login(ctrl, opts); const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; - if (opts?.reauthAccountId) { - const existing = getAccountCredential(provider, opts.reauthAccountId); - if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`); - const expected = existing.accountId ?? existing.email; - const got = cred.accountId ?? cred.email; - if (!expected) { - throw new Error("Could not verify signed-in account identity for reauth."); + const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; + try { + if (opts?.reauthAccountId) { + const existing = getAccountCredential(provider, opts.reauthAccountId); + if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`); + if (!existing.accountId && !existing.email) { + throw new Error("Could not verify signed-in account identity for reauth."); + } + const identityMatches = existing.accountId && cred.accountId + ? existing.accountId === cred.accountId + : existing.email && cred.email + ? existing.email.toLowerCase() === cred.email.toLowerCase() + : false; + if (!identityMatches) { + throw new Error("Signed-in account does not match the selected account. Sign in with the same account."); + } + await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred); + } else { + await (deps.saveCredential ?? saveCredential)(provider, cred, { + preserveIdentityless: provider === "kiro" && opts?.forceLogin === true, + }); } - if (!got || expected !== got) { - throw new Error("Signed-in account does not match the selected account. Sign in with the same account."); + if (provider !== "chatgpt") { + const config = (deps.loadConfig ?? loadConfig)(); + upsertOAuthProvider(config, provider); + (deps.saveConfig ?? saveConfig)(config); } - await saveAccountCredential(provider, opts.reauthAccountId, cred); - } else { - await saveCredential(provider, cred); + } catch (error) { + const errors: unknown[] = [error]; + if (shouldRollbackKiroAccounts) { + try { + await rollbackForcedKiroAccountWrite(provider, previousKiroActiveId, previousKiroAccountIds, deps); + } catch (rollbackError) { + errors.push(rollbackError); + } + } + try { + settleKiroTransaction(rawCred, false); + } catch (restoreError) { + errors.push(restoreError); + } + if (errors.length > 1) { + throw new AggregateError( + errors, + "Kiro login persistence failed and the previous Kiro CLI session could not be restored.", + ); + } + throw error; } + settleKiroTransaction(rawCred, true); if (provider !== "chatgpt") { try { const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota"); @@ -676,10 +745,6 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L // Quota module may be unavailable in tightly scoped unit tests. } } - if (provider === "chatgpt") return cred; - const config = loadConfig(); - upsertOAuthProvider(config, provider); - saveConfig(config); return cred; } diff --git a/src/oauth/kiro-credentials.ts b/src/oauth/kiro-credentials.ts index 880785684..dc54d21cb 100644 --- a/src/oauth/kiro-credentials.ts +++ b/src/oauth/kiro-credentials.ts @@ -1,9 +1,16 @@ -import { existsSync, readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { Database } from "bun:sqlite"; const DEFAULT_EXPIRES_MS = 3600_000; +const KIRO_CLI_RECOVERY_SUFFIX = ".opencodex-recovery"; +const KIRO_CLI_RECOVERY_HEADER_V1 = Buffer.from("opencodex-kiro-session-v1\n", "utf8"); +const KIRO_CLI_RECOVERY_HEADER = Buffer.from("opencodex-kiro-session-v2\n", "utf8"); +const KIRO_CLI_RECOVERY_PROCESS_INSTANCE = randomUUID(); +const KIRO_CLI_RECOVERY_PROCESS_INSTANCE_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; +const SQLITE_DATABASE_HEADER = Buffer.from("SQLite format 3\0", "binary"); const KIRO_REGION_PATTERN = /^[a-z]{2}(?:-[a-z]+)+-\d$/; const CLIENT_ID_HASH_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; const TOKEN_KEYS = [ @@ -49,6 +56,13 @@ export interface ImportedKiroCredential { clientSecret?: string; } +/** Opaque backup retained on disk while a forced Kiro CLI login is pending persistence. */ +export interface KiroCliSessionSnapshot { + readonly path: string; + readonly database: Buffer; + readonly recoveryPath: string; +} + type JsonObject = Record; function userHome(): string { @@ -108,18 +122,30 @@ function jsonCredentialPaths(): string[] { .map(expandPath); } -function sqliteEntries(): Array<{ location: KiroImportDiagnostic["location"]; path: string }> { +function nativeKiroCliSessionEntries(): Array<{ location: "kiro-cli-data" | "kiro-cli-linux-data"; path: string }> { const home = userHome(); - const configured = process.env.KIROCLI_DB_PATH?.trim() || process.env.KIRO_CLI_DB_FILE?.trim(); - if (configured) return [{ location: "kiro-cli-db-env", path: expandPath(configured) }]; + // Only the stores that `kiro-cli logout` / `kiro-cli login` themselves mutate. Import fallbacks + // (Amazon Q / SSO cache) and KIROCLI_DB_PATH selectors must not be snapshotted for rollback. return [ { location: "kiro-cli-data", path: join(home, "Library", "Application Support", "kiro-cli", "data.sqlite3") }, { location: "kiro-cli-linux-data", path: join(home, ".local", "share", "kiro-cli", "data.sqlite3") }, - { location: "amazon-q-data", path: join(home, ".local", "share", "amazon-q", "data.sqlite3") }, - { location: "kiro-sso-cache", path: join(home, ".kiro", "sso", "cache.db") }, ]; } +function sqliteEntries(): Array<{ location: KiroImportDiagnostic["location"]; path: string }> { + const configured = process.env.KIROCLI_DB_PATH?.trim() || process.env.KIRO_CLI_DB_FILE?.trim(); + if (configured) return [{ location: "kiro-cli-db-env", path: expandPath(configured) }]; + return [ + ...nativeKiroCliSessionEntries(), + { location: "amazon-q-data", path: join(userHome(), ".local", "share", "amazon-q", "data.sqlite3") }, + { location: "kiro-sso-cache", path: join(userHome(), ".kiro", "sso", "cache.db") }, + ]; +} + +function kiroCliImportSelectorConfigured(): boolean { + return Boolean(process.env.KIROCLI_DB_PATH?.trim() || process.env.KIRO_CLI_DB_FILE?.trim()); +} + function selectTokenRow(db: Database): { value: string } | null | "ambiguous" | "selected_missing" { const rows = db.query("SELECT key, value FROM auth_kv WHERE key LIKE ? ORDER BY key ASC").all("%:token") as Array<{ key: string; value: string }>; const selectedKey = process.env.KIROCLI_TOKEN_KEY?.trim(); @@ -210,7 +236,16 @@ function readStateProfile(db: Database): { profileArn?: string; apiRegion?: stri } } -function readSqliteCredentials(diagnostics: KiroImportDiagnostic[]): ImportedKiroCredential | undefined { +interface LocatedKiroCliCredential { + credential: ImportedKiroCredential; + path: string; + database?: Buffer; +} + +function readSqliteCredentials( + diagnostics: KiroImportDiagnostic[], + includeSnapshot = false, +): LocatedKiroCliCredential | undefined { for (const { location, path } of sqliteEntries()) { if (!existsSync(path)) { diagnostics.push({ location, status: "missing" }); @@ -261,7 +296,13 @@ function readSqliteCredentials(diagnostics: KiroImportDiagnostic[]): ImportedKir const merged = { ...registrationData, ...tokenData, ...profile }; const credential = credentialFromJson(merged, "sqlite"); diagnostics.push({ location, status: credential ? "token_found" : "token_missing" }); - if (credential) return credential; + if (credential) { + return { + credential, + path, + ...(includeSnapshot ? { database: db.serialize() } : {}), + }; + } } catch (error) { if (error instanceof Error && error.message.includes("KIROCLI_TOKEN_KEY")) throw error; diagnostics.push({ location, status: "schema_mismatch" }); @@ -277,12 +318,12 @@ export function inspectKiroCredentialSources(): { credential: ImportedKiroCreden const json = readJsonCredentials(diagnostics); if (json) return { credential: json, diagnostics }; const sqlite = readSqliteCredentials(diagnostics); - return { credential: sqlite ?? null, diagnostics }; + return { credential: sqlite?.credential ?? null, diagnostics }; } export function inspectKiroCliSqliteSources(): { credential: ImportedKiroCredential | null; diagnostics: KiroImportDiagnostic[] } { const diagnostics: KiroImportDiagnostic[] = []; - return { credential: readSqliteCredentials(diagnostics) ?? null, diagnostics }; + return { credential: readSqliteCredentials(diagnostics)?.credential ?? null, diagnostics }; } export function readImportedKiroCredential(): ImportedKiroCredential | null { @@ -292,3 +333,274 @@ export function readImportedKiroCredential(): ImportedKiroCredential | null { export function readKiroCliSqliteCredential(): ImportedKiroCredential | null { return inspectKiroCliSqliteSources().credential; } + +/** Capture the complete active CLI database so a failed account switch can restore it exactly. */ +export function snapshotKiroCliSession(): KiroCliSessionSnapshot | null { + return inspectKiroCliSessionSnapshot().snapshot; +} + +/** + * A store that exists but cannot be captured must never be logged out of: the recovery contract + * promises an exact restore, and without a snapshot a later failure would destroy the session for + * good. `missing`/`token_missing` are the only statuses that mean "there is nothing to lose". + */ +const KIRO_UNSNAPSHOTTABLE_SESSION_STATUSES: ReadonlySet = new Set([ + "unreadable", + "schema_mismatch", + "invalid_json", + "token_ambiguous", + "token_key_missing", + "token_found", +]); + +/** + * Capture the active CLI session and report why capture failed. `blocked` is true when a session + * store is present but could not be snapshotted (unreadable / schema-mismatched / ambiguous), so + * callers can abort before mutating it. + * + * Forced-login rollback must target the exact database `kiro-cli` mutates. Custom import selectors + * and lower-priority Amazon Q / SSO caches are never used here: snapshotting the wrong file would + * leave the real CLI session switched or lost after failure. + */ +export function inspectKiroCliSessionSnapshot(): { + snapshot: KiroCliSessionSnapshot | null; + diagnostics: KiroImportDiagnostic[]; + blocked: boolean; +} { + const diagnostics: KiroImportDiagnostic[] = []; + if (kiroCliImportSelectorConfigured()) { + diagnostics.push({ location: "kiro-cli-db-env", status: "token_ambiguous" }); + return { snapshot: null, diagnostics, blocked: true }; + } + + for (const { location, path } of nativeKiroCliSessionEntries()) { + if (!existsSync(path)) { + diagnostics.push({ location, status: "missing" }); + continue; + } + + let db: Database | undefined; + try { + db = new Database(path, { readonly: true }); + try { db.exec("PRAGMA busy_timeout = 5000"); } catch { /* read-only best effort */ } + } catch { + diagnostics.push({ location, status: "unreadable" }); + return { snapshot: null, diagnostics, blocked: true }; + } + + try { + const row = selectTokenRow(db); + if (row === "ambiguous") { + diagnostics.push({ location, status: "token_ambiguous" }); + return { snapshot: null, diagnostics, blocked: true }; + } + if (row === "selected_missing") { + diagnostics.push({ location, status: "token_key_missing" }); + return { snapshot: null, diagnostics, blocked: true }; + } + if (!row) { + // An existing CLI database with no recognized token is still what logout mutates. Falling + // through to Amazon Q / another platform path would snapshot the wrong store. + diagnostics.push({ location, status: "token_missing" }); + return { snapshot: null, diagnostics, blocked: true }; + } + try { + JSON.parse(row.value); + } catch { + diagnostics.push({ location, status: "invalid_json" }); + return { snapshot: null, diagnostics, blocked: true }; + } + const database = db.serialize(); + diagnostics.push({ location, status: "token_found" }); + return { + snapshot: { + path, + database, + recoveryPath: `${path}${KIRO_CLI_RECOVERY_SUFFIX}`, + }, + diagnostics, + blocked: false, + }; + } catch (error) { + if (error instanceof Error && error.message.includes("KIROCLI_TOKEN_KEY")) { + diagnostics.push({ location, status: "token_key_missing" }); + return { snapshot: null, diagnostics, blocked: true }; + } + diagnostics.push({ location, status: "schema_mismatch" }); + return { snapshot: null, diagnostics, blocked: true }; + } finally { + db.close(); + } + } + + return { + snapshot: null, + diagnostics, + blocked: diagnostics.some(entry => KIRO_UNSNAPSHOTTABLE_SESSION_STATUSES.has(entry.status)), + }; +} + +/** Persist a complete, private recovery image before kiro-cli is allowed to mutate its store. */ +export function persistKiroCliSessionRecovery(snapshot: KiroCliSessionSnapshot): void { + if (existsSync(snapshot.recoveryPath)) { + throw new Error("Kiro CLI session recovery is already pending."); + } + const nonce = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`; + const staged = `${snapshot.recoveryPath}.${nonce}.tmp`; + try { + writeFileSync(staged, Buffer.concat([ + KIRO_CLI_RECOVERY_HEADER, + Buffer.from(`${process.pid}\n`, "utf8"), + Buffer.from(`${KIRO_CLI_RECOVERY_PROCESS_INSTANCE}\n`, "utf8"), + snapshot.database, + ]), { flag: "wx", mode: 0o600 }); + try { chmodSync(staged, 0o600); } catch { /* platform may ignore chmod */ } + const fd = openSync(staged, "r+"); + try { fsyncSync(fd); } finally { closeSync(fd); } + // A hard link publishes the fsynced inode atomically and, unlike rename, can never replace + // another process's live recovery transaction if both raced past the initial existence check. + linkSync(staged, snapshot.recoveryPath); + } finally { + rmSync(staged, { force: true }); + } +} + +/** Remove recovery data only after the corresponding login transaction has settled. */ +export function discardKiroCliSessionRecovery(snapshot: KiroCliSessionSnapshot): void { + rmSync(snapshot.recoveryPath, { force: true }); +} + +interface ParsedKiroCliSessionRecovery { + ownerPid: number; + ownerProcessInstance?: string; + database: Buffer; +} + +function parseKiroCliSessionRecovery(payload: Buffer): ParsedKiroCliSessionRecovery | null { + const isV2 = payload.subarray(0, KIRO_CLI_RECOVERY_HEADER.length).equals(KIRO_CLI_RECOVERY_HEADER); + const isV1 = payload.subarray(0, KIRO_CLI_RECOVERY_HEADER_V1.length).equals(KIRO_CLI_RECOVERY_HEADER_V1); + if (!isV2 && !isV1) return null; + const headerLength = isV2 ? KIRO_CLI_RECOVERY_HEADER.length : KIRO_CLI_RECOVERY_HEADER_V1.length; + const ownerEnd = payload.indexOf(0x0a, headerLength); + if (ownerEnd <= headerLength) return null; + const ownerPid = Number(payload.subarray(headerLength, ownerEnd).toString("utf8")); + let databaseStart = ownerEnd + 1; + let ownerProcessInstance: string | undefined; + if (isV2) { + const instanceEnd = payload.indexOf(0x0a, databaseStart); + if (instanceEnd <= databaseStart) return null; + ownerProcessInstance = payload.subarray(databaseStart, instanceEnd).toString("utf8"); + if (!KIRO_CLI_RECOVERY_PROCESS_INSTANCE_PATTERN.test(ownerProcessInstance)) return null; + databaseStart = instanceEnd + 1; + } + const database = payload.subarray(databaseStart); + if ( + !Number.isSafeInteger(ownerPid) || ownerPid <= 0 || + !database.subarray(0, SQLITE_DATABASE_HEADER.length).equals(SQLITE_DATABASE_HEADER) + ) { + return null; + } + return { ownerPid, ...(ownerProcessInstance ? { ownerProcessInstance } : {}), database }; +} + +function isKiroRecoveryOwnerAlive(ownerPid: number, ownerProcessInstance?: string): boolean { + // A restarted supervisor may reuse the exact same PID (commonly PID 1). Only this process + // instance's nonce proves that a same-PID recovery transaction is still live. + if (ownerPid === process.pid) return ownerProcessInstance === KIRO_CLI_RECOVERY_PROCESS_INSTANCE; + try { + process.kill(ownerPid, 0); + return true; + } catch (error) { + if (error && typeof error === "object" && "code" in error) return error.code !== "ESRCH"; + return true; + } +} + +/** Restore a previously captured CLI database after every kiro-cli child process has exited. */ +export function restoreKiroCliSession(snapshot: KiroCliSessionSnapshot): void { + const nonce = `${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`; + const staged = `${snapshot.path}.ocx-restore.${nonce}.tmp`; + const displacedBase = `${snapshot.path}.ocx-restore.${nonce}.new`; + const displaced: Array<{ current: string; backup: string }> = []; + let published = false; + try { + writeFileSync(staged, snapshot.database, { mode: 0o600 }); + try { chmodSync(staged, 0o600); } catch { /* platform may ignore chmod */ } + for (const suffix of ["", "-wal", "-shm", "-journal"]) { + const current = `${snapshot.path}${suffix}`; + if (!existsSync(current)) continue; + const backup = `${displacedBase}${suffix || ".db"}`; + renameSync(current, backup); + displaced.push({ current, backup }); + } + renameSync(staged, snapshot.path); + published = true; + } catch (error) { + const recoveryErrors: unknown[] = []; + for (const { current, backup } of [...displaced].reverse()) { + if (!existsSync(backup) || existsSync(current)) continue; + try { renameSync(backup, current); } catch (recoveryError) { recoveryErrors.push(recoveryError); } + } + if (recoveryErrors.length > 0) throw new AggregateError([error, ...recoveryErrors], "Kiro CLI session restore failed during rollback."); + throw error; + } finally { + rmSync(staged, { force: true }); + if (published) { + for (const { backup } of displaced) { + try { rmSync(backup, { force: true }); } catch { /* restored database is already published */ } + } + } + } +} + +/** Restore a transaction abandoned by a crashed process before starting another forced login. */ +export function restoreStaleKiroCliSessionRecovery(): boolean { + for (const { path } of nativeKiroCliSessionEntries()) { + const recoveryPath = `${path}${KIRO_CLI_RECOVERY_SUFFIX}`; + if (!existsSync(recoveryPath)) continue; + const payload = readFileSync(recoveryPath); + const recovery = parseKiroCliSessionRecovery(payload); + if (!recovery) { + throw new Error( + `Kiro CLI session recovery data is invalid: ${recoveryPath}. Remove this file to continue.`, + ); + } + if (isKiroRecoveryOwnerAlive(recovery.ownerPid, recovery.ownerProcessInstance)) { + throw new Error( + `Another Kiro CLI login transaction is still in progress (pid ${recovery.ownerPid}, ${recoveryPath}).`, + ); + } + // Atomically claim the marker before restoring so a concurrent process cannot restore the + // same stale image (and later delete a newer recovery file published by the winner). + const claimedPath = `${recoveryPath}.claimed.${process.pid}.${Date.now()}`; + try { + renameSync(recoveryPath, claimedPath); + } catch { + continue; + } + try { + const claimed = readFileSync(claimedPath); + if (!claimed.equals(payload)) { + // Put the unexpected claim back so an operator / later process can inspect it. + try { renameSync(claimedPath, recoveryPath); } catch { /* keep claimed path for manual recovery */ } + throw new Error( + `Kiro CLI session recovery data changed while claiming ${recoveryPath}. Remove leftover claim files under the kiro-cli data directory and retry.`, + ); + } + restoreKiroCliSession({ + path, + database: recovery.database, + recoveryPath: claimedPath, + }); + rmSync(claimedPath, { force: true }); + return true; + } catch (error) { + // Preserve the only copy of the prior database when restore fails (disk full, permissions). + if (existsSync(claimedPath) && !existsSync(recoveryPath)) { + try { renameSync(claimedPath, recoveryPath); } catch { /* claimed file remains for manual recovery */ } + } + throw error; + } + } + return false; +} diff --git a/src/oauth/kiro.ts b/src/oauth/kiro.ts index 4a9b8b968..fd47be8cf 100644 --- a/src/oauth/kiro.ts +++ b/src/oauth/kiro.ts @@ -1,29 +1,44 @@ /** * Kiro (AWS CodeWhisperer) OAuth — import-first. * - * Unlike browser/PKCE providers, kiro reuses the locally installed kiro-cli login: - * it reads the kiro-cli SQLite token store, falls back to KIRO_ACCESS_TOKEN env, then to a - * manual access-token paste (CLI only). Refresh hits the Kiro desktop refresh endpoint. + * Normal login imports the locally installed kiro-cli session. Account-add login deliberately + * asks kiro-cli to switch identities in its supported browser flow, then imports that fresh session. * * Ported from jawcode packages/ai/src/providers/kiro.ts (readKiroCliSqlite, refreshKiroDesktopToken). - * profileArn/region are NOT stored in the credential — the kiro ADAPTER resolves them at request - * time (SQLite profile_arn / KIRO_PROFILE_ARN, KIRO_REGION) since getValidAccessToken surfaces - * only the access token. + * profileArn/region/client registration are persisted per OCX account so switching the account pool + * never combines one account's access token with another account's local Kiro profile metadata. */ -import type { OAuthController, OAuthCredentials } from "./types"; +import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types"; import { + discardKiroCliSessionRecovery, inferRegionFromProfileArn, inspectKiroCliSqliteSources, + inspectKiroCliSessionSnapshot, normalizeKiroRegion, + persistKiroCliSessionRecovery, readImportedKiroCredential, readKiroCliSqliteCredential, + restoreKiroCliSession, + restoreStaleKiroCliSessionRecovery, requireKiroRegion, + type ImportedKiroCredential, + type KiroCliSessionSnapshot, type KiroImportDiagnostic, } from "./kiro-credentials"; +import { getAccountSet, saveAccountCredential } from "./store"; const DEFAULT_REGION = "us-east-1"; const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken"; const OIDC_URL = "https://oidc.{region}.amazonaws.com/token"; +const KIRO_TERMINAL_REFRESH_ERRORS = new Set([ + "invalid_grant", + "refresh_token_reused", + "revoked", + "revoked_token", + "refresh_token_revoked", + "access_denied", + "expired_token", +]); interface ImportedKiroToken { access: string; @@ -31,6 +46,205 @@ interface ImportedKiroToken { expires: number; } +export interface KiroCliCommandResult { + exitCode: number; + stdout: string; +} + +export class KiroTokenRefreshError extends Error { + constructor( + readonly httpStatus: number, + readonly oauthError?: string, + ) { + super(`Kiro token refresh failed: ${httpStatus}${oauthError ? ` (${oauthError})` : ""}`); + this.name = "KiroTokenRefreshError"; + } +} + +export type KiroCliRunner = (args: string[], signal?: AbortSignal) => Promise; + +export interface KiroLoginOptions { + forceLogin?: boolean; + cliRunner?: KiroCliRunner; +} + +const pendingKiroLoginTransactions = new WeakMap(); +/** Forced logins that started with no native CLI DB must logout on persistence failure. */ +const pendingKiroEmptyPriorSessions = new WeakSet(); + +function logoutKiroCliBestEffort(): void { + try { + Bun.spawnSync(["kiro-cli", "logout"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + windowsHide: true, + }); + } catch { + // Best-effort rollback when the prior CLI state was empty. + } +} + +/** Settle the external CLI side of a forced login after OCX credential persistence resolves. */ +export function settleKiroLoginTransaction(credential: OAuthCredentials, persisted: boolean): void { + const snapshot = pendingKiroLoginTransactions.get(credential); + const emptyPrior = pendingKiroEmptyPriorSessions.has(credential); + pendingKiroLoginTransactions.delete(credential); + pendingKiroEmptyPriorSessions.delete(credential); + if (!persisted) { + if (snapshot) { + restoreKiroCliSession(snapshot); + discardKiroCliSessionRecovery(snapshot); + return; + } + if (emptyPrior) logoutKiroCliBestEffort(); + return; + } + if (snapshot) discardKiroCliSessionRecovery(snapshot); +} + +function restoreKiroLoginOrThrow(snapshot: KiroCliSessionSnapshot | null, emptyPrior: boolean, cause: unknown): never { + if (snapshot) { + try { + restoreKiroCliSession(snapshot); + discardKiroCliSessionRecovery(snapshot); + } catch (restoreError) { + throw new AggregateError( + [cause, restoreError], + "Kiro login failed and the previous Kiro CLI session could not be restored.", + ); + } + } else if (emptyPrior) { + logoutKiroCliBestEffort(); + } + throw cause; +} + +function throwIfKiroLoginCancelled(signal?: AbortSignal): void { + if (signal?.aborted) throw new Error("Kiro login cancelled."); +} + +async function defaultKiroCliRunner(args: string[], signal?: AbortSignal): Promise { + throwIfKiroLoginCancelled(signal); + let child: ReturnType; + try { + child = Bun.spawn(["kiro-cli", ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }); + } catch { + throw new Error("Kiro CLI is not installed or could not be started."); + } + const abort = () => child.kill(); + signal?.addEventListener("abort", abort, { once: true }); + // AbortSignal does not replay an abort that lands between the pre-check and listener registration. + if (signal?.aborted) abort(); + try { + const [exitCode, stdout] = await Promise.all([ + child.exited, + child.stdout instanceof ReadableStream ? new Response(child.stdout).text() : Promise.resolve(""), + ]); + throwIfKiroLoginCancelled(signal); + return { exitCode, stdout }; + } finally { + signal?.removeEventListener("abort", abort); + } +} + +async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal): Promise<{ email?: string }> { + try { + const result = await runner(["whoami", "--format", "json"], signal); + if (result.exitCode !== 0) return {}; + const parsed = JSON.parse(result.stdout) as { email?: unknown }; + const email = typeof parsed.email === "string" ? parsed.email.trim().toLowerCase() : ""; + return email && email.length <= 320 ? { email } : {}; + } catch { + return {}; + } +} + +function metadataFromImported(imported: ImportedKiroCredential): KiroOAuthMetadata | undefined { + const metadata: KiroOAuthMetadata = { + ...(imported.profileArn ? { profileArn: imported.profileArn } : {}), + ...(imported.ssoRegion ? { ssoRegion: imported.ssoRegion } : {}), + ...(imported.apiRegion ? { apiRegion: imported.apiRegion } : {}), + ...(imported.clientId ? { clientId: imported.clientId } : {}), + ...(imported.clientSecret ? { clientSecret: imported.clientSecret } : {}), + }; + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +/** Persistable routing subset from explicit KIRO_* environment overrides (never local CLI state). */ +export function environmentKiroRoutingMetadata(): Pick | undefined { + const profileArn = process.env.KIRO_PROFILE_ARN?.trim() || undefined; + const apiRegion = process.env.KIRO_API_REGION !== undefined + ? requireKiroRegion(process.env.KIRO_API_REGION) + : undefined; + const ssoRegion = process.env.KIRO_REGION !== undefined + ? requireKiroRegion(process.env.KIRO_REGION) + : undefined; + if (!profileArn && !apiRegion && !ssoRegion) return undefined; + return { + ...(profileArn ? { profileArn } : {}), + ...(apiRegion ? { apiRegion } : {}), + ...(ssoRegion ? { ssoRegion } : {}), + }; +} + +/** + * Before Add-account switches the external CLI identity, bind any matching legacy identity-less + * OCX row to the current CLI session. Unmatched identity-less rows are left alone here so a + * cancelled browser login cannot destroy the only stored credential; after a successful add they + * remain selectable but cannot borrow the new CLI identity (and reauth refuses identity-less slots). + */ +async function bindMatchingLegacyIdentitylessKiroAccounts( + runner: KiroCliRunner, + signal?: AbortSignal, +): Promise { + const set = getAccountSet("kiro"); + if (!set) return; + const legacyAccounts = set.accounts.filter( + account => account.credential.accountId === undefined && account.credential.email === undefined, + ); + if (legacyAccounts.length === 0) return; + + let imported: ImportedKiroCredential | null = null; + try { + imported = readKiroCliSqliteCredential(); + } catch { + imported = null; + } + if (!imported?.refresh) return; + + for (const account of legacyAccounts) { + const refresh = account.credential.refresh; + if (!refresh || imported.refresh !== refresh) continue; + const bound = await oauthCredentialFromImported(imported, runner, signal); + if (!bound.accountId && !bound.email) continue; + await saveAccountCredential("kiro", account.id, bound); + } +} + +async function oauthCredentialFromImported( + imported: ImportedKiroCredential, + runner: KiroCliRunner, + signal?: AbortSignal, +): Promise { + const identity = imported.source === "sqlite" ? await readKiroCliIdentity(runner, signal) : {}; + const metadata = metadataFromImported(imported); + return { + access: imported.access, + refresh: imported.refresh, + expires: imported.expires, + source: imported.source === "json" ? "credential-file" : "local-cli", + ...(imported.profileArn ? { accountId: imported.profileArn } : {}), + ...(identity.email ? { email: identity.email } : {}), + ...(metadata ? { kiro: metadata } : {}), + }; +} + export type KiroCliImportDiagnosticStatus = KiroImportDiagnostic["status"]; export type KiroCliImportDiagnostic = KiroImportDiagnostic; @@ -54,22 +268,73 @@ export function readKiroCliSqlite(): ImportedKiroToken | null { * so the GUI renders the paste-input field, then blocks on onManualCodeInput for the token. * If neither onAuth nor onManualCodeInput is available, throws a clear error. */ -export async function loginKiro(ctrl: OAuthController): Promise { +export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions = {}): Promise { + const runner = options.cliRunner ?? defaultKiroCliRunner; + // A prior process may have exited after switching the external CLI account but before + // settlement. Recover that durable transaction before either importing or switching again. + restoreStaleKiroCliSessionRecovery(); + if (options.forceLogin) { + throwIfKiroLoginCancelled(ctrl.signal); + ctrl.onAuth?.({ + url: "", + instructions: "Kiro CLI is opening a fresh browser login. This also switches the account used by kiro-cli.", + }); + ctrl.onProgress?.("Opening a fresh Kiro CLI browser login."); + // Never log out of a session we could not capture: the recovery contract promises an exact + // restore, so an uncapturable store (unreadable / schema-mismatched / ambiguous token) must + // abort here rather than let a later failure destroy it permanently. + const inspected = inspectKiroCliSessionSnapshot(); + if (inspected.blocked) { + throw new Error( + "Kiro CLI session could not be backed up, so OCX will not sign it out. " + + "Repair or remove the unreadable kiro-cli credential database " + + "(usually `~/.local/share/kiro-cli/data.sqlite3` or " + + "`~/Library/Application Support/kiro-cli/data.sqlite3`), " + + "unset KIROCLI_DB_PATH / KIRO_CLI_DB_FILE if set for import-only overrides, then retry.", + ); + } + const previousSession = inspected.snapshot; + await bindMatchingLegacyIdentitylessKiroAccounts(runner, ctrl.signal); + if (previousSession) persistKiroCliSessionRecovery(previousSession); + try { + const logout = await runner(["logout"], ctrl.signal); + throwIfKiroLoginCancelled(ctrl.signal); + if (logout.exitCode !== 0) throw new Error("Kiro CLI could not prepare a fresh login."); + const login = await runner(["login"], ctrl.signal); + throwIfKiroLoginCancelled(ctrl.signal); + if (login.exitCode !== 0) throw new Error("Kiro CLI login did not complete successfully."); + const fresh = readKiroCliSqliteCredential(); + if (!fresh) throw new Error("Kiro CLI login completed but no credential could be imported."); + const credential = await oauthCredentialFromImported(fresh, runner, ctrl.signal); + throwIfKiroLoginCancelled(ctrl.signal); + if (!credential.accountId && !credential.email) { + throw new Error("Kiro login completed but OCX could not determine a stable account identity."); + } + if (previousSession) pendingKiroLoginTransactions.set(credential, previousSession); + else pendingKiroEmptyPriorSessions.add(credential); + return credential; + } catch (error) { + restoreKiroLoginOrThrow(previousSession, !previousSession, error); + } + } + const imported = readImportedKiroCredential(); if (imported) { ctrl.onProgress?.(imported.source === "json" ? "Imported token from Kiro credentials file." : "Imported token from installed kiro-cli login."); - return { - access: imported.access, - refresh: imported.refresh, - expires: imported.expires, - source: imported.source === "json" ? "credential-file" : "local-cli", - }; + return oauthCredentialFromImported(imported, runner, ctrl.signal); } const envToken = process.env.KIRO_ACCESS_TOKEN; if (envToken) { ctrl.onProgress?.("Using KIRO_ACCESS_TOKEN from environment."); - return { access: envToken, refresh: process.env.KIRO_REFRESH_TOKEN ?? "", expires: Date.now() + 3600_000, source: "environment" }; + const routing = environmentKiroRoutingMetadata(); + return { + access: envToken, + refresh: process.env.KIRO_REFRESH_TOKEN ?? "", + expires: Date.now() + 3600_000, + source: "environment", + ...(routing ? { kiro: routing } : {}), + }; } if (ctrl.onManualCodeInput) { @@ -85,7 +350,16 @@ export async function loginKiro(ctrl: OAuthController): Promise): string { + if (account !== undefined) { + return ( + normalizeKiroRegion(account.apiRegion) || + inferRegionFromProfileArn(account.profileArn) || + normalizeKiroRegion(account.ssoRegion) || + DEFAULT_REGION + ); + } if (process.env.KIRO_API_REGION !== undefined) return requireKiroRegion(process.env.KIRO_API_REGION); + const imported = readImportedKiroCredential(); return ( normalizeKiroRegion(imported?.apiRegion) || inferRegionFromProfileArn(imported?.profileArn) || @@ -116,15 +399,29 @@ export function resolveKiroApiRegion(): string { /** * Resolve the CodeWhisperer profileArn for request-time use by the adapter. - * KIRO_PROFILE_ARN env → kiro-cli SQLite `profile_arn`. Returns undefined if absent - * (the adapter decides whether that is fatal). + * Account metadata is authoritative. Legacy accountless calls use KIRO_PROFILE_ARN → local import. + * Returns undefined if absent (the adapter decides whether that is fatal). */ -export function resolveKiroProfileArn(): string | undefined { +export function resolveKiroProfileArn(account?: Pick): string | undefined { + if (account !== undefined) return account.profileArn; const env = process.env.KIRO_PROFILE_ARN; if (env) return env; return readImportedKiroCredential()?.profileArn; } +async function kiroTokenRefreshError(response: Response): Promise { + let oauthError: string | undefined; + try { + const payload = await response.json() as { error?: unknown }; + if (typeof payload.error === "string" && KIRO_TERMINAL_REFRESH_ERRORS.has(payload.error)) { + oauthError = payload.error; + } + } catch { + // Error bodies are untrusted and intentionally excluded from the surfaced message. + } + return new KiroTokenRefreshError(response.status, oauthError); +} + async function readTokenResponse(res: Response, oldRefresh: string): Promise { const data = (await res.json()) as { accessToken?: string; refreshToken?: string; expiresIn?: number }; if (!data.accessToken) throw new Error("Kiro refresh returned no accessToken"); @@ -135,43 +432,126 @@ async function readTokenResponse(res: Response, oldRefresh: string): Promise { - const region = resolveKiroRegion(); +function kiroRefreshSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(30_000); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +async function refreshKiroDesktopToken(refresh: string, signal?: AbortSignal, metadata?: KiroOAuthMetadata): Promise { + const region = resolveKiroRegion(metadata); const res = await fetch(REFRESH_URL.replace("{region}", region), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken: refresh }), - signal: signal ?? AbortSignal.timeout(30_000), + signal: kiroRefreshSignal(signal), }); - if (!res.ok) throw new Error(`Kiro token refresh failed: ${res.status}`); + if (!res.ok) throw await kiroTokenRefreshError(res); return readTokenResponse(res, refresh); } -async function refreshAwsSsoOidcToken(refresh: string, signal?: AbortSignal): Promise { - const imported = readImportedKiroCredential(); - if (!imported?.clientId || !imported.clientSecret) return refreshKiroDesktopToken(refresh, signal); - const region = resolveKiroRegion(); +async function refreshAwsSsoOidcToken( + refresh: string, + signal?: AbortSignal, + credential?: OAuthCredentials, +): Promise { + let metadata = credential?.kiro; + if (!metadata) { + const local = readImportedKiroCredential(); + // Only a local store holding this exact refresh token describes this account. Anything else + // belongs to whichever account kiro-cli is currently signed into. + if (local?.refresh === refresh) metadata = metadataFromImported(local); + } + // A stored OCX account with no usable `kiro` metadata must still refresh account-scoped: falling + // through to `resolveKiroRegion(undefined)` would read KIRO_REGION or the local CLI import and + // borrow an unrelated account's region after a switch. Environment/manual credentials may still + // honor explicit KIRO_* routing; other stored accounts pin an empty marker (default region). + // Only a truly accountless refresh (no stored credential) keeps the legacy env/local fallback. + if (!metadata && credential) { + metadata = credential.source === "environment" || credential.source === "manual" + ? environmentKiroRoutingMetadata() ?? {} + : {}; + } + const clientId = metadata?.clientId; + const clientSecret = metadata?.clientSecret; + if (!clientId || !clientSecret) return refreshKiroDesktopToken(refresh, signal, metadata); + const region = resolveKiroRegion(metadata); const run = async (refreshToken: string): Promise => fetch(OIDC_URL.replace("{region}", region), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grantType: "refresh_token", - clientId: imported.clientId, - clientSecret: imported.clientSecret, + clientId, + clientSecret, refreshToken, }), - signal: signal ?? AbortSignal.timeout(30_000), + signal: kiroRefreshSignal(signal), }); - let res = await run(refresh); - if (!res.ok && res.status === 400 && imported.source === "sqlite") { - const reloaded = readImportedKiroCredential(); - if (reloaded?.refresh && reloaded.refresh !== refresh) res = await run(reloaded.refresh); - } - if (!res.ok) throw new Error(`Kiro AWS SSO OIDC refresh failed: ${res.status}`); + const res = await run(refresh); + if (!res.ok) throw await kiroTokenRefreshError(res); return readTokenResponse(res, refresh); } -export async function refreshKiroToken(refresh: string, signal?: AbortSignal): Promise { +function matchingRotatedKiroCliCredential( + refresh: string, + credential?: OAuthCredentials, +): ImportedKiroCredential | undefined { + const storedIdentities = [credential?.kiro?.profileArn, credential?.accountId] + .filter((value): value is string => Boolean(value)); + if (storedIdentities.length === 0 || new Set(storedIdentities).size !== 1) return undefined; + try { + const local = readKiroCliSqliteCredential(); + if (!local?.profileArn || storedIdentities.some(identity => identity !== local.profileArn)) return undefined; + if (!local.refresh || local.refresh === refresh) return undefined; + return local; + } catch { + // An unrelated or malformed local store must not block the stored OCX account. + return undefined; + } +} + +function metadataForRotatedKiroCliCredential( + credential: OAuthCredentials, + local: ImportedKiroCredential, +): KiroOAuthMetadata { + const { + clientId: _storedClientId, + clientSecret: _storedClientSecret, + ...storedRouting + } = credential.kiro ?? {}; + const localMetadata = metadataFromImported(local) ?? {}; + const { + clientId: _localClientId, + clientSecret: _localClientSecret, + ...localRouting + } = localMetadata; + return { + ...storedRouting, + ...localRouting, + ...(local.authType === "aws_sso_oidc" && local.clientId && local.clientSecret + ? { clientId: local.clientId, clientSecret: local.clientSecret } + : {}), + }; +} + +export async function refreshKiroToken( + refresh: string, + signal?: AbortSignal, + credential?: OAuthCredentials, +): Promise { if (!refresh) throw new Error("Kiro: no refresh token available (re-run `kiro-cli login`)."); - return refreshAwsSsoOidcToken(refresh, signal); + try { + return await refreshAwsSsoOidcToken(refresh, signal, credential); + } catch (error) { + if (!(error instanceof KiroTokenRefreshError) || error.httpStatus !== 400) throw error; + if (!credential) throw error; + const local = matchingRotatedKiroCliCredential(refresh, credential); + if (!local) throw error; + const retryMetadata = metadataForRotatedKiroCliCredential(credential, local); + const fresh = await refreshAwsSsoOidcToken(local.refresh, signal, { + ...credential, + refresh: local.refresh, + kiro: retryMetadata, + }); + return { ...fresh, kiro: retryMetadata }; + } } diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 69110cace..ad761d6a0 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -10,7 +10,7 @@ * Exceptions: * - `chatgpt` stays single-slot (always replaced): codex-auth-api uses it as a scratch slot * for Codex pool logins, which have their own ledger (codex-accounts.json). - * - Credentials without identity (no accountId/email — e.g. kiro) replace the active slot + * - Credentials without identity (no accountId/email) replace the active slot * instead of appending: their refresh tokens rotate, so a derived id would duplicate the * same human on every re-login. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor * extracts JWT `sub` — both append distinct accounts under multiauth. @@ -206,6 +206,28 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null { const validated = validateCopilotApiBaseUrl(candidate.apiBaseUrl); if (validated) normalized.apiBaseUrl = validated; } + if (candidate.kiro && typeof candidate.kiro === "object") { + const kiro = candidate.kiro; + const clean = (value: unknown, max: number): string | undefined => { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed && trimmed.length <= max && !/[\x00-\x1f\x7f]/.test(trimmed) ? trimmed : undefined; + }; + const profileArn = clean(kiro.profileArn, 1024); + const ssoRegion = clean(kiro.ssoRegion, 64); + const apiRegion = clean(kiro.apiRegion, 64); + const clientId = clean(kiro.clientId, 4096); + const clientSecret = clean(kiro.clientSecret, 4096); + if (profileArn || ssoRegion || apiRegion || clientId || clientSecret) { + normalized.kiro = { + ...(profileArn ? { profileArn } : {}), + ...(ssoRegion ? { ssoRegion } : {}), + ...(apiRegion ? { apiRegion } : {}), + ...(clientId ? { clientId } : {}), + ...(clientSecret ? { clientSecret } : {}), + }; + } + } return normalized; } @@ -293,7 +315,11 @@ export function getCredential(provider: string): OAuthCredentials | null { * (rotating refresh tokens would fabricate duplicates) and single-slot providers replace the * active slot / whole set instead. */ -export async function saveCredential(provider: string, cred: OAuthCredentials): Promise { +export async function saveCredential( + provider: string, + cred: OAuthCredentials, + opts: { preserveIdentityless?: boolean } = {}, +): Promise { const safe = normalizeCredential(cred); if (!safe) return; await mutateStore(store => { @@ -317,7 +343,7 @@ export async function saveCredential(provider: string, cred: OAuthCredentials): // active identity-less row in place prevents a stale duplicate that stays selectable // and would re-refresh into a second row with the same identity. const active = set.accounts.find(a => a.id === set.activeAccountId); - if (active && active.credential.accountId === undefined && active.credential.email === undefined) { + if (!opts.preserveIdentityless && active && active.credential.accountId === undefined && active.credential.email === undefined) { active.credential = safe; delete active.needsReauth; return; @@ -418,6 +444,29 @@ export async function removeAccount(provider: string, accountId: string): Promis }); } +/** Replace or clear a provider account set (used for transactional Kiro add-account rollback). */ +export async function replaceProviderAccountSet( + provider: string, + set: ProviderAccountSet | null, +): Promise { + await mutateStore(store => { + if (!set || set.accounts.length === 0) { + delete store[provider]; + return; + } + store[provider] = { + activeAccountId: set.activeAccountId, + accounts: set.accounts.map(account => ({ + id: account.id, + credential: { ...account.credential, ...(account.credential.kiro ? { kiro: { ...account.credential.kiro } } : {}) }, + ...(account.alias ? { alias: account.alias } : {}), + ...(account.needsReauth ? { needsReauth: true } : {}), + ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}), + })), + }; + }); +} + export async function markAccountNeedsReauth(provider: string, accountId: string, needsReauth: boolean): Promise { await mutateStore(store => { const account = store[provider]?.accounts.find(a => a.id === accountId); diff --git a/src/oauth/types.ts b/src/oauth/types.ts index 1ec061f76..e39a79c6a 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -1,6 +1,15 @@ /** Minimal OAuth types, ported from jawcode packages/ai/src/utils/oauth/types.ts. */ export type OAuthCredentialSource = "oauth" | "local-cli" | "credential-file" | "environment" | "manual"; +/** Account-scoped Kiro data required for refresh and request routing. */ +export interface KiroOAuthMetadata { + profileArn?: string; + ssoRegion?: string; + apiRegion?: string; + clientId?: string; + clientSecret?: string; +} + export type OAuthCredentials = { refresh: string; access: string; @@ -15,6 +24,8 @@ export type OAuthCredentials = { * Never reuse for Antigravity projectId; validated on write and again at request time. */ apiBaseUrl?: string; + /** Never returned by management APIs; persisted only inside the protected auth-store boundary. */ + kiro?: KiroOAuthMetadata; }; /** One logged-in account inside a provider's account set (multiauth). */ diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5902c5b29..5552b845f 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -506,7 +506,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ baseUrl: "https://runtime.us-east-1.kiro.dev", authKind: "oauth", oauthId: "kiro", - note: "Import-first: reuses your installed Kiro CLI login — requires kiro-cli installed and signed in (`kiro-cli login`). Experimental third-party harness — see Kiro ToS.", + note: "Import-first: reuses your installed and signed-in Kiro CLI session (requires `kiro-cli login`). Add account logs `kiro-cli` out, switches it through a fresh browser login, stores the account by profile ARN, and restores the previous CLI session on cancellation or failure. Experimental third-party harness — see Kiro ToS.", models: KIRO_MODELS, defaultModel: "kiro-auto", // Kiro speaks CodeWhisperer wire, not OpenAI-style GET /models. Keep the static diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index fbed02554..2472b6608 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1028,6 +1028,11 @@ export async function handleResponses( const resolved = await getValidAccessTokenSnapshot(route.providerName); if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved; route.provider = { ...route.provider, apiKey: resolved.accessToken }; + if (route.providerName === "kiro") { + // `{}` is intentional: this is an account-scoped request with no stored routing metadata. + // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback. + parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) }; + } // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the // CCA envelope; the server injects only the bare token, so pull project from the credential. if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) { @@ -1806,6 +1811,9 @@ export async function handleResponses( return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err)); } sentOAuthSnapshot = refreshed; + if (route.providerName === "kiro") { + parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; + } const refreshedProvider = resolveProviderTransport( route.providerName, { ...route.provider, apiKey: refreshed.accessToken }, diff --git a/src/types.ts b/src/types.ts index 0b70e6ec2..373177049 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { KiroOAuthMetadata } from "./oauth/types"; + export interface OcxParsedRequest { modelId: string; previousResponseId?: string; @@ -23,6 +25,8 @@ export interface OcxParsedRequest { * derived from the parent thread id. */ _cursorIsolateConversation?: boolean; + /** Account-scoped, non-secret Kiro request metadata selected with the OAuth access token. */ + _kiroAuthContext?: Pick; /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */ _providerContinuation?: OcxProviderContinuationState; /** diff --git a/tests/kiro-adapter.test.ts b/tests/kiro-adapter.test.ts index d00ae1bda..62e578279 100644 --- a/tests/kiro-adapter.test.ts +++ b/tests/kiro-adapter.test.ts @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createKiroAdapter } from "../src/adapters/kiro"; import { KIRO_TOOL_RESULT_CARRIER_MESSAGE } from "../src/adapters/kiro-constants"; import { applyProviderConfigHints, buildCatalogEntries } from "../src/codex/catalog"; +import { getValidAccessTokenSnapshot } from "../src/oauth"; +import { saveCredential } from "../src/oauth/store"; import { normalizeKiroModelId } from "../src/providers/kiro-models"; import { configuredReasoningEfforts, mapReasoningEffort } from "../src/reasoning-effort"; import { PROVIDER_REGISTRY } from "../src/providers/registry"; @@ -16,12 +19,14 @@ const origApiRegion = process.env.KIRO_API_REGION; const origArn = process.env.KIRO_PROFILE_ARN; const origCredsFile = process.env.KIRO_CREDS_FILE; const origCredentialsFile = process.env.KIRO_CREDENTIALS_FILE; +const origOcxHome = process.env.OPENCODEX_HOME; let tmp: string; beforeEach(() => { // isolate: empty HOME so no kiro-cli SQLite is read; deterministic region. tmp = mkdtempSync(join(tmpdir(), "kiro-adapter-")); process.env.HOME = tmp; + process.env.OPENCODEX_HOME = tmp; process.env.KIRO_REGION = "us-east-1"; delete process.env.KIRO_API_REGION; delete process.env.KIRO_PROFILE_ARN; @@ -35,6 +40,7 @@ afterEach(() => { if (origArn === undefined) delete process.env.KIRO_PROFILE_ARN; else process.env.KIRO_PROFILE_ARN = origArn; if (origCredsFile === undefined) delete process.env.KIRO_CREDS_FILE; else process.env.KIRO_CREDS_FILE = origCredsFile; if (origCredentialsFile === undefined) delete process.env.KIRO_CREDENTIALS_FILE; else process.env.KIRO_CREDENTIALS_FILE = origCredentialsFile; + if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = origOcxHome; rmSync(tmp, { recursive: true, force: true }); }); @@ -45,6 +51,18 @@ function parsedWith(messages: unknown[], tools?: unknown[], modelId = "claude-so return { modelId, stream: true, options: {}, context: { messages, tools } } as unknown as OcxParsedRequest; } +function seedKiroCliMetadata(profileArn: string, region: string): void { + const dir = join(tmp, "Library", "Application Support", "kiro-cli"); + mkdirSync(dir, { recursive: true }); + const db = new Database(join(dir, "data.sqlite3")); + db.run("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)"); + db.run("INSERT INTO auth_kv (key, value) VALUES (?, ?)", [ + "kirocli:social:token", + JSON.stringify({ access_token: "local-access", refresh_token: "local-refresh", profile_arn: profileArn, region }), + ]); + db.close(); +} + describe("kiro adapter — buildRequest", () => { test("rejects missing and blank Kiro tokens before building a request", async () => { for (const apiKey of [undefined, "", " "]) { @@ -74,6 +92,65 @@ describe("kiro adapter — buildRequest", () => { expect(url).toBe("https://runtime.ap-northeast-2.kiro.dev/"); }); + test("account-scoped OAuth metadata selects the matching Kiro region and profile", async () => { + const parsed = parsedWith([{ role: "user", content: "hi" }]); + parsed._kiroAuthContext = { + apiRegion: "eu-central-1", + profileArn: "arn:aws:codewhisperer:eu-central-1:123456789012:profile/account-b", + }; + + const request = await createKiroAdapter(provider).buildRequest(parsed); + const body = JSON.parse(request.body) as { profileArn?: string }; + + expect(request.url).toBe("https://runtime.eu-central-1.kiro.dev/"); + expect(request.headers["x-amzn-kiro-profile-arn"]).toBe(parsed._kiroAuthContext.profileArn); + expect(body.profileArn).toBe(parsed._kiroAuthContext.profileArn); + }); + + test("an account with no stored Kiro metadata never borrows different local CLI metadata", async () => { + seedKiroCliMetadata( + "arn:aws:codewhisperer:eu-west-1:123456789012:profile/local-other-account", + "eu-west-1", + ); + delete process.env.KIRO_REGION; + await saveCredential("kiro", { + access: "stored-access", + refresh: "stored-refresh", + expires: Date.now() + 3_600_000, + source: "oauth", + }); + + const snapshot = await getValidAccessTokenSnapshot("kiro"); + expect(snapshot.kiro).toEqual({}); + const parsed = parsedWith([{ role: "user", content: "hi" }]); + parsed._kiroAuthContext = { ...snapshot.kiro }; + const request = await createKiroAdapter(provider).buildRequest(parsed); + const body = JSON.parse(request.body) as { profileArn?: string }; + + expect(request.url).toBe("https://runtime.us-east-1.kiro.dev/"); + expect(request.headers["x-amzn-kiro-profile-arn"]).toBeUndefined(); + expect(body.profileArn).toBeUndefined(); + }); + + test("genuinely accountless requests still honor Kiro environment overrides", async () => { + const previousApiRegion = process.env.KIRO_API_REGION; + const previousProfileArn = process.env.KIRO_PROFILE_ARN; + process.env.KIRO_API_REGION = "ap-northeast-1"; + process.env.KIRO_PROFILE_ARN = "arn:aws:codewhisperer:ap-northeast-1:123456789012:profile/env"; + try { + const parsed = parsedWith([{ role: "user", content: "hi" }]); + expect(parsed._kiroAuthContext).toBeUndefined(); + const request = await createKiroAdapter(provider).buildRequest(parsed); + expect(request.url).toBe("https://runtime.ap-northeast-1.kiro.dev/"); + expect(request.headers["x-amzn-kiro-profile-arn"]).toBe(process.env.KIRO_PROFILE_ARN); + } finally { + if (previousApiRegion === undefined) delete process.env.KIRO_API_REGION; + else process.env.KIRO_API_REGION = previousApiRegion; + if (previousProfileArn === undefined) delete process.env.KIRO_PROFILE_ARN; + else process.env.KIRO_PROFILE_ARN = previousProfileArn; + } + }); + test("a genuinely custom Kiro base URL is honored", async () => { const custom = { ...provider, baseUrl: "https://kiro.internal.example/custom/generate" }; const { url } = await createKiroAdapter(custom).buildRequest(parsedWith([{ role: "user", content: "hi" }])); diff --git a/tests/kiro-oauth.test.ts b/tests/kiro-oauth.test.ts index 467da9da9..6b770795d 100644 --- a/tests/kiro-oauth.test.ts +++ b/tests/kiro-oauth.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { Database } from "bun:sqlite"; -import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { inspectKiroCliSqlite, loginKiro, readKiroCliSqlite, refreshKiroToken, resolveKiroApiRegion, resolveKiroProfileArn, resolveKiroRegion } from "../src/oauth/kiro"; +import { OAUTH_PROVIDERS, runLogin } from "../src/oauth"; +import { inspectKiroCliSqlite, loginKiro, readKiroCliSqlite, refreshKiroToken, resolveKiroApiRegion, resolveKiroProfileArn, resolveKiroRegion, settleKiroLoginTransaction } from "../src/oauth/kiro"; // Windows CI cold runners take 5-7s for the real SQLite create/inspect cycles here // (same flake class as 810fa115); the default 5s harness timeout is too tight. @@ -96,6 +97,31 @@ function seedKiroCliRawValue(value: string) { db.close(); } +function removeKiroCliDb(): void { + const path = kiroCliDbPath(); + for (const suffix of ["", "-wal", "-shm", "-journal"]) rmSync(`${path}${suffix}`, { force: true }); +} + +function kiroCliDbPath(): string { + return join(tmp, "Library", "Application Support", "kiro-cli", "data.sqlite3"); +} + +function kiroCliRecoveryPath(): string { + return `${kiroCliDbPath()}.opencodex-recovery`; +} + +function rewriteKiroCliRecoveryOwner(ownerPid: number): void { + const path = kiroCliRecoveryPath(); + const payload = readFileSync(path); + const firstLineEnd = payload.indexOf(0x0a); + const ownerLineEnd = payload.indexOf(0x0a, firstLineEnd + 1); + writeFileSync(path, Buffer.concat([ + payload.subarray(0, firstLineEnd + 1), + Buffer.from(`${ownerPid}\n`, "utf8"), + payload.subarray(ownerLineEnd + 1), + ]), { mode: 0o600 }); +} + function seedCustomTokenDb(path: string, rows: Array<[string, Record]>): void { mkdirSync(join(path, ".."), { recursive: true }); const db = new Database(path); @@ -157,12 +183,301 @@ describe("kiro oauth — import-first", () => { test("loginKiro returns imported SQLite credentials", async () => { seedKiroCliDb({ access_token: "aoa-xyz", refresh_token: "rt-2" }); - const cred = await loginKiro({}); + const cred = await loginKiro({}, { cliRunner: async () => ({ exitCode: 1, stdout: "" }) }); expect(cred.access).toBe("aoa-xyz"); expect(cred.refresh).toBe("rt-2"); expect(cred.source).toBe("local-cli"); }); + test("force login switches Kiro CLI identity and imports a distinct account", async () => { + const calls: string[][] = []; + const auth: Array<{ url: string; instructions?: string }> = []; + const runner = async (args: string[]) => { + calls.push(args); + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-second", + refresh_token: "rt-second", + expires_at: "2099-01-01T00:00:00Z", + region: "eu-west-1", + }); + } + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "Second@Example.com" }) }; + return { exitCode: 0, stdout: "" }; + }; + + const cred = await loginKiro({ onAuth: info => auth.push(info) }, { forceLogin: true, cliRunner: runner }); + + expect(calls).toEqual([ + ["logout"], + ["login"], + ["whoami", "--format", "json"], + ]); + expect(auth).toEqual([expect.objectContaining({ url: "", instructions: expect.stringContaining("fresh browser login") })]); + expect(cred).toMatchObject({ + access: "aoa-second", + refresh: "rt-second", + email: "second@example.com", + source: "local-cli", + kiro: { ssoRegion: "eu-west-1" }, + }); + }); + + test("force login imports only the newly authenticated CLI account, not a configured credential file", async () => { + const file = join(tmp, "old-account.json"); + writeFileSync(file, JSON.stringify({ + accessToken: "aoa-old-json", + refreshToken: "rt-old-json", + profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/old", + })); + process.env.KIRO_CREDS_FILE = file; + const runner = async (args: string[]) => { + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-new-cli", + refresh_token: "rt-new-cli", + profile_arn: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/new", + }); + } + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "new@example.com" }) }; + return { exitCode: 0, stdout: "" }; + }; + + const cred = await loginKiro({}, { forceLogin: true, cliRunner: runner }); + + expect(cred.access).toBe("aoa-new-cli"); + expect(cred.refresh).toBe("rt-new-cli"); + expect(cred.accountId).toBe("arn:aws:codewhisperer:eu-west-1:123456789012:profile/new"); + expect(cred.email).toBe("new@example.com"); + expect(cred.source).toBe("local-cli"); + }); + + test("force login durably records the prior session before logout and deletes it after successful settlement", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + let recoveryExistedBeforeLogout = false; + let recoveryModeBeforeLogout: number | undefined; + const runner = async (args: string[]) => { + if (args[0] === "logout") { + recoveryExistedBeforeLogout = existsSync(kiroCliRecoveryPath()); + recoveryModeBeforeLogout = statSync(kiroCliRecoveryPath()).mode & 0o777; + removeKiroCliDb(); + } + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-new", + refresh_token: "rt-new", + profile_arn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/new", + }); + } + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "new@example.com" }) }; + return { exitCode: 0, stdout: "" }; + }; + + const pending = await loginKiro({}, { forceLogin: true, cliRunner: runner }); + + expect(recoveryExistedBeforeLogout).toBe(true); + if (process.platform !== "win32") expect(recoveryModeBeforeLogout).toBe(0o600); + expect(existsSync(kiroCliRecoveryPath())).toBe(true); + settleKiroLoginTransaction(pending, true); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + expect(readKiroCliSqlite()?.access).toBe("aoa-new"); + }); + + test("force login refuses to log out when a present CLI session cannot be snapshotted", async () => { + const dir = join(tmp, "Library", "Application Support", "kiro-cli"); + mkdirSync(dir, { recursive: true }); + const db = new Database(join(dir, "data.sqlite3")); + db.run("CREATE TABLE other_table (key TEXT PRIMARY KEY, value TEXT)"); + db.close(); + const calls: string[][] = []; + + await expect(loginKiro({}, { + forceLogin: true, + cliRunner: async (args: string[]) => { + calls.push(args); + return { exitCode: 0, stdout: "" }; + }, + })).rejects.toThrow(/could not be backed up/i); + + expect(calls).toEqual([]); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + expect(existsSync(join(dir, "data.sqlite3"))).toBe(true); + }); + + test("force login still proceeds when no CLI session exists at all", async () => { + const calls: string[][] = []; + const runner = async (args: string[]) => { + calls.push(args); + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-first", + refresh_token: "rt-first", + profile_arn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/first", + }); + } + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "first@example.com" }) }; + return { exitCode: 0, stdout: "" }; + }; + + const cred = await loginKiro({}, { forceLogin: true, cliRunner: runner }); + + expect(calls[0]).toEqual(["logout"]); + expect(cred.access).toBe("aoa-first"); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); + + test("next ordinary login restores stale crash recovery before importing SQLite", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + const firstRunner = async (args: string[]) => { + if (args[0] === "logout") removeKiroCliDb(); + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-abandoned", + refresh_token: "rt-abandoned", + profile_arn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/abandoned", + }); + } + if (args[0] === "whoami") { + for (const suffix of ["-wal", "-shm", "-journal"]) writeFileSync(`${kiroCliDbPath()}${suffix}`, `abandoned${suffix}`); + return { exitCode: 0, stdout: JSON.stringify({ email: "abandoned@example.com" }) }; + } + return { exitCode: 0, stdout: "" }; + }; + + const abandoned = await loginKiro({}, { forceLogin: true, cliRunner: firstRunner }); + expect(abandoned.access).toBe("aoa-abandoned"); + expect(existsSync(kiroCliRecoveryPath())).toBe(true); + const liveTransactionFiles = ["", "-wal", "-shm", "-journal", ".opencodex-recovery"] + .map(suffix => readFileSync(`${kiroCliDbPath()}${suffix}`)); + + let liveOwnerRunnerCalled = false; + const liveOwnerFailure = await loginKiro({}, { + cliRunner: async () => { + liveOwnerRunnerCalled = true; + return { exitCode: 0, stdout: "" }; + }, + }).catch((error: unknown) => error); + expect(liveOwnerFailure).toBeInstanceOf(Error); + expect((liveOwnerFailure as Error).message).toContain("still in progress"); + expect((liveOwnerFailure as Error).message).toContain(`pid ${process.pid}`); + expect((liveOwnerFailure as Error).message).toContain(kiroCliRecoveryPath()); + expect(liveOwnerRunnerCalled).toBe(false); + expect(existsSync(kiroCliRecoveryPath())).toBe(true); + expect(["", "-wal", "-shm", "-journal", ".opencodex-recovery"] + .map((suffix, index) => readFileSync(`${kiroCliDbPath()}${suffix}`).equals(liveTransactionFiles[index]!))) + .toEqual([true, true, true, true, true]); + + const exitedOwner = Bun.spawn([process.execPath, "-e", ""]); + await exitedOwner.exited; + rewriteKiroCliRecoveryOwner(exitedOwner.pid); + + // This call stands in for a fresh process: it deliberately never settles or otherwise uses + // the in-memory transaction above. Ordinary import must recover the stale transaction first. + const calls: string[][] = []; + const secondRunner = async (args: string[]) => { + calls.push(args); + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "prior@example.com" }) }; + throw new Error(`unexpected Kiro CLI command: ${args[0]}`); + }; + + const restored = await loginKiro({}, { cliRunner: secondRunner }); + + expect(restored).toMatchObject({ access: "aoa-prior", refresh: "rt-prior", email: "prior@example.com" }); + expect(calls).toEqual([["whoami", "--format", "json"]]); + expect(["-wal", "-shm", "-journal"].map(suffix => existsSync(`${kiroCliDbPath()}${suffix}`))).toEqual([false, false, false]); + expect(readKiroCliSqlite()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); + + test("invalid recovery data names the file the operator must remove", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + writeFileSync(kiroCliRecoveryPath(), "not a recovery database", { mode: 0o600 }); + let runnerCalled = false; + + const failure = await loginKiro({}, { + cliRunner: async () => { + runnerCalled = true; + return { exitCode: 0, stdout: "" }; + }, + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("recovery data is invalid"); + expect((failure as Error).message).toContain(kiroCliRecoveryPath()); + expect((failure as Error).message).toContain("Remove this file to continue"); + expect(runnerCalled).toBe(false); + expect(readKiroCliSqlite()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(true); + }); + + test("force login cancellation during browser login restores the prior Kiro CLI session", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + const controller = new AbortController(); + const calls: string[][] = []; + const runner = async (args: string[]) => { + calls.push(args); + if (args[0] === "logout") { + removeKiroCliDb(); + } + if (args[0] === "login") { + controller.abort(); + } + return { exitCode: 0, stdout: "" }; + }; + + await expect(loginKiro({ signal: controller.signal }, { forceLogin: true, cliRunner: runner })).rejects.toThrow(/cancelled/i); + expect(calls).toEqual([["logout"], ["login"]]); + expect(readKiroCliSqlite()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); + + test("force login callback failure restores the prior Kiro CLI session", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + const calls: string[][] = []; + const runner = async (args: string[]) => { + calls.push(args); + if (args[0] === "logout") removeKiroCliDb(); + return { exitCode: args[0] === "login" ? 1 : 0, stdout: "" }; + }; + + await expect(loginKiro({}, { forceLogin: true, cliRunner: runner })).rejects.toThrow(/did not complete successfully/i); + + expect(calls).toEqual([["logout"], ["login"]]); + expect(readKiroCliSqlite()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); + + test("credential persistence failure restores the prior Kiro CLI session", async () => { + seedKiroCliDb({ access_token: "aoa-prior", refresh_token: "rt-prior" }); + const runner = async (args: string[]) => { + if (args[0] === "logout") removeKiroCliDb(); + if (args[0] === "login") { + seedKiroCliDb({ + access_token: "aoa-new", + refresh_token: "rt-new", + profile_arn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/new", + }); + } + if (args[0] === "whoami") return { exitCode: 0, stdout: JSON.stringify({ email: "new@example.com" }) }; + return { exitCode: 0, stdout: "" }; + }; + const pending = await loginKiro({}, { forceLogin: true, cliRunner: runner }); + expect(readKiroCliSqlite()?.access).toBe("aoa-new"); + + const originalLogin = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => pending; + try { + await expect(runLogin("kiro", {}, { forceLogin: true }, { + saveCredential: async () => { throw new Error("simulated credential persistence failure"); }, + })).rejects.toThrow("simulated credential persistence failure"); + } finally { + OAUTH_PROVIDERS.kiro.login = originalLogin; + } + + expect(readKiroCliSqlite()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); + test("loginKiro imports JSON credentials and resolver metadata", async () => { const file = join(tmp, "kiro-creds.json"); writeFileSync(file, JSON.stringify({ @@ -180,6 +495,12 @@ describe("kiro oauth — import-first", () => { expect(cred.access).toBe("aoa-json"); expect(cred.refresh).toBe("rt-json"); expect(cred.source).toBe("credential-file"); + expect(cred.accountId).toBe("arn:aws:codewhisperer:ap-northeast-1:123456789012:profile/demo"); + expect(cred.kiro).toMatchObject({ + profileArn: "arn:aws:codewhisperer:ap-northeast-1:123456789012:profile/demo", + ssoRegion: "us-west-2", + apiRegion: "eu-central-1", + }); expect(resolveKiroProfileArn()).toBe("arn:aws:codewhisperer:ap-northeast-1:123456789012:profile/demo"); expect(resolveKiroRegion()).toBe("us-west-2"); expect(resolveKiroApiRegion()).toBe("eu-central-1"); @@ -433,6 +754,241 @@ describe("kiro oauth — import-first", () => { }); }); + test("refreshKiroToken uses stored account metadata instead of another local Kiro session", async () => { + const file = join(tmp, "other-local-account.json"); + writeFileSync(file, JSON.stringify({ + accessToken: "aoa-other", + refreshToken: "rt-other", + region: "ap-southeast-1", + clientId: "other-client", + clientSecret: "other-secret", + })); + process.env.KIRO_CREDS_FILE = file; + let captured: { url: string; body: Record } | undefined; + globalThis.fetch = (async (input, init) => { + captured = { url: String(input), body: JSON.parse(String(init?.body)) as Record }; + return new Response(JSON.stringify({ accessToken: "aoa-stored-new", expiresIn: 60 }), { status: 200 }); + }) as typeof fetch; + + await refreshKiroToken("rt-stored", undefined, { + access: "aoa-stored", + refresh: "rt-stored", + expires: 0, + accountId: "profile-stored", + kiro: { + profileArn: "profile-stored", + ssoRegion: "eu-west-1", + clientId: "stored-client", + clientSecret: "stored-secret", + }, + }); + + expect(captured?.url).toBe("https://oidc.eu-west-1.amazonaws.com/token"); + expect(captured?.body).toMatchObject({ + clientId: "stored-client", + clientSecret: "stored-secret", + refreshToken: "rt-stored", + }); + }); + + test("legacy stored credential without kiro metadata does not borrow the local CLI region", async () => { + // A legacy OCX account predates account-scoped metadata. The local CLI is signed into a + // different account in another region; refresh must not route through that region. + seedKiroCliDb({ + access_token: "aoa-other-cli", + refresh_token: "rt-other-cli", + region: "ap-southeast-1", + profile_arn: "arn:aws:codewhisperer:ap-southeast-1:123456789012:profile/other", + }); + let captured: string | undefined; + globalThis.fetch = (async (input) => { + captured = String(input); + return new Response(JSON.stringify({ accessToken: "aoa-legacy-new", expiresIn: 60 }), { status: 200 }); + }) as typeof fetch; + + const cred = await refreshKiroToken("rt-legacy", undefined, { + access: "aoa-legacy", + refresh: "rt-legacy", + expires: 0, + }); + + expect(captured).toBe("https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken"); + expect(captured).not.toContain("ap-southeast-1"); + expect(cred.access).toBe("aoa-legacy-new"); + }); + + test("legacy stored credential ignores KIRO_REGION set for a different local account", async () => { + process.env.KIRO_REGION = "eu-central-1"; + let captured: string | undefined; + globalThis.fetch = (async (input) => { + captured = String(input); + return new Response(JSON.stringify({ accessToken: "aoa-scoped-new", expiresIn: 60 }), { status: 200 }); + }) as typeof fetch; + + await refreshKiroToken("rt-legacy", undefined, { + access: "aoa-legacy", + refresh: "rt-legacy", + expires: 0, + }); + + expect(captured).toBe("https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken"); + + // A truly accountless refresh keeps the documented env fallback. + await refreshKiroToken("rt-accountless"); + expect(captured).toBe("https://prod.eu-central-1.auth.desktop.kiro.dev/refreshToken"); + }); + + test("stored refresh metadata does not inspect an unrelated ambiguous local CLI store", async () => { + const path = join(tmp, "ambiguous-refresh", "credentials.sqlite3"); + seedCustomTokenDb(path, [ + ["custom:a:token", { access_token: "aoa-a", refresh_token: "rt-a" }], + ["custom:b:token", { access_token: "aoa-b", refresh_token: "rt-b" }], + ]); + process.env.KIROCLI_DB_PATH = path; + globalThis.fetch = (async () => + new Response(JSON.stringify({ accessToken: "aoa-stored-new", expiresIn: 60 }), { status: 200 })) as typeof fetch; + + await expect(refreshKiroToken("rt-stored", undefined, { + access: "aoa-stored", + refresh: "rt-stored", + expires: 0, + kiro: { + ssoRegion: "eu-west-1", + clientId: "stored-client", + clientSecret: "stored-secret", + }, + })).resolves.toMatchObject({ access: "aoa-stored-new", refresh: "rt-stored" }); + }); + + test("refreshKiroToken retries a rotated local refresh only for the same profile", async () => { + const profileArn = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/same"; + seedKiroCliDb({ + access_token: "aoa-local-new", + refresh_token: "rt-local-new", + profile_arn: profileArn, + region: "eu-west-1", + }); + const refreshRequests: Array<{ url: string; body: Record }> = []; + globalThis.fetch = (async (input, init) => { + refreshRequests.push({ url: String(input), body: JSON.parse(String(init?.body)) as Record }); + if (refreshRequests.length === 1) { + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + } + return new Response(JSON.stringify({ accessToken: "aoa-recovered", expiresIn: 60 }), { status: 200 }); + }) as typeof fetch; + + const fresh = await refreshKiroToken("rt-stored-old", undefined, { + access: "aoa-stored-old", + refresh: "rt-stored-old", + expires: 0, + accountId: profileArn, + source: "local-cli", + kiro: { + profileArn, + ssoRegion: "eu-west-1", + clientId: "stale-client", + clientSecret: "stale-secret", + }, + }); + + expect(refreshRequests).toEqual([ + { + url: "https://oidc.eu-west-1.amazonaws.com/token", + body: { + grantType: "refresh_token", + clientId: "stale-client", + clientSecret: "stale-secret", + refreshToken: "rt-stored-old", + }, + }, + { + url: "https://prod.eu-west-1.auth.desktop.kiro.dev/refreshToken", + body: { refreshToken: "rt-local-new" }, + }, + ]); + expect(fresh).toMatchObject({ access: "aoa-recovered", refresh: "rt-local-new", kiro: { profileArn } }); + expect(fresh.kiro?.clientId).toBeUndefined(); + expect(fresh.kiro?.clientSecret).toBeUndefined(); + }); + + test("refreshKiroToken never retries a rotated local refresh from another profile", async () => { + seedKiroCliDb({ + access_token: "aoa-other", + refresh_token: "rt-other-new", + profile_arn: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/other", + region: "eu-west-1", + }); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + }) as typeof fetch; + + await expect(refreshKiroToken("rt-stored-old", undefined, { + access: "aoa-stored-old", + refresh: "rt-stored-old", + expires: 0, + accountId: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/stored", + source: "local-cli", + kiro: { + profileArn: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/stored", + ssoRegion: "eu-west-1", + }, + })).rejects.toBeInstanceOf(Error); + expect(calls).toBe(1); + }); + + test("refreshKiroToken rejects conflicting stored profile identities before local recovery", async () => { + const localProfile = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/local"; + seedKiroCliDb({ + access_token: "aoa-local", + refresh_token: "rt-local-new", + profile_arn: localProfile, + region: "eu-west-1", + }); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + }) as typeof fetch; + + await expect(refreshKiroToken("rt-stored-old", undefined, { + access: "aoa-stored-old", + refresh: "rt-stored-old", + expires: 0, + accountId: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/different", + source: "local-cli", + kiro: { profileArn: localProfile, ssoRegion: "eu-west-1" }, + })).rejects.toBeInstanceOf(Error); + expect(calls).toBe(1); + }); + + test("refreshKiroToken composes caller cancellation with its request timeout", async () => { + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout"); + let requestSignal: AbortSignal | undefined; + globalThis.fetch = (async (_input, init) => { + requestSignal = init?.signal ?? undefined; + return new Response(JSON.stringify({ accessToken: "aoa-new", expiresIn: 60 }), { status: 200 }); + }) as typeof fetch; + + try { + await refreshKiroToken("rt-old", controller.signal, { + access: "aoa-old", + refresh: "rt-old", + expires: 0, + kiro: { ssoRegion: "us-east-1" }, + }); + expect(timeout).toHaveBeenCalledWith(30_000); + expect(requestSignal).not.toBe(controller.signal); + expect(requestSignal?.aborted).toBe(false); + controller.abort(); + expect(requestSignal?.aborted).toBe(true); + } finally { + timeout.mockRestore(); + } + }); + test("refreshKiroToken maps the desktop refresh response to credentials", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ accessToken: "aoa-new", refreshToken: "rt-new", expiresIn: 1000 }), { @@ -458,6 +1014,36 @@ describe("kiro oauth — import-first", () => { }); describe("kiro oauth — adapter-time resolvers (profileArn / region)", () => { + test("account-scoped resolution never borrows another local Kiro identity", () => { + const file = join(tmp, "local-other-account.json"); + writeFileSync(file, JSON.stringify({ + accessToken: "aoa-other", + refreshToken: "rt-other", + profileArn: "arn:aws:codewhisperer:eu-central-1:123456789012:profile/other", + region: "eu-central-1", + })); + process.env.KIRO_CREDS_FILE = file; + + expect(resolveKiroProfileArn({})).toBeUndefined(); + expect(resolveKiroRegion({})).toBe("us-east-1"); + expect(resolveKiroApiRegion({})).toBe("us-east-1"); + }); + + test("account-scoped resolution is not overridden by another account's environment metadata", () => { + process.env.KIRO_PROFILE_ARN = "arn:environment-account"; + process.env.KIRO_REGION = "ap-southeast-1"; + process.env.KIRO_API_REGION = "ap-northeast-1"; + const account = { + profileArn: "arn:aws:codewhisperer:eu-west-1:123456789012:profile/account-b", + ssoRegion: "eu-west-1", + apiRegion: "eu-central-1", + }; + + expect(resolveKiroProfileArn(account)).toBe(account.profileArn); + expect(resolveKiroRegion(account)).toBe("eu-west-1"); + expect(resolveKiroApiRegion(account)).toBe("eu-central-1"); + }); + test("resolveKiroProfileArn: KIRO_PROFILE_ARN env wins over SQLite", () => { process.env.KIRO_PROFILE_ARN = "arn:env"; seedKiroCliDb({ access_token: "aoa", profile_arn: "arn:sqlite" }); diff --git a/tests/kiro-review-regressions.test.ts b/tests/kiro-review-regressions.test.ts new file mode 100644 index 000000000..a3d83bd76 --- /dev/null +++ b/tests/kiro-review-regressions.test.ts @@ -0,0 +1,369 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getValidAccessTokenSnapshot, OAUTH_PROVIDERS, runLogin } from "../src/oauth"; +import { loginKiro, refreshKiroToken } from "../src/oauth/kiro"; +import { + inspectKiroCliSessionSnapshot, + persistKiroCliSessionRecovery, + readKiroCliSqliteCredential, + restoreStaleKiroCliSessionRecovery, +} from "../src/oauth/kiro-credentials"; +import { getAccountCredential, getAccountSet, saveCredential, setActiveAccount } from "../src/oauth/store"; +import type { OAuthController, OAuthCredentials } from "../src/oauth/types"; +import type { OcxConfig } from "../src/types"; + +const ENV_KEYS = [ + "HOME", + "OPENCODEX_HOME", + "KIRO_ACCESS_TOKEN", + "KIRO_REFRESH_TOKEN", + "KIRO_PROFILE_ARN", + "KIRO_REGION", + "KIRO_API_REGION", + "KIRO_CREDS_FILE", + "KIRO_CREDENTIALS_FILE", + "KIRO_CLI_DB_FILE", + "KIROCLI_DB_PATH", + "KIROCLI_TOKEN_KEY", +] as const; +const originalEnv = new Map(ENV_KEYS.map(key => [key, process.env[key]])); +let tmp: string; + +function config(): OcxConfig { + return { + port: 10100, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: {}, + }; +} + +function kiroCliDbPath(): string { + return join(tmp, "Library", "Application Support", "kiro-cli", "data.sqlite3"); +} + +function kiroCliRecoveryPath(): string { + return `${kiroCliDbPath()}.opencodex-recovery`; +} + +function amazonQDbPath(): string { + return join(tmp, ".local", "share", "amazon-q", "data.sqlite3"); +} + +function removeKiroCliDb(): void { + for (const suffix of ["", "-wal", "-shm", "-journal"]) { + rmSync(`${kiroCliDbPath()}${suffix}`, { force: true }); + } +} + +function seedSqliteTokenDb( + path: string, + access: string, + refresh: string, + opts: { profileArn?: string; emptyAuthKv?: boolean } = {}, +): void { + mkdirSync(join(path, ".."), { recursive: true }); + const db = new Database(path); + db.run("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)"); + if (!opts.emptyAuthKv) { + db.run("INSERT INTO auth_kv (key, value) VALUES (?, ?)", [ + "kirocli:social:token", + JSON.stringify({ access_token: access, refresh_token: refresh }), + ]); + } + if (opts.profileArn) { + db.run("CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT)"); + db.run("INSERT INTO state (key, value) VALUES (?, ?)", [ + "api.codewhisperer.profile", + JSON.stringify({ arn: opts.profileArn }), + ]); + } + db.close(); +} + +function seedKiroCliDb(access: string, refresh: string, opts: { profileArn?: string; emptyAuthKv?: boolean } = {}): void { + seedSqliteTokenDb(kiroCliDbPath(), access, refresh, opts); +} + +function rewriteRecoveryProcessInstance(processInstance: string): void { + const path = kiroCliRecoveryPath(); + const payload = readFileSync(path); + const headerEnd = payload.indexOf(0x0a); + const ownerEnd = payload.indexOf(0x0a, headerEnd + 1); + const instanceEnd = payload.indexOf(0x0a, ownerEnd + 1); + if (headerEnd < 0 || ownerEnd < 0 || instanceEnd < 0) throw new Error("unexpected Kiro recovery format"); + expect(Number(payload.subarray(headerEnd + 1, ownerEnd).toString("utf8"))).toBe(process.pid); + writeFileSync(path, Buffer.concat([ + payload.subarray(0, ownerEnd + 1), + Buffer.from(`${processInstance}\n`, "utf8"), + payload.subarray(instanceEnd + 1), + ]), { mode: 0o600 }); +} + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "kiro-review-regressions-")); + for (const key of ENV_KEYS) delete process.env[key]; + process.env.HOME = tmp; + process.env.OPENCODEX_HOME = join(tmp, "opencodex"); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + const value = originalEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("Kiro review regressions", () => { + test("environment login keeps explicit request-routing metadata without borrowing local CLI state", async () => { + process.env.KIRO_ACCESS_TOKEN = "aoa-env"; + process.env.KIRO_REFRESH_TOKEN = "rt-env"; + process.env.KIRO_PROFILE_ARN = "arn:aws:codewhisperer:ap-southeast-2:123456789012:profile/env"; + process.env.KIRO_API_REGION = "eu-west-1"; + process.env.KIRO_REGION = "eu-central-1"; + + const credential = await runLogin("kiro", {} as OAuthController, undefined, { + loadConfig: config, + saveConfig: () => {}, + }); + const snapshot = await getValidAccessTokenSnapshot("kiro"); + + expect(credential).toMatchObject({ + access: "aoa-env", + refresh: "rt-env", + source: "environment", + kiro: { + profileArn: "arn:aws:codewhisperer:ap-southeast-2:123456789012:profile/env", + apiRegion: "eu-west-1", + ssoRegion: "eu-central-1", + }, + }); + expect(snapshot).toMatchObject({ + accessToken: "aoa-env", + kiro: { + profileArn: "arn:aws:codewhisperer:ap-southeast-2:123456789012:profile/env", + apiRegion: "eu-west-1", + ssoRegion: "eu-central-1", + }, + }); + }); + + test("environment credentials refresh with KIRO_REGION instead of defaulting to us-east-1", async () => { + process.env.KIRO_REGION = "eu-central-1"; + const seen: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + seen.push(url); + return new Response(JSON.stringify({ + accessToken: "aoa-refreshed", + refreshToken: "rt-refreshed", + expiresIn: 3600, + }), { status: 200 }); + }) as typeof fetch; + try { + const fresh = await refreshKiroToken("rt-env", undefined, { + access: "aoa-expired", + refresh: "rt-env", + expires: 0, + source: "environment", + }); + expect(fresh.access).toBe("aoa-refreshed"); + expect(seen.some(url => url.includes("prod.eu-central-1.auth.desktop.kiro.dev"))).toBe(true); + expect(seen.some(url => url.includes("prod.us-east-1.auth.desktop.kiro.dev"))).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("Kiro CLI recovery rolls back auth and config persistence failures", async () => { + await saveCredential("kiro", { + access: "old-access", + refresh: "old-refresh", + expires: Date.now() + 60_000, + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/old", + email: "old@example.test", + source: "local-cli", + }); + const previousActive = getAccountSet("kiro")!.activeAccountId; + const rawCredential: OAuthCredentials = { + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/new", + email: "new@example.test", + source: "local-cli", + }; + const events: string[] = []; + const originalLogin = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => rawCredential; + try { + await expect(runLogin("kiro", {} as OAuthController, { forceLogin: true }, { + loadConfig: () => { + events.push("load-config"); + return config(); + }, + saveConfig: () => { + events.push("save-config"); + throw new Error("config write failed"); + }, + settleKiroLoginTransaction: (credential, persisted) => { + expect(credential).toBe(rawCredential); + events.push(`settle:${persisted}`); + }, + })).rejects.toThrow("config write failed"); + } finally { + OAUTH_PROVIDERS.kiro.login = originalLogin; + } + + expect(events).toEqual(["load-config", "save-config", "settle:false"]); + expect(getAccountSet("kiro")?.activeAccountId).toBe(previousActive); + expect(getAccountSet("kiro")?.accounts).toHaveLength(1); + expect(getAccountCredential("kiro", previousActive)).toMatchObject({ + access: "old-access", + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/old", + }); + }); + + test("forced login refuses custom import DB selectors that diverge from the CLI store", async () => { + seedKiroCliDb("aoa-primary", "rt-primary", { + profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/primary", + }); + const custom = join(tmp, "custom-import.sqlite3"); + seedSqliteTokenDb(custom, "aoa-custom", "rt-custom", { + profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/custom", + }); + process.env.KIROCLI_DB_PATH = custom; + const calls: string[][] = []; + await expect(loginKiro({} as OAuthController, { + forceLogin: true, + cliRunner: async args => { + calls.push(args); + return { exitCode: 0, stdout: "" }; + }, + })).rejects.toThrow(/will not sign it out|KIROCLI_DB_PATH|KIRO_CLI_DB_FILE/); + expect(calls).toEqual([]); + expect(readFileSync(kiroCliDbPath()).length).toBeGreaterThan(0); + expect(inspectKiroCliSessionSnapshot()).toMatchObject({ blocked: true, snapshot: null }); + }); + + test("forced login refuses when the primary CLI store exists but only a later fallback is readable", async () => { + mkdirSync(join(kiroCliDbPath(), ".."), { recursive: true }); + writeFileSync(kiroCliDbPath(), "not-a-sqlite-database", { mode: 0o600 }); + seedSqliteTokenDb(amazonQDbPath(), "aoa-fallback", "rt-fallback", { + profileArn: "arn:aws:codewhisperer:us-east-1:123456789012:profile/fallback", + }); + const calls: string[][] = []; + await expect(loginKiro({} as OAuthController, { + forceLogin: true, + cliRunner: async args => { + calls.push(args); + return { exitCode: 0, stdout: "" }; + }, + })).rejects.toThrow(/will not sign it out/); + expect(calls).toEqual([]); + expect(existsSync(kiroCliDbPath())).toBe(true); + expect(readFileSync(kiroCliDbPath(), "utf8")).toBe("not-a-sqlite-database"); + }); + + test("Add account binds a legacy identity-less Kiro row before switching and keeps it selectable", async () => { + const legacyArn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/legacy"; + const nextArn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/next"; + seedKiroCliDb("aoa-legacy", "rt-legacy", { profileArn: legacyArn }); + await saveCredential("kiro", { + access: "aoa-legacy", + refresh: "rt-legacy", + expires: Date.now() + 60_000, + source: "local-cli", + }); + const legacySlot = getAccountSet("kiro")!.activeAccountId; + + const credential = await loginKiro({} as OAuthController, { + forceLogin: true, + cliRunner: async args => { + if (args[0] === "whoami") { + return { exitCode: 0, stdout: JSON.stringify({ email: "legacy@example.test" }) }; + } + if (args[0] === "logout") { + removeKiroCliDb(); + return { exitCode: 0, stdout: "" }; + } + if (args[0] === "login") { + seedKiroCliDb("aoa-next", "rt-next", { profileArn: nextArn }); + return { exitCode: 0, stdout: "" }; + } + return { exitCode: 1, stdout: "" }; + }, + }); + await saveCredential("kiro", credential, { preserveIdentityless: true }); + + const set = getAccountSet("kiro")!; + expect(set.accounts.length).toBeGreaterThanOrEqual(2); + const legacy = getAccountCredential("kiro", legacySlot); + expect(legacy).toMatchObject({ + accountId: legacyArn, + refresh: "rt-legacy", + kiro: { profileArn: legacyArn }, + }); + expect(await setActiveAccount("kiro", legacySlot)).toBe(true); + expect(getAccountSet("kiro")?.activeAccountId).toBe(legacySlot); + }); + + test("Kiro reauth accepts the same email when the refreshed credential gains a profile ARN", async () => { + await saveCredential("kiro", { + access: "old-access", + refresh: "old-refresh", + expires: Date.now() + 60_000, + email: "same@example.test", + source: "local-cli", + }); + const slotId = getAccountSet("kiro")!.activeAccountId; + const profileArn = "arn:aws:codewhisperer:us-east-1:123456789012:profile/same"; + const originalLogin = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => ({ + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + email: "SAME@example.test", + accountId: profileArn, + source: "local-cli", + kiro: { profileArn }, + }); + try { + await runLogin("kiro", {} as OAuthController, { reauthAccountId: slotId }, { + loadConfig: config, + saveConfig: () => {}, + }); + } finally { + OAUTH_PROVIDERS.kiro.login = originalLogin; + } + + expect(getAccountSet("kiro")?.accounts).toHaveLength(1); + expect(getAccountCredential("kiro", slotId)).toMatchObject({ + access: "new-access", + email: "SAME@example.test", + accountId: profileArn, + kiro: { profileArn }, + }); + }); + + test("same-PID process restart restores a stale Kiro CLI recovery transaction", () => { + seedKiroCliDb("aoa-prior", "rt-prior"); + const snapshot = inspectKiroCliSessionSnapshot().snapshot; + expect(snapshot).not.toBeNull(); + persistKiroCliSessionRecovery(snapshot!); + + removeKiroCliDb(); + seedKiroCliDb("aoa-abandoned", "rt-abandoned"); + rewriteRecoveryProcessInstance("restarted-process-instance"); + + expect(restoreStaleKiroCliSessionRecovery()).toBe(true); + expect(readKiroCliSqliteCredential()).toMatchObject({ access: "aoa-prior", refresh: "rt-prior" }); + expect(existsSync(kiroCliRecoveryPath())).toBe(false); + }); +}); diff --git a/tests/oauth-reauth-bind.test.ts b/tests/oauth-reauth-bind.test.ts index 58ea165bd..b88f43dbe 100644 --- a/tests/oauth-reauth-bind.test.ts +++ b/tests/oauth-reauth-bind.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { OAUTH_PROVIDERS, runLogin } from "../src/oauth"; import { getAccountCredential, getAccountSet, saveCredential } from "../src/oauth/store"; -import type { OAuthController } from "../src/oauth/types"; +import type { OAuthController, OAuthCredentials } from "../src/oauth/types"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; @@ -109,6 +109,91 @@ describe("OAuth account-scoped reauth", () => { expect(getAccountSet("xai")?.accounts).toHaveLength(1); }); + test("forced Kiro add-account preserves a legacy identity-less account", async () => { + await saveCredential("kiro", { + access: "legacy-access", + refresh: "legacy-refresh", + expires: Date.now() + 60_000, + source: "local-cli", + }); + const original = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => ({ + access: "identified-access", + refresh: "identified-refresh", + expires: Date.now() + 60_000, + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/new", + source: "local-cli", + }); + try { + await runLogin("kiro", {} as OAuthController, { forceLogin: true }); + } finally { + OAUTH_PROVIDERS.kiro.login = original; + } + + const set = getAccountSet("kiro")!; + expect(set.accounts).toHaveLength(2); + expect(set.accounts.some(account => account.credential.access === "legacy-access")).toBe(true); + expect(getAccountCredential("kiro", set.activeAccountId)?.access).toBe("identified-access"); + }); + + test("non-force Kiro login upgrades a legacy identity-less slot in place", async () => { + await saveCredential("kiro", { + access: "legacy-access", + refresh: "legacy-refresh", + expires: Date.now() + 60_000, + source: "local-cli", + }); + const legacySlotId = getAccountSet("kiro")!.activeAccountId; + const original = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => ({ + access: "identified-access", + refresh: "identified-refresh", + expires: Date.now() + 60_000, + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/existing", + source: "local-cli", + }); + try { + await runLogin("kiro", {} as OAuthController); + } finally { + OAUTH_PROVIDERS.kiro.login = original; + } + + const set = getAccountSet("kiro")!; + expect(set.accounts).toHaveLength(1); + expect(set.activeAccountId).toBe(legacySlotId); + expect(getAccountCredential("kiro", legacySlotId)?.access).toBe("identified-access"); + }); + + test("runLogin settles a source-less Kiro credential with its exact raw object identity", async () => { + const rawCredential: OAuthCredentials = { + access: "source-less-access", + refresh: "source-less-refresh", + expires: Date.now() + 60_000, + accountId: "arn:aws:codewhisperer:us-east-1:123456789012:profile/source-less", + }; + let savedCredential: OAuthCredentials | undefined; + let settledCredential: OAuthCredentials | undefined; + let settledPersisted: boolean | undefined; + const original = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async () => rawCredential; + try { + await runLogin("kiro", {} as OAuthController, undefined, { + saveCredential: async (_provider, credential) => { savedCredential = credential; }, + settleKiroLoginTransaction: (credential, persisted) => { + settledCredential = credential; + settledPersisted = persisted; + }, + }); + } finally { + OAUTH_PROVIDERS.kiro.login = original; + } + + expect(savedCredential).not.toBe(rawCredential); + expect(savedCredential?.source).toBe("oauth"); + expect(settledCredential).toBe(rawCredential); + expect(settledPersisted).toBe(true); + }); + test("management login passes reauthAccountId into startLoginFlow", async () => { const source = await Bun.file("src/server/management/oauth-account-routes.ts").text(); expect(source).toContain("reauthAccountId: accountId"); diff --git a/tests/oauth-refresh.test.ts b/tests/oauth-refresh.test.ts index 8434016d2..5e1995112 100644 --- a/tests/oauth-refresh.test.ts +++ b/tests/oauth-refresh.test.ts @@ -10,6 +10,9 @@ import { credentialGeneration, getAccountCredential, getAccountSet, getAuthRefre const origHome = process.env.HOME; const origOcxHome = process.env.OPENCODEX_HOME; const origRegion = process.env.KIRO_REGION; +const origCliDbFile = process.env.KIRO_CLI_DB_FILE; +const origCliDbPath = process.env.KIROCLI_DB_PATH; +const origCliTokenKey = process.env.KIROCLI_TOKEN_KEY; const origClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; const origFetch = globalThis.fetch; const origWarn = console.warn; @@ -21,6 +24,9 @@ beforeEach(() => { process.env.HOME = tmp; process.env.OPENCODEX_HOME = join(tmp, "ocx"); process.env.KIRO_REGION = "us-east-1"; + delete process.env.KIRO_CLI_DB_FILE; + delete process.env.KIROCLI_DB_PATH; + delete process.env.KIROCLI_TOKEN_KEY; process.env.CLAUDE_CONFIG_DIR = join(tmp, ".claude"); }); @@ -28,13 +34,22 @@ afterEach(() => { if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome; if (origOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = origOcxHome; if (origRegion === undefined) delete process.env.KIRO_REGION; else process.env.KIRO_REGION = origRegion; + if (origCliDbFile === undefined) delete process.env.KIRO_CLI_DB_FILE; else process.env.KIRO_CLI_DB_FILE = origCliDbFile; + if (origCliDbPath === undefined) delete process.env.KIROCLI_DB_PATH; else process.env.KIROCLI_DB_PATH = origCliDbPath; + if (origCliTokenKey === undefined) delete process.env.KIROCLI_TOKEN_KEY; else process.env.KIROCLI_TOKEN_KEY = origCliTokenKey; if (origClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = origClaudeConfigDir; globalThis.fetch = origFetch; console.warn = origWarn; rmSync(tmp, { recursive: true, force: true }); }); -function seedKiroCliDb(token: { access_token: string; refresh_token?: string; expires_at?: string }) { +function seedKiroCliDb(token: { + access_token: string; + refresh_token?: string; + expires_at?: string; + profile_arn?: string; + region?: string; +}) { const dir = join(tmp, "Library", "Application Support", "kiro-cli"); mkdirSync(dir, { recursive: true }); const db = new Database(join(dir, "data.sqlite3")); @@ -125,17 +140,68 @@ describe("oauth refresh hardening", () => { expect(getCredential("kiro")?.refresh).toBe("rt-fresh"); }); - test("fresh Kiro CLI SQLite token is imported before refresh endpoint", async () => { - const mock = mockRefreshFetch([new Response("unexpected", { status: 500 })]); + test("stored Kiro account refresh does not import a different local CLI session", async () => { + const mock = mockRefreshFetch([ + new Response(JSON.stringify({ accessToken: "aoa-refreshed", refreshToken: "rt-refreshed", expiresIn: 3600 }), { status: 200 }), + ]); seedKiroCliDb({ access_token: "aoa-sqlite", refresh_token: "rt-sqlite", expires_at: "2099-01-01T00:00:00Z" }); await saveCredential("kiro", { access: "aoa-old", refresh: "rt-old", expires: Date.now() - 1 }); - await expect(getValidAccessToken("kiro")).resolves.toBe("aoa-sqlite"); - expect(mock.count()).toBe(0); - expect(getCredential("kiro")?.refresh).toBe("rt-sqlite"); - expect(getCredential("kiro")?.source).toBe("local-cli"); + await expect(getValidAccessToken("kiro")).resolves.toBe("aoa-refreshed"); + expect(mock.count()).toBe(1); + expect(getCredential("kiro")?.refresh).toBe("rt-refreshed"); + expect(getCredential("kiro")?.source).not.toBe("local-cli"); + }); + + test("account-scoped Kiro OIDC refresh sends stored client registration and preserves metadata", async () => { + const profileArn = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/account-scoped"; + const urls: string[] = []; + const bodies: unknown[] = []; + globalThis.fetch = (async (input, init) => { + urls.push(String(input)); + bodies.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ + accessToken: "aoa-oidc-fresh", + refreshToken: "rt-oidc-fresh", + expiresIn: 3600, + }), { status: 200 }); + }) as typeof fetch; + await saveCredential("kiro", { + access: "aoa-oidc-old", + refresh: "rt-oidc-old", + expires: Date.now() - 1, + accountId: profileArn, + kiro: { + profileArn, + apiRegion: "eu-west-1", + ssoRegion: "eu-west-1", + clientId: "stored-client", + clientSecret: "stored-secret", + }, + }); + + await expect(getValidAccessToken("kiro")).resolves.toBe("aoa-oidc-fresh"); + expect(urls).toEqual(["https://oidc.eu-west-1.amazonaws.com/token"]); + expect(bodies).toEqual([{ + grantType: "refresh_token", + clientId: "stored-client", + clientSecret: "stored-secret", + refreshToken: "rt-oidc-old", + }]); + expect(getCredential("kiro")).toMatchObject({ + access: "aoa-oidc-fresh", + refresh: "rt-oidc-fresh", + accountId: profileArn, + kiro: { + profileArn, + apiRegion: "eu-west-1", + ssoRegion: "eu-west-1", + clientId: "stored-client", + clientSecret: "stored-secret", + }, + }); }); - test("failed refresh recovers from a now-fresh Kiro CLI SQLite token", async () => { + test("failed Kiro refresh does not overwrite the stored account from local CLI", async () => { await saveCredential("kiro", { access: "aoa-old", refresh: "rt-old", expires: Date.now() - 1 }); let calls = 0; globalThis.fetch = (async () => { @@ -144,10 +210,114 @@ describe("oauth refresh hardening", () => { throw new Error("network down"); }) as typeof fetch; - await expect(getValidAccessToken("kiro")).resolves.toBe("aoa-recovered"); + await expect(getValidAccessToken("kiro")).rejects.toThrow("network down"); expect(calls).toBe(1); - expect(getCredential("kiro")?.refresh).toBe("rt-recovered"); - expect(getCredential("kiro")?.source).toBe("local-cli"); + expect(getCredential("kiro")?.refresh).toBe("rt-old"); + expect(getCredential("kiro")?.access).toBe("aoa-old"); + }); + + test("late Kiro refresh cannot overwrite a newer reauthentication", async () => { + await saveCredential("kiro", { + access: "old-access", + refresh: "old-refresh", + expires: Date.now() - 1, + accountId: "kiro-race-account", + kiro: { profileArn: "old-profile", apiRegion: "us-east-1" }, + }); + let release!: () => void; + let started!: () => void; + const didStart = new Promise(resolve => { started = resolve; }); + const mayFinish = new Promise(resolve => { release = resolve; }); + globalThis.fetch = (async () => { + started(); + await mayFinish; + return new Response(JSON.stringify({ accessToken: "late-access", refreshToken: "late-refresh", expiresIn: 3600 }), { status: 200 }); + }) as typeof fetch; + + const pending = getValidAccessToken("kiro"); + await didStart; + await saveCredential("kiro", { + access: "reauth-access", + refresh: "reauth-refresh", + expires: Date.now() + 3_600_000, + accountId: "kiro-race-account", + kiro: { profileArn: "reauth-profile", apiRegion: "eu-west-1" }, + }); + release(); + + await expect(pending).resolves.toBe("reauth-access"); + expect(getCredential("kiro")).toMatchObject({ + access: "reauth-access", + refresh: "reauth-refresh", + kiro: { profileArn: "reauth-profile", apiRegion: "eu-west-1" }, + }); + }); + + test("terminal Kiro refresh errors mark only the rejected generation for reauthentication", async () => { + await saveCredential("kiro", { + access: "expired-access", + refresh: "revoked-refresh", + expires: Date.now() - 1, + accountId: "kiro-revoked-account", + }); + mockRefreshFetch([ + new Response(JSON.stringify({ error: "invalid_grant", error_description: "do not surface this detail" }), { + status: 400, + headers: { "content-type": "application/json" }, + }), + ]); + + const error = await getValidAccessToken("kiro").then( + () => undefined, + reason => reason, + ); + expect(error).toBeInstanceOf(OAuthLoginRequiredError); + expect(String(error)).not.toContain("do not surface this detail"); + expect(getAccountSet("kiro")?.accounts[0]?.needsReauth).toBe(true); + }); + + test("same-profile local rotation persists the recovered desktop generation without stale registration", async () => { + const profileArn = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/persisted"; + seedKiroCliDb({ + access_token: "aoa-local", + refresh_token: "rt-local-new", + expires_at: "2099-01-01T00:00:00Z", + profile_arn: profileArn, + region: "eu-west-1", + }); + await saveCredential("kiro", { + access: "aoa-stored", + refresh: "rt-stored-old", + expires: Date.now() - 1, + accountId: profileArn, + source: "local-cli", + kiro: { + profileArn, + ssoRegion: "eu-west-1", + clientId: "stale-client", + clientSecret: "stale-secret", + }, + }); + const urls: string[] = []; + globalThis.fetch = (async (input) => { + urls.push(String(input)); + if (urls.length === 1) return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + return new Response(JSON.stringify({ accessToken: "aoa-recovered", expiresIn: 3600 }), { status: 200 }); + }) as typeof fetch; + + const access = await getValidAccessToken("kiro"); + expect(access).toBe("aoa-recovered"); + expect(urls).toEqual([ + "https://oidc.eu-west-1.amazonaws.com/token", + "https://prod.eu-west-1.auth.desktop.kiro.dev/refreshToken", + ]); + expect(getCredential("kiro")).toMatchObject({ + access: "aoa-recovered", + refresh: "rt-local-new", + kiro: { profileArn, ssoRegion: "eu-west-1" }, + }); + expect(getCredential("kiro")?.kiro?.clientId).toBeUndefined(); + expect(getCredential("kiro")?.kiro?.clientSecret).toBeUndefined(); }); test("refresh preserves existing credential source metadata", async () => { diff --git a/tests/oauth-store-multi.test.ts b/tests/oauth-store-multi.test.ts index 066bba0c4..91b0d01b5 100644 --- a/tests/oauth-store-multi.test.ts +++ b/tests/oauth-store-multi.test.ts @@ -14,11 +14,12 @@ import { setAccountAlias, setActiveAccount, } from "../src/oauth/store"; +import type { OAuthCredentials } from "../src/oauth/types"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-store-multi-test"); let previousOpencodexHome: string | undefined; -const cred = (over: Partial<{ access: string; refresh: string; expires: number; email: string; accountId: string; projectId: string }> = {}) => ({ +const cred = (over: Partial = {}): OAuthCredentials => ({ access: "access-1", refresh: "refresh-1", expires: Date.now() + 3600_000, @@ -83,6 +84,33 @@ describe("multi-account auth store", () => { expect(getCredential("anthropic")?.email).toBe("b@example.com"); }); + test("Kiro account metadata stays attached to each distinct identity", async () => { + await saveCredential("kiro", cred({ + accountId: "profile-a", + access: "kiro-a", + kiro: { profileArn: "profile-a", apiRegion: "us-east-1", clientSecret: "secret-a" }, + })); + await saveCredential("kiro", cred({ + accountId: "profile-b", + access: "kiro-b", + kiro: { profileArn: "profile-b", apiRegion: "eu-west-1", clientSecret: "secret-b" }, + })); + + const set = getAccountSet("kiro")!; + expect(set.accounts).toHaveLength(2); + expect(set.accounts.find(account => account.credential.accountId === "profile-a")?.credential.kiro).toMatchObject({ + profileArn: "profile-a", + apiRegion: "us-east-1", + clientSecret: "secret-a", + }); + expect(set.accounts.find(account => account.credential.accountId === "profile-b")?.credential.kiro).toMatchObject({ + profileArn: "profile-b", + apiRegion: "eu-west-1", + clientSecret: "secret-b", + }); + expect(getCredential("kiro")).toMatchObject({ accountId: "profile-b", access: "kiro-b" }); + }); + test("same identity replaces credential without duplicating", async () => { await saveCredential("anthropic", cred({ email: "a@example.com", accountId: "acct-a" })); await saveCredential("anthropic", cred({ email: "a@example.com", accountId: "acct-a", access: "rotated", refresh: "rotated-refresh" })); diff --git a/tests/server-kiro-oauth-401-replay.test.ts b/tests/server-kiro-oauth-401-replay.test.ts index 8c5e1c800..c063169eb 100644 --- a/tests/server-kiro-oauth-401-replay.test.ts +++ b/tests/server-kiro-oauth-401-replay.test.ts @@ -141,6 +141,47 @@ function installFetch(chatStatuses: number[]): { chatAuth: string[]; refreshCall } describe("Kiro OAuth upstream 401 replay", () => { + test("selected OAuth account supplies its own Kiro runtime region and profile", async () => { + const profileArn = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/account-b"; + await saveCredential("kiro", { + access: "account-b-access", + refresh: "account-b-refresh", + expires: Date.now() + 3_600_000, + accountId: profileArn, + source: "local-cli", + kiro: { profileArn, apiRegion: "eu-west-1", ssoRegion: "us-east-1" }, + }); + saveConfig(config()); + let observed: { url: string; profileHeader: string | null; profileBody?: string } | undefined; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://runtime.eu-west-1.kiro.dev/") { + const body = JSON.parse(String(init?.body)) as { profileArn?: string }; + observed = { + url, + profileHeader: new Headers(init?.headers).get("x-amzn-kiro-profile-arn"), + profileBody: body.profileArn, + }; + return new Response(eventStream("account b"), { + headers: { "content-type": "application/vnd.amazon.eventstream" }, + }); + } + if (/^https:\/\/runtime\.[a-z0-9-]+\.kiro\.dev\//.test(url)) { + throw new Error(`unexpected Kiro runtime host: ${url}`); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0); + try { + const response = await post(server); + expect(response.status).toBe(200); + expect(observed).toEqual({ url: "https://runtime.eu-west-1.kiro.dev/", profileHeader: profileArn, profileBody: profileArn }); + } finally { + server.stop(true); + } + }); + test("401 then 200 performs one refresh and one replay", async () => { await seedOAuth(); saveConfig(config()); @@ -158,6 +199,66 @@ describe("Kiro OAuth upstream 401 replay", () => { } }); + test("401 replay uses metadata from a concurrently updated account generation", async () => { + const oldProfile = "arn:aws:codewhisperer:us-east-1:123456789012:profile/concurrent"; + const newProfile = "arn:aws:codewhisperer:eu-west-1:123456789012:profile/concurrent"; + await saveCredential("kiro", { + access: "rejected-access", + refresh: "initial-refresh", + expires: Date.now() + 3_600_000, + accountId: "kiro-concurrent-account", + source: "oauth", + kiro: { profileArn: oldProfile, apiRegion: "us-east-1", ssoRegion: "us-east-1" }, + }); + saveConfig(config()); + const observed: Array<{ url: string; auth: string; profile: string | null }> = []; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === CHAT_ENDPOINT) { + observed.push({ + url, + auth: new Headers(init?.headers).get("authorization") ?? "", + profile: new Headers(init?.headers).get("x-amzn-kiro-profile-arn"), + }); + await saveCredential("kiro", { + access: "concurrent-access", + refresh: "concurrent-refresh", + expires: Date.now() + 3_600_000, + accountId: "kiro-concurrent-account", + source: "oauth", + kiro: { profileArn: newProfile, apiRegion: "eu-west-1", ssoRegion: "eu-west-1" }, + }); + return new Response("rejected", { status: 401 }); + } + if (url === "https://runtime.eu-west-1.kiro.dev/") { + observed.push({ + url, + auth: new Headers(init?.headers).get("authorization") ?? "", + profile: new Headers(init?.headers).get("x-amzn-kiro-profile-arn"), + }); + return new Response(eventStream("updated account"), { + headers: { "content-type": "application/vnd.amazon.eventstream" }, + }); + } + if (/^https:\/\/runtime\.[a-z0-9-]+\.kiro\.dev\//.test(url)) { + throw new Error(`unexpected Kiro runtime host: ${url}`); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0); + try { + const response = await post(server); + expect(response.status).toBe(200); + expect(observed).toEqual([ + { url: CHAT_ENDPOINT, auth: "Bearer rejected-access", profile: oldProfile }, + { url: "https://runtime.eu-west-1.kiro.dev/", auth: "Bearer concurrent-access", profile: newProfile }, + ]); + } finally { + server.stop(true); + } + }); + test("a second 401 is propagated without a second refresh or replay", async () => { await seedOAuth(); saveConfig(config()); From 65e3fee1f8916ad08f61a0297da91aab05714795 Mon Sep 17 00:00:00 2001 From: tizerluo Date: Tue, 28 Jul 2026 07:14:15 +0800 Subject: [PATCH 019/129] feat(google): enable Gemini inline image output (#355) Gemini chat image models emit authenticated opaque artifact URLs via responseModalities, with CCA Images fallback hardening and focused regressions. Maintainer takeover on latest dev with CodeRabbit/Codex/Wibias review harden (admission bearer CCA path, n=1 + RECITATION, base64/magic validation, explicit allowlists, artifact HTTP route, size caps). Credit: @tizerluo (original feature). Co-authored-by: tizerluo <192086140+tizerluo@users.noreply.github.com> --- .../content/docs/guides/codex-integration.md | 17 +- .../docs/ja/guides/codex-integration.md | 11 +- .../docs/ko/guides/codex-integration.md | 11 +- .../src/content/docs/reference/adapters.md | 9 + .../docs/ru/guides/codex-integration.md | 12 +- .../docs/zh-cn/guides/codex-integration.md | 10 +- src/adapters/google-wire-compiler.ts | 4 + src/adapters/google.ts | 130 +- src/codex/catalog.ts | 2 +- src/codex/catalog/parsing.ts | 17 +- src/images/artifacts.ts | 286 +++++ src/lib/destination-policy.ts | 51 +- src/providers/antigravity-models.ts | 3 + src/server/images.ts | 312 ++++- src/server/index.ts | 30 + tests/codex-catalog.test.ts | 29 +- tests/google-antigravity-wire.test.ts | 1 + tests/google-hardening.test.ts | 26 + tests/images/gemini-inline.test.ts | 388 ++++++ tests/oauth-provider-reconcile.test.ts | 1 + tests/provider-registry-parity.test.ts | 3 +- tests/server-images.test.ts | 1041 ++++++++++++++++- 22 files changed, 2365 insertions(+), 29 deletions(-) create mode 100644 src/images/artifacts.ts create mode 100644 tests/images/gemini-inline.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index cd4fe810a..fab358a74 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -46,9 +46,17 @@ points at opencodex, the proxy relays those calls to the OpenAI upstream: `openai-responses` provider whose endpoint implements the OpenAI Images API. Explicit selection fails closed and never falls back to a different paid upstream. Registry-managed provider ids are not accepted here; omit `images.provider` to use the built-in OpenAI tiers. +- **Google Antigravity (CCA) fallback:** when neither an OpenAI forward candidate nor a keyed + provider is configured, `/v1/images/generations` (not `/images/edits`) falls back to the + Antigravity **Cloud Code Assist** endpoint using the `gemini-3.1-flash-image` model. The fallback + also fires after OpenAI auth resolution fails (e.g. an expired or missing ChatGPT credential), + not only when no OpenAI candidate is configured. This + requires `ocx login google-antigravity`; the OAuth token is sent only to the pinned CCA registry + host, never to a config-level `baseUrl` override. The response is returned in the same + `{created, data:[{b64_json}]}` shape Codex expects. - **Neither:** the proxy returns a clear error instead of a generic 404. Routed providers - (Cursor, Gemini, Kiro, …) cannot serve image generation; if you don't want the tool offered at - all, disable it in Codex with `codex features disable image_generation` + (Cursor, Gemini, Kiro, …) cannot serve the `image_generation` tool relay; if you don't want the + tool offered at all, disable it in Codex with `codex features disable image_generation` (`[features] image_generation = false` in `config.toml`). For an OpenAI-compatible custom gateway, configure a dedicated provider and select it only for @@ -75,6 +83,11 @@ The custom endpoint must accept `POST /v1/images/generations` and `/v1/images/ed OpenAI Images response shape expected by Codex. The provider's configured key replaces any caller bearer before the upstream request. +> **Note:** This refers only to the Codex `image_generation` tool (`/images/generations` relay). +> Gemini models that are image-capable produce inline images natively through the `google` adapter +> (via `responseModalities: ["TEXT", "IMAGE"]`), independent of this relay — see +> [Adapters](/reference/adapters/#google). + For a non-loopback `hostname`, Codex must send the generated API auth header. The injector therefore uses a dedicated provider instead: diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 80c4c530c..4d1a7da49 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -45,8 +45,15 @@ ChatGPT bearer 認証で直接 POST します。注入された `base_url` が o API キー方式 `openai-responses` プロバイダーを指定できます。明示的な選択が失敗しても、別の 有料上流へフォールバックしません。組み込みプロバイダー id には使わず、既定の OpenAI 経路を 使う場合は省略してください。 -- **両方なし:** 曖昧な 404 の代わりに明確なエラーを返します。ルーティングされる他のプロバイダー(Cursor、 - Gemini、Kiro など)は既定では画像生成を提供できません。ツール自体をオフにしたい場合は Codex で +- **Google Antigravity (CCA) フォールバック:** OpenAI forward 候補も API キープロバイダーもない場合、 + `/v1/images/generations`(`/images/edits` を除く)は Antigravity **Cloud Code Assist** エンドポイントに + フォールバックし、`gemini-3.1-flash-image` モデルを使用します。OpenAI 認証の解決に失敗した場合 + (例: ChatGPT 認証情報が期限切れまたは不在)も同様にフォールバックが発火し、OpenAI 候補が全くない + 場合のみではありません。`ocx login google-antigravity` が + 必要です。OAuth トークンは CCA レジストリホストにのみ送信され、設定の `baseUrl` オーバーライドには + 送信されません。レスポンスは Codex が期待する `{created, data:[{b64_json}]}` 形式で返されます。 +- **いずれもなし:** 曖昧な 404 の代わりに明確なエラーを返します。ルーティングされる他のプロバイダー(Cursor、 + Gemini、Kiro など)は画像生成を提供できません。ツール自体をオフにしたい場合は Codex で `codex features disable image_generation`(`config.toml` の `[features] image_generation = false`)を 使ってください。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index bcc3c1977..63182b91a 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -44,8 +44,15 @@ ChatGPT bearer 인증으로 직접 POST합니다. 주입된 `base_url`이 openco - **명시적 커스텀 프로바이더:** `images.provider`에 OpenAI Images API를 구현한 커스텀 API-key `openai-responses` 프로바이더를 지정할 수 있습니다. 명시적 선택이 실패해도 다른 유료 업스트림으로 fallback하지 않습니다. 내장 프로바이더 id에는 사용하지 말고, 기본 OpenAI 경로를 쓰려면 생략하세요. -- **둘 다 없음:** 모호한 404 대신 명확한 오류를 반환합니다. 라우팅되는 다른 프로바이더(Cursor, - Gemini, Kiro 등)는 기본적으로 이미지 생성을 제공할 수 없습니다. 도구 자체를 끄고 싶다면 Codex에서 +- **Google Antigravity (CCA) 폴백:** OpenAI forward 후보와 API key 프로바이더 모두 없을 때, + `/v1/images/generations`(`/images/edits` 제외)가 Antigravity **Cloud Code Assist** 엔드포인트로 + 폴백되며 `gemini-3.1-flash-image` 모델을 사용합니다. OpenAI 인증 해석이 실패할 때(예: ChatGPT 자격 + 증명이 만료되거나 누락된 경우)에도 동일하게 폴백이 트리거되며, OpenAI 후보가 아예 없을 때만 + 발생하는 것은 아닙니다. `ocx login google-antigravity`가 필요합니다. + OAuth 토큰은 CCA 레지스트리 호스트로만 전송되며 설정의 `baseUrl` 재정의로는 가지 않습니다. + 응답은 Codex가 기대하는 `{created, data:[{b64_json}]}` 형식으로 반환됩니다. +- **모두 없음:** 모호한 404 대신 명확한 오류를 반환합니다. 라우팅되는 다른 프로바이더(Cursor, + Gemini, Kiro 등)는 이미지 생성을 제공할 수 없습니다. 도구 자체를 끄고 싶다면 Codex에서 `codex features disable image_generation`(`config.toml`의 `[features] image_generation = false`)을 사용하세요. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1cce67679..e2ab3ebee 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -79,6 +79,15 @@ streams the response back **untranslated**. `functionDeclarations`. Data-URL images → `inline_data`. - Tool-call ids are synthesized when Gemini omits them. Antigravity preserves and replays real `thoughtSignature` values so reasoning continuity survives later turns. +- **Inline image output:** when the model is one of the explicit image-capable chat IDs + (`gemini-3.1-flash-image`, `gemini-2.0-flash-preview-image-generation`, or + `gemini-3-pro-image-preview`), the adapter sends `responseModalities: ["TEXT", "IMAGE"]`. + Standalone media-generation IDs such as `gemini-3-pro-image` are not included. Returned + `inlineData` parts are materialized under the configured OpenCodex `artifacts/` directory and + surfaced as markdown image links to the authenticated opaque route + `/v1/opencodex/artifacts/` (not `file:` URIs or host filesystem paths). Each image is capped + at 50 MB and each response at 100 MB of decoded data; malformed base64 payloads are rejected. + Artifacts are pruned automatically when the count exceeds 200 files. ## `kiro` diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index d6083929b..e92939c77 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -50,8 +50,16 @@ fast_mode = true API-key-провайдер `openai-responses`, реализующий OpenAI Images API. Ошибка явного выбора не приводит к fallback на другую платную вышестоящую сторону. Встроенные id провайдеров здесь не используются; чтобы сохранить стандартный путь OpenAI, опустите это поле. -- **Ни того, ни другого:** прокси возвращает понятную ошибку вместо безликого 404. - Маршрутизируемые провайдеры (Cursor, Gemini, Kiro, …) по умолчанию не могут обслуживать генерацию +- **Резерв Google Antigravity (CCA):** когда ни forward-кандидат OpenAI, ни провайдер с + API-ключом не настроены, `/v1/images/generations` (но не `/images/edits`) переключается на + эндпоинт Antigravity **Cloud Code Assist** с моделью `gemini-3.1-flash-image`. Этот же резерв + срабатывает и при сбое разрешения аутентификации OpenAI (например, истёкшая или отсутствующая + учётная запись ChatGPT), а не только при отсутствии кандидата OpenAI. Требуется + `ocx login google-antigravity`; OAuth-токен отправляется только на закреплённый хост реестра + CCA, а не на `baseUrl` из конфигурации. Ответ возвращается в том же формате + `{created, data:[{b64_json}]}`, что ожидает Codex. +- **Ничего из перечисленного:** прокси возвращает понятную ошибку вместо безликого 404. + Маршрутизируемые провайдеры (Cursor, Gemini, Kiro, …) не могут обслуживать генерацию изображений; если вы вообще не хотите предлагать этот инструмент, отключите его в Codex командой `codex features disable image_generation` (`[features] image_generation = false` в `config.toml`). diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 9fc3a5d4a..ba5b31951 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -43,8 +43,14 @@ OpenAI 上游: - **显式自定义 provider:** 可将 `images.provider` 设为一个自定义 API-key `openai-responses` provider;该 endpoint 必须实现 OpenAI Images API。显式选择失败时不会 fallback 到其他付费上游。内置 provider id 不适用于此字段;省略它即可使用默认 OpenAI 路径。 -- **两者都没有:** proxy 返回明确的错误而不是含糊的 404。其他路由提供商(Cursor、Gemini、 - Kiro 等)默认无法提供图像生成;如果想完全关闭该工具,可在 Codex 中执行 +- **Google Antigravity(CCA)回退:** 当 OpenAI forward 候选和 API key 提供商都不存在时, + `/v1/images/generations`(不含 `/images/edits`)会回退到 Antigravity **Cloud Code Assist** + 端点,使用 `gemini-3.1-flash-image` 模型。当 OpenAI 认证解析失败(例如 ChatGPT 凭证过期或缺失)时, + 该回退同样会触发,而不仅仅在没有任何 OpenAI 候选时。需要 `ocx login google-antigravity`;OAuth token + 只发送到 CCA 注册端点,不会发送到配置中的 `baseUrl` 覆盖地址。返回格式与 Codex 期望的 + `{created, data:[{b64_json}]}` 一致。 +- **以上都没有:** proxy 返回明确的错误而不是含糊的 404。其他路由提供商(Cursor、Gemini、 + Kiro 等)无法提供图像生成;如果想完全关闭该工具,可在 Codex 中执行 `codex features disable image_generation`(即 `config.toml` 的 `[features] image_generation = false`)。 diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts index 87100b0b0..88c482ba7 100644 --- a/src/adapters/google-wire-compiler.ts +++ b/src/adapters/google-wire-compiler.ts @@ -137,6 +137,10 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined { : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); if (thinkingLevel) out.thinkingConfig = { thinkingLevel }; } + if (Array.isArray(value.responseModalities)) { + const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m)); + if (valid.length > 0) out.responseModalities = valid; + } return Object.keys(out).length > 0 ? out : undefined; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index db627a174..abeb34620 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,6 +1,7 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; import { createHash } from "node:crypto"; +import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts"; import type { AdapterEvent, OcxAssistantMessage, @@ -233,6 +234,38 @@ function usageFromGemini(usage: Record | undefined): OcxUsage | }; } +/** + * Cap on the buffered non-streaming response body (100 MiB), matching + * IMAGES_RESPONSE_MAX_BYTES in src/server/images.ts. Enforced by streaming the + * body with a hard byte cap before JSON.parse — Content-Length alone is not + * trusted (missing/lying headers must still reject oversized payloads). + * Streaming SSE responses also cap each data frame before JSON.parse. + */ +const MAX_RESPONSE_BYTES = 100 * 1024 * 1024; +const MAX_SSE_FRAME_BYTES = MAX_RESPONSE_BYTES; + +// Note: imagen-* models use a different API surface (prediction/image-generation +// schema) and must NOT be treated as responseModalities-capable Gemini models. +// Explicit allowlist only — never `/gemini/ && /image/` (resurrects media-gen IDs). +const IMAGE_CAPABLE_MODELS = new Set([ + "gemini-3.1-flash-image", + "gemini-2.0-flash-preview-image-generation", + "gemini-3-pro-image-preview", +]); + +function isImageCapableModel(modelId: string): boolean { + return IMAGE_CAPABLE_MODELS.has(modelId); +} + +/** + * Model-visible markdown link for a materialized artifact. Uses the authenticated + * opaque HTTP route so remote/container clients can fetch the image without host + * filesystem paths leaking into the transcript. + */ +function artifactMarkdownUrl(filePath: string): string { + return artifactHttpUrl(filePath).replace(/([()])/g, "\\$1"); +} + export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter { // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest // can stash the CCA model/session for parseStream's reasoning-replay observation. @@ -272,6 +305,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte ? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning) : undefined; if (directFlashThinking) generationConfig.thinkingConfig = { thinkingLevel: directFlashThinking }; + if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) { + generationConfig.responseModalities = ["TEXT", "IMAGE"]; + } if (Object.keys(generationConfig).length > 0) body.generationConfig = generationConfig; const method = parsed.stream ? "streamGenerateContent" : "generateContent"; @@ -377,6 +413,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte yield { type: "error", message: "No response body" }; return; } + // Streaming responses are processed incrementally (SSE chunks), so the full body + // is never buffered — no Content-Length pre-check is needed here. Per-image size + // protection is enforced on each chunk via MAX_ENCODED_BYTES_PER_IMAGE before + // materializeInlineImage is called (see the inline.data check below). const reader = response.body.getReader(); const decoder = new TextDecoder(); @@ -387,9 +427,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte let sawAnyFrame = false; let sawTerminalSignal = false; - const handleDataLine = function* (line: string): Generator { + const handleDataLine = async function* (line: string): AsyncGenerator { const payload = line.slice(5).trim(); if (!payload) return "continue"; + if (payload.length > MAX_SSE_FRAME_BYTES) { + yield { type: "error", message: `upstream SSE data frame exceeds ${MAX_SSE_FRAME_BYTES} bytes` }; + return "terminate"; + } let emittedContentEvent = false; let chunk: Record; @@ -452,6 +496,21 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte emittedContentEvent = true; yield { type: "text_delta", text: part.text }; } + const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; + if (inline && typeof inline.data === "string") { + if (inline.data.length > MAX_ENCODED_BYTES_PER_IMAGE) { + yield { type: "error", message: "inline image exceeds per-image size cap" }; + } else { + try { + const filePath = await materializeInlineImage(inline.data, imageBudget); + const escapedPath = artifactMarkdownUrl(filePath); + emittedContentEvent = true; + yield { type: "text_delta", text: `\n![image](${escapedPath})\n` }; + } catch { + yield { type: "error", message: "failed to materialize inline image" }; + } + } + } if (part.functionCall) { const id = `call_${crypto.randomUUID().slice(0, 8)}`; toolCallsStarted++; @@ -464,12 +523,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } return emittedContentEvent ? "content" : "continue"; }; + const imageBudget = createImageBudget(); try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); + // Cap incomplete frames before waiting for a newline — otherwise a single + // unterminated data: payload can grow without bound. + if (buffer.length > MAX_SSE_FRAME_BYTES) { + yield { type: "error", message: `upstream SSE data frame exceeds ${MAX_SSE_FRAME_BYTES} bytes` }; + try { await reader.cancel(); } catch { /* ignore */ } + return; + } const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; @@ -526,7 +593,51 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte }, async parseResponse(response: Response): Promise { - const raw = await response.json() as Record; + // Reject oversized responses before JSON parse. Prefer Content-Length when + // present and truthful; always stream-read with a hard byte cap so a missing + // or lying Content-Length cannot force a full in-memory buffer + parse. + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { + try { await response.body?.cancel(); } catch { /* ignore */ } + return [{ type: "error", message: `google response too large (content-length ${contentLength} exceeds ${MAX_RESPONSE_BYTES} bytes)` }]; + } + let rawText: string; + try { + const reader = response.body?.getReader(); + if (!reader) return [{ type: "error", message: "google response had no body" }]; + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => {}); + return [{ type: "error", message: `google response too large (exceeded ${MAX_RESPONSE_BYTES} bytes)` }]; + } + chunks.push(value); + } + } finally { + try { await reader.cancel(); } catch { /* ignore */ } + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + rawText = new TextDecoder().decode(bytes); + } catch (err) { + return [{ type: "error", message: err instanceof Error ? err.message : "failed to read google response body" }]; + } + let raw: Record; + try { + raw = JSON.parse(rawText) as Record; + } catch { + return [{ type: "error", message: "google response was not valid JSON" }]; + } if (raw.error) { const err = raw.error as { message?: string }; return [{ type: "error", message: err.message ?? "upstream error" }]; @@ -547,6 +658,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return [{ type: "error", message: "google response contained no candidates" }]; } let toolCallsStarted = 0; + const imageBudget = createImageBudget(); if (candidates?.[0]?.content?.parts) { // Non-streaming CCA: observe thoughtSignatures for the next turn, same as the stream path. if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravitySession) { @@ -554,6 +666,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } for (const part of candidates[0].content.parts) { if (part.text) events.push({ type: "text_delta", text: part.text }); + const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; + if (inline && typeof inline.data === "string") { + if (inline.data.length > MAX_ENCODED_BYTES_PER_IMAGE) { + events.push({ type: "error", message: "inline image exceeds per-image size cap" }); + } else { + try { + const filePath = await materializeInlineImage(inline.data, imageBudget); + const escapedPath = artifactMarkdownUrl(filePath); + events.push({ type: "text_delta", text: `\n![image](${escapedPath})\n` }); + } catch { + events.push({ type: "error", message: "failed to materialize inline image" }); + } + } + } if (part.functionCall) { const id = `call_${crypto.randomUUID().slice(0, 8)}`; toolCallsStarted++; diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 97eda8b89..0b4337f5e 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -1,6 +1,6 @@ // AUTO-SPLIT facade: original catalog.ts body moved into ./catalog/* modules. // Public surface preserved exactly; importers keep using "src/codex/catalog". -export { isMediaGenerationModelId, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; +export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 9022dde71..96a4c0f26 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -141,9 +141,24 @@ export function isMediaGenerationModelId(id: string): boolean { return MEDIA_GEN_ID_RE.test(id); } +/** + * Gemini image-capable chat models produce inline images within text responses + * via the Responses API. Explicit allowlist only — a broad `/gemini/ && /image/` + * heuristic resurrects standalone media-gen IDs (e.g. gemini-3-pro-image). + */ +const GEMINI_IMAGE_CHAT_MODEL_IDS = new Set([ + "gemini-3.1-flash-image", + "gemini-2.0-flash-preview-image-generation", + "gemini-3-pro-image-preview", +]); + +function isGeminiImageChatModel(id: string): boolean { + return GEMINI_IMAGE_CHAT_MODEL_IDS.has(id); +} + export function shouldExposeRoutedModel(model: CatalogModel): boolean { if (isRoutedModelCompatibilityExcluded(`${model.provider}/${model.id}`)) return false; - if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true; + if (isGeminiImageChatModel(model.id)) return true; return !isMediaGenerationModelId(model.id); } diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts new file mode 100644 index 000000000..3d8d95227 --- /dev/null +++ b/src/images/artifacts.ts @@ -0,0 +1,286 @@ +import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, join, resolve, sep } from "node:path"; +import { getConfigDir } from "../config"; +import { assessUrlDestination, assertUrlResolvesPublic } from "../lib/destination-policy"; + +const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024; +const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024; +const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MiB + +/** + * Upper bound on the raw base64 string length before it is decoded. Base64 + * encoding expands 3 decoded bytes to 4 encoded chars, so this corresponds to + * MAX_DECODED_BYTES_PER_IMAGE. Checking this in the adapter (before calling + * materializeInlineImage) rejects oversized payloads before normalization + * copies them — see Wibias R4 finding 5. + */ +export const MAX_ENCODED_BYTES_PER_IMAGE = Math.ceil(MAX_DECODED_BYTES_PER_IMAGE * 4 / 3); + +/** Default cap on files retained under artifacts/. Oldest files are pruned when exceeded. */ +export const DEFAULT_ARTIFACT_KEEP_COUNT = 200; + +/** Opaque artifact HTTP path prefix (data-plane, API-auth gated). */ +export const ARTIFACT_HTTP_PREFIX = "/v1/opencodex/artifacts"; + +const ARTIFACT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,200}\.(png|jpe?g|webp|gif)$/i; + +// Strict alphabet check: Buffer.from(..., "base64") silently ignores invalid +// characters, so malformed payloads would otherwise decode to garbage bytes. +const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; + +export interface ImageBudget { + spent: number; +} + +export function createImageBudget(): ImageBudget { + return { spent: 0 }; +} + +export function getArtifactsDir(): string { + return join(getConfigDir(), "artifacts"); +} + +/** + * Markdown-safe relative URL for a materialized artifact. Opaque filename only — + * never expose host filesystem paths to model-visible content. + */ +export function artifactHttpUrl(filePath: string): string { + const name = basename(filePath); + if (!ARTIFACT_ID_RE.test(name)) { + throw new Error("artifact filename is not a valid opaque id"); + } + return `${ARTIFACT_HTTP_PREFIX}/${name}`; +} + +/** + * Resolve an opaque artifact id to an absolute path under the artifacts dir. + * Rejects traversal (`..`, absolute paths, separators). + */ +export function resolveArtifactPath(id: string): string | null { + if (!ARTIFACT_ID_RE.test(id)) return null; + const dir = resolve(getArtifactsDir()); + const candidate = resolve(dir, id); + if (candidate !== dir && !candidate.startsWith(dir + sep)) return null; + if (!existsSync(candidate)) return null; + try { + if (!statSync(candidate).isFile()) return null; + } catch { + return null; + } + return candidate; +} + +export function readArtifactBytes(id: string): { bytes: Buffer; contentType: string } | null { + const path = resolveArtifactPath(id); + if (!path) return null; + const bytes = readFileSync(path); + const ext = path.split(".").pop()?.toLowerCase(); + const contentType = + ext === "png" ? "image/png" + : ext === "jpg" || ext === "jpeg" ? "image/jpeg" + : ext === "webp" ? "image/webp" + : ext === "gif" ? "image/gif" + : "application/octet-stream"; + return { bytes, contentType }; +} + +/** + * Decode + validate base64 image bytes (alphabet, size, magic). Used by CCA + * Images fallback before returning b64_json and by materializeInlineImage. + */ +export function decodeValidatedImageBase64(base64Data: string): Buffer { + const normalized = base64Data.replace(/\s+/g, ""); + if (!BASE64_RE.test(normalized) || normalized.length % 4 !== 0) { + throw new Error("inline image data is not valid base64"); + } + if (normalized.length > MAX_ENCODED_BYTES_PER_IMAGE) { + throw new Error(`inline image exceeds ${MAX_DECODED_BYTES_PER_IMAGE} byte per-image cap`); + } + const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0; + const decodedBytes = (normalized.length / 4) * 3 - padding; + if (decodedBytes === 0) throw new Error("inline image data is empty after base64 decode"); + if (decodedBytes > MAX_DECODED_BYTES_PER_IMAGE) { + throw new Error(`inline image exceeds ${MAX_DECODED_BYTES_PER_IMAGE} byte per-image cap`); + } + const buf = Buffer.from(normalized, "base64"); + guessExtFromMagic(buf); + return buf; +} + +/** + * Best-effort retention cap: when the artifact directory holds more than `maxFiles`, + * delete the oldest (by mtime) until the count is back under the limit. Synchronous + * on purpose — it runs right after each successful write and touches at most a handful + * of files. All errors are swallowed and logged so a prune failure never breaks an image write. + */ +export function pruneOldArtifacts(dir: string, maxFiles: number): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch (e) { + console.warn(`[images] prune: could not read ${dir}:`, e instanceof Error ? e.message : e); + return; + } + if (entries.length <= maxFiles) return; + + let stats: Array<{ name: string; mtime: number }>; + try { + stats = entries.map(name => { + const st = statSync(join(dir, name)); + return { name, mtime: st.mtimeMs }; + }); + } catch (e) { + console.warn(`[images] prune: could not stat files in ${dir}:`, e instanceof Error ? e.message : e); + return; + } + + // Sort oldest-first, delete the excess. + stats.sort((a, b) => a.mtime - b.mtime); + const toDelete = stats.slice(0, stats.length - maxFiles); + for (const { name } of toDelete) { + try { + unlinkSync(join(dir, name)); + } catch (e) { + console.warn(`[images] prune: could not delete ${name}:`, e instanceof Error ? e.message : e); + } + } +} + +function timestampPrefix(): string { + const now = new Date(); + return [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + "-", + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + "-", + String(now.getMilliseconds()).padStart(3, "0"), + ].join(""); +} + +/** + * Write a buffer to a unique artifact file using `flag: "wx"` (exclusive create). + * Collisions on the random UUID suffix are astronomically unlikely, but `wx` + * would surface them as EEXIST; retry a few times with a fresh UUID before + * giving up so a fluke name clash can never fail an image write. + */ +async function writeArtifactUnique( + dir: string, + prefix: string, + buf: Uint8Array, + ext: string, +): Promise { + for (let attempt = 0; ; attempt++) { + const suffix = attempt === 0 ? crypto.randomUUID() : `${crypto.randomUUID()}-${attempt}`; + const filePath = join(dir, `${prefix}${timestampPrefix()}-${suffix}.${ext}`); + try { + await writeFile(filePath, buf, { mode: 0o600, flag: "wx" }); + return filePath; + } catch (e) { + if (e instanceof Error && "code" in e && (e as { code: string }).code === "EEXIST" && attempt < 3) continue; + throw e; + } + } +} + +export function guessExtFromMagic(bytes: Uint8Array): string { + const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1"); + if (sig.startsWith("\x89PNG\r\n\x1a\n")) return "png"; + if (sig.startsWith("\xff\xd8\xff")) return "jpg"; + if (sig.startsWith("RIFF") && sig.slice(8, 12) === "WEBP") return "webp"; + if (sig.startsWith("GIF8")) return "gif"; + throw new Error("unrecognized image format — magic bytes do not match PNG, JPEG, WebP, or GIF"); +} + +export async function materializeInlineImage( + base64Data: string, + budget?: ImageBudget, + keepCount?: number, +): Promise { + const dir = getArtifactsDir(); + await mkdir(dir, { recursive: true, mode: 0o700 }); + + const buf = decodeValidatedImageBase64(base64Data); + if (budget && budget.spent + buf.length > MAX_DECODED_BYTES_PER_RESPONSE) { + throw new Error(`inline image response exceeds ${MAX_DECODED_BYTES_PER_RESPONSE} byte per-response cap`); + } + if (budget) budget.spent += buf.length; + + // Sniff actual format from decoded bytes rather than trusting the declared mimeType. + const ext = guessExtFromMagic(buf); + const filePath = await writeArtifactUnique(dir, "img-", buf, ext); + pruneOldArtifacts(dir, keepCount ?? DEFAULT_ARTIFACT_KEEP_COUNT); + return filePath; +} + +export async function downloadImageToArtifact( + url: string, + budget?: ImageBudget, + signal?: AbortSignal, + keepCount?: number, +): Promise { + if (url.startsWith("data:")) { + const m = /^data:([^;]+);base64,(.+)$/.exec(url); + if (!m) throw new Error("data URL is not a valid base64 image"); + return materializeInlineImage(m[2], budget, keepCount); + } + + // SSRF protection: validate the provider-returned URL before fetching. + // Require HTTPS strictly — plain HTTP and all other schemes (ftp, file, …) are rejected. + let parsedUrl: URL; + try { parsedUrl = new URL(url); } catch { throw new Error("image URL is not valid"); } + if (parsedUrl.protocol !== "https:") { + throw new Error(`image URL must use HTTPS, got ${parsedUrl.protocol}`); + } + // Reject literal private/loopback/link-local/metadata addresses. + const assessment = assessUrlDestination(url); + if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") { + throw new Error(`image URL targets ${assessment.detail}`); + } + // DNS check: resolve hostname and reject if it points at private/internal space. + await assertUrlResolvesPublic(url); + const resp = await fetch(url, { signal, redirect: "error" }); + if (!resp.ok) throw new Error("image download failed: " + resp.status); + + // Stream the body with a hard byte cap so a missing/lying Content-Length or a + // compromised CDN URL cannot exhaust memory before the size check runs. + if (!resp.body) throw new Error("image download returned no body"); + const reader = resp.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_DOWNLOAD_BYTES) { + throw new Error(`image download exceeds ${MAX_DOWNLOAD_BYTES} byte cap`); + } + chunks.push(value); + } + } finally { + try { await reader.cancel(); } catch { /* ignore cancel errors */ } + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { bytes.set(c, offset); offset += c.byteLength; } + + if (budget && budget.spent + bytes.length > MAX_DECODED_BYTES_PER_RESPONSE) { + throw new Error(`image download exceeds ${MAX_DECODED_BYTES_PER_RESPONSE} byte per-response budget`); + } + + const ext = guessExtFromMagic(bytes); + const dir = getArtifactsDir(); + await mkdir(dir, { recursive: true, mode: 0o700 }); + if (budget) budget.spent += bytes.length; + + const filePath = await writeArtifactUnique(dir, "dl-", bytes, ext); + pruneOldArtifacts(dir, keepCount ?? DEFAULT_ARTIFACT_KEEP_COUNT); + return filePath; +} diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 54cffcbd1..1ec932bd4 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -19,7 +19,7 @@ const BLOCKED_METADATA_IPV6 = new Set([ "fd00:ec2::254", ]); -type DestinationKind = +export type DestinationKind = | "public" | "hostname" | "localhost" @@ -178,3 +178,52 @@ export async function providerDestinationResolvedError( } return null; } + +export interface UrlDestinationAssessment { + kind: DestinationKind; + detail: string; +} + +/** + * Synchronous literal URL destination assessment — classifies the hostname + * without DNS resolution. Returns null for unparseable URLs. + */ +export function assessUrlDestination(url: string): UrlDestinationAssessment | null { + return assessDestination(url); +} + +/** + * Async DNS-resolved URL safety check. Resolves A/AAAA records and rejects + * if any address is loopback, private, link-local, unspecified, or metadata. + * Throws on unsafe destination; returns void on safe/public destination. + * DNS resolution failures are treated as unsafe (fail-closed). + */ +export async function assertUrlResolvesPublic(url: string): Promise { + let hostname: string; + try { + hostname = normalizeHostname(new URL(url.trim()).hostname); + } catch { + throw new Error("image URL is not a valid URL"); + } + if (!hostname) throw new Error("image URL has no hostname"); + const literalAssessment = assessDestination(url); + if (literalAssessment && literalAssessment.kind !== "public" && literalAssessment.kind !== "hostname") { + throw new Error(`image URL targets ${literalAssessment.detail}`); + } + // For literal IPs and localhost, the sync path already classified them. + if (isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) return; + let addresses: { address: string }[]; + try { + addresses = await lookup(hostname, { all: true, verbatim: true }); + } catch { + // If DNS fails, we can't verify — fail-closed (unlike provider config-time validation, + // this is a runtime fetch to an untrusted URL, so be conservative). + throw new Error(`image URL hostname ${hostname} could not be resolved`); + } + for (const { address } of addresses) { + const ipKind = isIP(address); + const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; + if (!assessment || assessment.kind === "public") continue; + throw new Error(`image URL hostname ${hostname} resolves to ${assessment.detail} (${address})`); + } +} diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index e241e1e33..18b0b3961 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -13,6 +13,7 @@ const ANTIGRAVITY_WIRE_MODELS = [ "gemini-3.6-flash-high", "gemini-3.1-pro-low", "gemini-pro-agent", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", @@ -85,6 +86,7 @@ export const ANTIGRAVITY_MODEL_ALIASES: Record = { export const ANTIGRAVITY_MODELS = [ "gemini-3.6-flash", "gemini-3.1-pro", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", @@ -97,6 +99,7 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record = { "gemini-3.6-flash-high": 1_048_576, "gemini-3.1-pro-low": 1_048_576, "gemini-pro-agent": 1_048_576, + "gemini-3.1-flash-image": 1_048_576, "claude-sonnet-4-6": 200_000, "claude-opus-4-6-thinking": 1_000_000, "gpt-oss-120b-medium": 131_072, diff --git a/src/server/images.ts b/src/server/images.ts index ea6b7a9a4..533098bb1 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -25,10 +25,16 @@ import { signalWithTimeout } from "../lib/abort"; import { sidecarEnter } from "../lib/sidecar-tracker"; import type { OcxConfig } from "../types"; import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar"; +import { getProviderRegistryEntry } from "../providers/registry"; import { readJsonRequestBody } from "./request-decompress"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; import type { RequestLogContext } from "./request-log"; import { codexLogAccountId, decodeRequestErrorResponse } from "./responses"; +import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index"; +import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors"; +import { sanitizeUpstreamErrorText } from "../adapters/upstream-http-error"; +import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; +import { decodeValidatedImageBase64, MAX_ENCODED_BYTES_PER_IMAGE } from "../images/artifacts"; export type ImagesEndpoint = "generations" | "edits"; @@ -42,6 +48,276 @@ const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000; */ const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024; +const CCA_IMAGE_MODEL = "gemini-3.1-flash-image"; + +/** + * Google Gemini finishReasons that indicate a permanent content/safety block. + * When any of these is present, the same prompt will always fail — retrying + * wastes paid quota. The response is surfaced as 400 (non-retryable) instead + * of 502 (which codex retries up to 5 times). + */ +const CCA_BLOCKING_FINISH_REASONS: ReadonlySet = new Set([ + "SAFETY", + "BLOCKLIST", + "PROHIBITED_CONTENT", + "SPII", + "RECITATION", +]); + +/** + * Race a promise against an abort signal. If the signal aborts first, reject + * immediately — our code stops awaiting the underlying operation even though + * the HTTP request behind it may still complete. Used to make the non-cancellable + * OAuth refresh chain responsive to client cancellation and deadline expiry. + */ +function abortableRace(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError")); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError")); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (val) => { signal.removeEventListener("abort", onAbort); resolve(val); }, + (err) => { signal.removeEventListener("abort", onAbort); reject(err); }, + ); + }); +} + +async function tryCcaImageGeneration( + body: unknown, + config: OcxConfig, + logCtx: RequestLogContext, + signal: AbortSignal, + endpoint: ImagesEndpoint, +): Promise { + if (endpoint !== "generations") return undefined; + const provider = config.providers?.["google-antigravity"]; + if (!provider || provider.disabled) return undefined; + + const prompt = (body as { prompt?: unknown })?.prompt; + if (typeof prompt !== "string" || !prompt.trim()) { + return formatErrorResponse(400, "invalid_request_error", "prompt is required and must not be empty"); + } + + const nRaw = (body as { n?: unknown })?.n; + if (nRaw !== undefined && nRaw !== null) { + const n = typeof nRaw === "number" ? nRaw : Number(nRaw); + if (!Number.isInteger(n) || n !== 1) { + return formatErrorResponse(400, "invalid_request_error", "CCA image generation supports n=1 only"); + } + } + + // Create the deadline before credential resolution so the timeout covers + // OAuth token refresh and project discovery, not just the upstream fetch. + const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS; + const linkedSignal = signalWithTimeout(timeoutMs, signal); + let token: string; + try { + // Race the OAuth refresh against the deadline signal. getValidAccessToken + // chains through 4 layers (resolveAccessSnapshotForAccount → + // refreshAndPersistAccessToken → refreshGenericAccountWithLock → + // def.refresh()) that do HTTP calls without accepting a signal. Rather than + // threading signal through the entire chain, race the whole call against + // linkedSignal: when the signal aborts we stop awaiting and surface the + // cancellation immediately instead of hanging on the refresh HTTP call. + token = await abortableRace(getValidAccessToken("google-antigravity"), linkedSignal.signal); + } catch (err) { + linkedSignal.cleanup(); + // abortableRace rejects immediately when the signal fires, so client + // cancellation and deadline expiry surface here. Parent abort propagates + // into the linked signal, so check parent first (499) before the linked + // signal (504). + if (signal.aborted) { + return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); + } + if (linkedSignal.signal.aborted) { + return formatErrorResponse(504, "upstream_error", "CCA image generation timed out during authentication"); + } + // Missing/revoked credential → 401 (re-login required); transient refresh/network → 502. + const errName = err instanceof Error ? err.name : ""; + if (errName === "OAuthLoginRequiredError") { + return formatErrorResponse(401, "invalid_request_error", "Google Antigravity login required: run 'ocx login google-antigravity'"); + } + return formatErrorResponse(502, "upstream_error", "CCA image generation failed: OAuth token refresh failed"); + } + const project = getOAuthCredentialProjectId("google-antigravity"); + if (!project) { + linkedSignal.cleanup(); + return formatErrorResponse( + 400, + "invalid_request_error", + "Antigravity requires a discovered Cloud Code Assist project id (re-run `ocx login google-antigravity`).", + ); + } + + logCtx.provider = "google-antigravity"; + logCtx.model = CCA_IMAGE_MODEL; + + // Pin to the registry endpoint — never use a config-level baseUrl override for OAuth token transmission. + const registryEntry = getProviderRegistryEntry("google-antigravity"); + const baseUrl = registryEntry?.baseUrl ?? "https://daily-cloudcode-pa.googleapis.com"; + const envelope = { + model: CCA_IMAGE_MODEL, + userAgent: "antigravity", + requestType: "agent", + project, + requestId: `agent-${crypto.randomUUID()}`, + request: { + contents: [{ role: "user", parts: [{ text: prompt }] }], + generationConfig: { responseModalities: ["TEXT", "IMAGE"] }, + sessionId: `ocx-img-${crypto.randomUUID().slice(0, 8)}`, + }, + }; + let upstream: Response; + try { + try { + upstream = await fetch(`${baseUrl}/v1internal:generateContent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + "User-Agent": ANTIGRAVITY_REQUEST_UA, + }, + body: JSON.stringify(envelope), + signal: linkedSignal.signal, + }); + } catch (err) { + if (signal.aborted) return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); + if (err instanceof Error && err.name === "TimeoutError") { + return formatErrorResponse(504, "upstream_error", "CCA image generation timed out"); + } + // Network/DNS/runtime errors may embed the request URL or headers verbatim + // (e.g. "fetch failed: https://…/v1internal:generateContent"). The token + // lives in an Authorization header, not in the URL, but sanitize defensively + // so no upstream-rejected credential or query param can reach the client, + // and strip the internal base URL host from the surfaced message. + const rawMsg = err instanceof Error ? err.message : String(err); + const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace( + /https?:\/\/[^\s"'<>]+/gi, + "[upstream-url]", + ); + return formatErrorResponse(502, "upstream_error", `CCA image generation failed: ${safeMsg}`); + } + + // Stream the upstream body with a bounded reader so an oversized or malicious + // response is rejected mid-stream rather than after a full arrayBuffer() allocation. + let payload: Uint8Array; + try { + const reader = upstream.body?.getReader(); + if (!reader) { + return formatErrorResponse(502, "upstream_error", "CCA image response had no body"); + } + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > IMAGES_RESPONSE_MAX_BYTES) { + await reader.cancel().catch(() => {}); + return formatErrorResponse(502, "upstream_error", `CCA image response too large (exceeded ${IMAGES_RESPONSE_MAX_BYTES} bytes)`); + } + chunks.push(value); + } + } finally { + try { await reader.cancel(); } catch { /* ignore */ } + reader.releaseLock(); + } + payload = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + payload.set(chunk, offset); + offset += chunk.byteLength; + } + } catch (err) { + // Body-read timeout/abort: when CCA returns headers then stalls, the linked + // signal's timeout aborts reader.read(), which rejects here. The rejection + // often surfaces as AbortError (not TimeoutError), so distinguish by signal + // state, not error name. Parent abort propagates into the linked signal, so + // check the parent first: parent abort → 499 (client cancelled); linked-only + // abort → 504 (upstream stall); anything else → 502 body-read failure. + if (signal.aborted) { + return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); + } + if (linkedSignal.signal.aborted) { + return formatErrorResponse(504, "upstream_error", "CCA image response timed out during body read"); + } + const rawMsg = err instanceof Error ? err.message : String(err); + const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace(/https?:\/\/[^\s"'<>]+/gi, "[upstream-url]"); + return formatErrorResponse(502, "upstream_error", `CCA image body read failed: ${safeMsg}`); + } + + if (!upstream.ok) { + // Preserve auth/rate-limit and other permanent 4xx signals so callers can + // distinguish retryable from permanent failures. Remaining 5xx collapse to 502. + const text = new TextDecoder().decode(payload); + const safeMsg = safeAntigravityHttpErrorMessage(upstream.status, text); + if (upstream.status >= 400 && upstream.status < 500) { + return formatErrorResponse(upstream.status, "upstream_error", safeMsg); + } + return formatErrorResponse(502, "upstream_error", safeMsg); + } + + let json: Record; + try { + json = JSON.parse(new TextDecoder().decode(payload)) as Record; + } catch { + return formatErrorResponse(502, "upstream_error", "CCA image response was not valid JSON"); + } + const resp = (json.response ?? json) as { + candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] }; finishReason?: string }[]; + promptFeedback?: { blockReason?: string }; + }; + // Safety blocks are permanent for the same prompt — return 400 (non-retryable) + // instead of 502 (which codex retries up to 5 times, wasting paid quota on a + // prompt that will never succeed). Two blocking signals exist in the Gemini + // API: promptFeedback.blockReason (prompt rejected before generation) and + // candidate.finishReason (generation cut off by a content filter). + const blockReason = resp.promptFeedback?.blockReason; + if (typeof blockReason === "string" && blockReason.trim()) { + return formatErrorResponse(400, "invalid_request_error", `CCA image generation blocked by safety filter (promptFeedback.blockReason: ${blockReason})`); + } + const candidate = resp.candidates?.[0]; + const finishReason = candidate?.finishReason; + if (typeof finishReason === "string" && CCA_BLOCKING_FINISH_REASONS.has(finishReason)) { + return formatErrorResponse(400, "invalid_request_error", `CCA image generation blocked by safety filter (finishReason: ${finishReason})`); + } + const parts = candidate?.content?.parts; + if (!Array.isArray(parts)) { + return formatErrorResponse(502, "upstream_error", "CCA image response had no valid parts array"); + } + const images: { b64_json: string }[] = []; + for (const part of parts) { + if (!part || typeof part !== "object") continue; + const data = part.inlineData?.data; + if (typeof data !== "string" || data.length === 0) continue; + if (data.length > MAX_ENCODED_BYTES_PER_IMAGE) { + return formatErrorResponse(502, "upstream_error", "CCA image payload exceeds per-image size cap"); + } + try { + decodeValidatedImageBase64(data); + } catch { + return formatErrorResponse(502, "upstream_error", "CCA image payload failed base64/magic validation"); + } + images.push({ b64_json: data }); + } + if (images.length === 0) { + return formatErrorResponse(502, "upstream_error", "CCA image model returned no image data"); + } + // Only `{created, data:[{b64_json}]}` is returned — no token, projectId, or + // upstream metadata leak through. The Authorization header is consumed by the + // fetch above and never copied onto this Response. + return new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: images }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } finally { + linkedSignal.cleanup(); + } +} + export async function handleImages( req: Request, config: OcxConfig, @@ -53,11 +329,18 @@ export async function handleImages( return formatErrorResponse(400, "invalid_request_error", candidates.error); } const explicitKeyedProvider = config.images?.provider !== undefined && candidates.keyed !== undefined; + // Admission bearer is valid proxy auth (requireApiAuth already passed) but must never be + // forwarded as OpenAI ChatGPT credentials. When the caller sent it, skip OpenAI forward + // and allow CCA / keyed paths instead of rejecting the whole request. + let skipOpenAiForwardForAdmissionBearer = false; if (!explicitKeyedProvider) { try { validateForwardAdmissionCredential(req.headers, config); } catch (err) { - if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message); - throw err; + if (err instanceof ForwardAdmissionCredentialError) { + skipOpenAiForwardForAdmissionBearer = true; + } else { + throw err; + } } } let body: unknown; @@ -69,15 +352,19 @@ export async function handleImages( const model = (body as { model?: unknown } | null)?.model; if (typeof model === "string" && model) logCtx.model = model; - if (candidates.forwardCandidates.length === 0 && !candidates.keyed) { + const canUseOpenAiForward = !skipOpenAiForwardForAdmissionBearer && candidates.forwardCandidates.length > 0; + + if (!canUseOpenAiForward && !candidates.keyed) { + const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); + if (ccaResponse) return ccaResponse; // 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent // configuration state that must surface on the first attempt. return formatErrorResponse( 400, "invalid_request_error", - "Built-in image generation needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider), " - + "but none is configured in opencodex. Routed providers cannot serve /v1/images/* — " - + "add an OpenAI provider or disable the tool with `codex features disable image_generation`.", + "Built-in image generation needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider) " + + "or a logged-in Google Antigravity (Cloud Code Assist) provider, " + + "but none is configured in opencodex. Add a provider or disable the tool with `codex features disable image_generation`.", ); } @@ -86,7 +373,7 @@ export async function handleImages( // 429 image_gen while api.openai.com sits idle). let forward: Awaited>; let forwardAuthError: Response | undefined; - if (candidates.forwardCandidates.length > 0) { + if (canUseOpenAiForward) { try { forward = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, req.headers, config); if (forward) logCtx.provider = formatCodexProviderForLog(forward.providerName, codexLogAccountId(forward.authContext), config); @@ -116,8 +403,12 @@ export async function handleImages( // The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`). url = `${provider.baseUrl}/images/${endpoint}`; } else if (forwardAuthError) { - // A configured OpenAI pool mode owns its authentication failure. Do not hide a - // broken/expired pool behind separately billed API-key image generation. + // Before surfacing the OpenAI auth failure, try CCA — the user may have a + // valid Google Antigravity login even though their OpenAI pool is broken. + const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); + if (ccaResponse) return ccaResponse; + // No CCA either: a configured OpenAI pool mode owns its authentication failure. + // Do not hide a broken/expired pool behind separately billed API-key image generation. return forwardAuthError; } else if (candidates.keyed) { const { provider, apiKey, providerName } = candidates.keyed; @@ -127,6 +418,9 @@ export async function handleImages( // Keyed providers tolerate baseUrl with or without /v1 (mirrors openai-responses.ts). url = `${provider.baseUrl.replace(/\/v1\/?$/, "")}/v1/images/${endpoint}`; } else { + // No usable OpenAI credential — try CCA before giving up. + const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); + if (ccaResponse) return ccaResponse; return formatErrorResponse( 401, "authentication_error", diff --git a/src/server/index.ts b/src/server/index.ts index 05c9b2b4f..2a2d36c7f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -498,6 +498,36 @@ export function startServer(port?: number) { return withCors(response, req, config); } + if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) { + const apiAuthError = requireApiAuth(req, config, "data-plane"); + if (apiAuthError) return withCors(apiAuthError, req, config); + if (!isAllowedRequestOrigin(req, config)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config); + } + const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length)); + const { resolveArtifactPath } = await import("../images/artifacts"); + const artifactPath = resolveArtifactPath(id); + if (!artifactPath) { + return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, config); + } + const file = Bun.file(artifactPath); + const ext = artifactPath.split(".").pop()?.toLowerCase(); + const contentType = + ext === "png" ? "image/png" + : ext === "jpg" || ext === "jpeg" ? "image/jpeg" + : ext === "webp" ? "image/webp" + : ext === "gif" ? "image/gif" + : "application/octet-stream"; + return withCors(new Response(file, { + status: 200, + headers: { + "content-type": contentType, + "cache-control": "private, max-age=3600", + "x-content-type-options": "nosniff", + }, + }), req, config); + } + if (url.pathname === "/v1/alpha/search" && req.method === "POST") { disableResponsesRequestTimeout(req, requestServer); if (isDraining()) { diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 228a66038..179e8a1bc 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests } from "../src/codex/catalog"; +import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, shouldExposeRoutedModel } from "../src/codex/catalog"; import { CURSOR_STATIC_MODELS, filterCursorConfiguredModelsByLiveDiscovery, @@ -2671,6 +2671,33 @@ describe("media-generation model filtering", () => { }); }); +describe("shouldExposeRoutedModel — Gemini image-capable exemption", () => { + test("exposes gemini-3.1-flash-image (image-capable chat model, not media-gen)", () => { + expect(shouldExposeRoutedModel({ provider: "google-antigravity", id: "gemini-3.1-flash-image" })).toBe(true); + }); + + test("exposes cursor gemini-3-pro-image-preview", () => { + expect(shouldExposeRoutedModel({ provider: "cursor", id: "gemini-3-pro-image-preview" })).toBe(true); + }); + + test("does not resurrect standalone media-gen gemini image ids", () => { + expect(shouldExposeRoutedModel({ provider: "google-antigravity", id: "gemini-3-pro-image" })).toBe(false); + expect(shouldExposeRoutedModel({ provider: "openrouter", id: "gemini-3-pro-image" })).toBe(false); + }); + + test("still filters true media-generation models", () => { + for (const id of [ + "grok-2-image", "gpt-image-1", "dall-e-3", "imagen-4", "sora-2", "veo-3", "flux", + ]) { + expect(shouldExposeRoutedModel({ provider: "openrouter", id })).toBe(false); + } + }); + + test("still filters compatibility-excluded slugs", () => { + expect(shouldExposeRoutedModel({ provider: "opencode-go", id: "hy3-preview" })).toBe(false); + }); +}); + describe("Codex reasoning-effort capability clamp", () => { function bundledCatalogDeps(efforts: string[]) { return { diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 65ea30564..148e40650 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -72,6 +72,7 @@ describe("antigravity CCA envelope", () => { expect(ANTIGRAVITY_MODELS).toEqual([ "gemini-3.6-flash", "gemini-3.1-pro", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index bec8e6fad..864046188 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -251,6 +251,32 @@ describe("google provider hardening", () => { } }); + test("non-streaming responses reject oversized Content-Length before buffering", async () => { + const adapter = createGoogleAdapter(provider()); + const oversized = new Response("{}", { + status: 200, + headers: { "content-length": String(101 * 1024 * 1024) }, + }); + + const events = await adapter.parseResponse!(oversized); + + expect(events).toEqual([{ type: "error", message: expect.stringContaining("google response too large") }]); + expect(events[0].type).toBe("error"); + }); + + test("non-streaming responses accept Content-Length under the cap", async () => { + const adapter = createGoogleAdapter(provider()); + const body = { candidates: [{ content: { parts: [{ text: "ok" }] }, finishReason: "STOP" }] }; + const response = new Response(JSON.stringify(body), { + status: 200, + headers: { "content-length": String(JSON.stringify(body).length) }, + }); + + const events = await adapter.parseResponse!(response); + expect(events.some(e => e.type === "done")).toBe(true); + expect(events.some(e => e.type === "error")).toBe(false); + }); + test("sends Gemini Flash thinkingLevel only for direct AI Studio requests", async () => { const direct = createGoogleAdapter(provider({ modelReasoningEfforts: { diff --git a/tests/images/gemini-inline.test.ts b/tests/images/gemini-inline.test.ts new file mode 100644 index 000000000..22be6d321 --- /dev/null +++ b/tests/images/gemini-inline.test.ts @@ -0,0 +1,388 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { createImageBudget, guessExtFromMagic, materializeInlineImage } from "../../src/images/artifacts"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { createGoogleAdapter } from "../../src/adapters/google"; +import type { AdapterEvent, OcxProviderConfig } from "../../src/types"; + +let tempHome: string; +let artifactsDir: string; +let savedHome: string | undefined; + +beforeAll(() => { + savedHome = process.env.OPENCODEX_HOME; + tempHome = mkdtempSync(join(tmpdir(), "ocx-test-")); + process.env.OPENCODEX_HOME = tempHome; + artifactsDir = join(tempHome, "artifacts"); +}); + +afterAll(() => { + if (savedHome !== undefined) process.env.OPENCODEX_HOME = savedHome; + else delete process.env.OPENCODEX_HOME; + rmSync(tempHome, { recursive: true, force: true }); +}); + +// 1x1 red PNG pixel in base64 (real PNG magic bytes: 89 50 4E 47) +const TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="; + +// Minimal JPEG byte stream (magic bytes FF D8 FF E0 ...) encoded as base64. +const TINY_JPEG = "/9j/4AAQ"; + +function sseResponse(chunks: unknown[]): Response { + const body = chunks.map(c => `data: ${JSON.stringify(c)}\n`).join("\n") + "\n"; + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +function jsonResponse(obj: unknown): Response { + return new Response(JSON.stringify(obj), { status: 200, headers: { "content-type": "application/json" } }); +} + +async function collectStream(provider: OcxProviderConfig, chunks: unknown[]): Promise { + const adapter = createGoogleAdapter(provider); + const events: AdapterEvent[] = []; + for await (const ev of adapter.parseStream(sseResponse(chunks))) events.push(ev); + return events; +} + +const aiStudioProvider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" } as OcxProviderConfig; + +describe("guessExtFromMagic", () => { + test("PNG magic bytes → png extension", () => { + // PNG file header: 89 50 4E 47 0D 0A 1A 0A + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect(guessExtFromMagic(pngBytes)).toBe("png"); + }); + + test("JPEG magic bytes → jpg extension", () => { + const jpgBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + expect(guessExtFromMagic(jpgBytes)).toBe("jpg"); + }); + + test("WebP magic bytes → webp extension", () => { + const webpBytes = Buffer.from("RIFF\x00\x00\x00\x00WEBP", "latin1"); + expect(guessExtFromMagic(webpBytes)).toBe("webp"); + }); + + test("GIF magic bytes → gif extension", () => { + const gifBytes = Buffer.from("GIF89a", "latin1"); + expect(guessExtFromMagic(gifBytes)).toBe("gif"); + }); + + test("unrecognized magic bytes throw (no silent 'png' fallback)", () => { + // Empty buffer and random bytes previously fell back to "png"; now they must throw. + expect(() => guessExtFromMagic(new Uint8Array())).toThrow("unrecognized image format"); + expect(() => guessExtFromMagic(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toThrow("unrecognized image format"); + }); + + test("truncated PNG signature (first 4 bytes only) is rejected", () => { + // Regression for Wibias R4 finding 4: \x89PNG is only the first 4 bytes of the + // 8-byte PNG signature (89 50 4E 47 0D 0A 1A 0A). The old startsWith("\x89PNG") + // accepted malformed data with a truncated/fake signature. The full 8-byte + // signature must now be validated. + const truncated = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x00, 0x00]); + expect(() => guessExtFromMagic(truncated)).toThrow("unrecognized image format"); + // First 4 bytes matching but followed by non-PNG data is also rejected. + const fake = Buffer.from("\x89PNGXXXX", "latin1"); + expect(() => guessExtFromMagic(fake)).toThrow("unrecognized image format"); + }); +}); + +describe("CCA image endpoint registry pinning (token-exfiltration guard)", () => { + test("google-antigravity registry baseUrl is the official CCA endpoint", () => { + // tryCcaImageGeneration must derive the upstream URL from the registry, not from + // a config-level baseUrl override, so a tampered baseUrl cannot redirect the + // Google OAuth bearer token to an attacker-controlled host. + const entry = getProviderRegistryEntry("google-antigravity"); + expect(entry).toBeDefined(); + expect(entry!.baseUrl).toBe("https://daily-cloudcode-pa.googleapis.com"); + }); + + test("the pinned URL is never an attacker-controlled host", () => { + // Even if a caller mutates a provider config to point baseUrl at evil.example, + // the image relay ignores it: the registry entry is the source of truth. + const evilUrl = "https://evil.example.com"; + const pinned = getProviderRegistryEntry("google-antigravity")?.baseUrl; + expect(pinned).not.toBe(evilUrl); + expect(pinned).toBe("https://daily-cloudcode-pa.googleapis.com"); + }); +}); + +describe("materializeInlineImage", () => { + test("writes a file and returns an absolute path", async () => { + const result = await materializeInlineImage(TINY_PNG); + expect(isAbsolute(result)).toBe(true); + expect(existsSync(result)).toBe(true); + const buf = readFileSync(result); + expect(buf.length).toBeGreaterThan(0); + }); + + test("extension follows real bytes, not an upstream MIME label", async () => { + // TINY_PNG carries genuine PNG magic bytes → always .png regardless of label. + expect((await materializeInlineImage(TINY_PNG)).endsWith(".png")).toBe(true); + // TINY_JPEG carries genuine JPEG magic bytes → always .jpg. + expect((await materializeInlineImage(TINY_JPEG)).endsWith(".jpg")).toBe(true); + }); + + test("spoofed MIME: JPEG bytes that upstream declared as image/png → saved as .jpg", async () => { + // Before the magic-byte fix, an upstream "image/png" label forced a .png name + // even when the bytes were JPEG. The extension now comes from the actual bytes. + const path = await materializeInlineImage(TINY_JPEG); + expect(path.endsWith(".jpg")).toBe(true); + }); + + test("creates the artifacts directory if missing", async () => { + rmSync(artifactsDir, { recursive: true, force: true }); + expect(existsSync(artifactsDir)).toBe(false); + const result = await materializeInlineImage(TINY_PNG); + expect(existsSync(artifactsDir)).toBe(true); + expect(existsSync(result)).toBe(true); + }); + + test("produces unique filenames for same-millisecond calls", async () => { + const a = await materializeInlineImage(TINY_PNG); + const b = await materializeInlineImage(TINY_PNG); + expect(a).not.toBe(b); + expect(existsSync(a)).toBe(true); + expect(existsSync(b)).toBe(true); + }); + + test("throws on empty base64 data", async () => { + await expect(materializeInlineImage("")).rejects.toThrow("empty"); + }); + + test("throws on malformed nonempty base64 data", async () => { + await expect(materializeInlineImage("abc!")).rejects.toThrow("not valid base64"); + await expect(materializeInlineImage("abc")).rejects.toThrow("not valid base64"); + }); + + test("enforces per-response budget", async () => { + const budget = createImageBudget(); + budget.spent = 100 * 1024 * 1024; // already at cap + await expect(materializeInlineImage(TINY_PNG, budget)).rejects.toThrow("per-response"); + }); +}); + +describe("google adapter — inline image streaming", () => { + test("yields markdown text_delta when a chunk contains inlineData", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + expect(textEvents[0].text).toMatch(/^\n!\[image\]\(.+\.png\)\n$/); + expect(events.some(e => e.type === "done")).toBe(true); + }); + + test("behaves unchanged when no inlineData is present (regression)", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ text: "hello world" }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 2 } }, + ]); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + expect(textEvents[0].text).toBe("hello world"); + const done = events.find(e => e.type === "done") as Extract; + expect(done.usage?.inputTokens).toBe(3); + expect(done.usage?.outputTokens).toBe(2); + }); + + test("empty inlineData.data yields an error event but does not abort the stream", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "" } }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + expect(events.some(e => e.type === "error" && /materialize/.test(e.message))).toBe(true); + // Stream still reaches a terminal done event. + expect(events.some(e => e.type === "done")).toBe(true); + }); +}); + +describe("google adapter — inline image non-streaming", () => { + test("parseResponse returns markdown text for inlineData parts", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ text: "Here is a cat:" }, { inlineData: { mimeType: "image/jpeg", data: TINY_JPEG } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 10 }, + })); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(2); + expect(textEvents[0].text).toBe("Here is a cat:"); + expect(textEvents[1].text).toMatch(/^\n!\[image\]\(.+\.jpg\)\n$/); + }); + + test("empty inlineData.data yields an error event, not a rejection", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "" } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + })); + expect(events.some(e => e.type === "error" && /materialize/.test(e.message))).toBe(true); + }); +}); + +describe("responseModalities gating", () => { + test("image-capable model gets responseModalities in compiled wire body", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const req = await adapter.buildRequest({ + context: { messages: [], tools: [] }, + options: {}, + modelId: "gemini-3.1-flash-image", + stream: false, + } as never); + const body = JSON.parse(req.body); + expect(body.generationConfig.responseModalities).toEqual(["TEXT", "IMAGE"]); + }); + + test("non-image model does NOT get responseModalities", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const req = await adapter.buildRequest({ + context: { messages: [], tools: [] }, + options: {}, + modelId: "gemini-3.6-flash", + stream: false, + } as never); + const body = JSON.parse(req.body); + expect(body.generationConfig).toBeUndefined(); + }); + + test("Imagen models do NOT get responseModalities (different API schema)", async () => { + // imagen-* uses the prediction/image-generation endpoint, not + // responseModalities — listing it here would send a Gemini image-capable + // request to a model that cannot handle it. + const adapter = createGoogleAdapter(aiStudioProvider); + const req = await adapter.buildRequest({ + context: { messages: [], tools: [] }, + options: {}, + modelId: "imagen-4.0-generate-001", + stream: false, + } as never); + const body = JSON.parse(req.body); + expect(body.generationConfig?.responseModalities).toBeUndefined(); + }); +}); + +describe("markdown emits authenticated opaque artifact URLs", () => { + test("streaming: markdown uses /v1/opencodex/artifacts/, not file: or host paths", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); + expect(match).not.toBeNull(); + const mdPath = match![1]; + expect(mdPath.startsWith("/v1/opencodex/artifacts/")).toBe(true); + expect(mdPath).not.toContain("file:"); + expect(mdPath).not.toContain("~/"); + expect(mdPath).not.toMatch(/[A-Za-z]:\\/); + expect(mdPath).not.toContain(tempHome); + const id = mdPath.slice("/v1/opencodex/artifacts/".length); + expect(existsSync(join(artifactsDir, id))).toBe(true); + }); + + test("non-streaming: markdown uses /v1/opencodex/artifacts/, not file: or host paths", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/webp", data: TINY_PNG } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 2, candidatesTokenCount: 3 }, + })); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); + expect(match).not.toBeNull(); + const mdPath = match![1]; + expect(mdPath.startsWith("/v1/opencodex/artifacts/")).toBe(true); + expect(mdPath).not.toContain("file:"); + expect(mdPath).not.toContain(tempHome); + const id = mdPath.slice("/v1/opencodex/artifacts/".length); + expect(existsSync(join(artifactsDir, id))).toBe(true); + }); +}); + +describe("artifact markdown never leaks OPENCODEX_HOME paths", () => { + let underHome: string; + let savedHome: string | undefined; + + beforeAll(() => { + savedHome = process.env.OPENCODEX_HOME; + underHome = mkdtempSync(join(homedir(), ".ocx-test-leak-")); + process.env.OPENCODEX_HOME = underHome; + }); + + afterAll(() => { + if (savedHome !== undefined) process.env.OPENCODEX_HOME = savedHome; + else delete process.env.OPENCODEX_HOME; + rmSync(underHome, { recursive: true, force: true }); + }); + + test("streaming: no home/username path segments in markdown", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const md = textEvents[0].text; + expect(md).toContain("/v1/opencodex/artifacts/"); + expect(md).not.toContain("file:"); + expect(md).not.toContain("~/"); + expect(md).not.toContain(underHome); + expect(md).not.toContain(homedir()); + }); + + test("non-streaming: no home/username path segments in markdown", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + })); + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const md = textEvents[0].text; + expect(md).toContain("/v1/opencodex/artifacts/"); + expect(md).not.toContain("file:"); + expect(md).not.toContain(underHome); + }); +}); + +describe("malformed inlineData does not abort the stream", () => { + test("streaming: sibling text + bad inline yields text_delta AND error event", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [ + { text: "before image" }, + { inlineData: { mimeType: "image/png", data: "!!!not-base64!!!" } }, + ] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + // The text part before the bad image is still emitted. + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.some(e => e.text === "before image")).toBe(true); + // An error event is emitted for the bad image. + expect(events.some(e => e.type === "error" && /materialize/.test(e.message))).toBe(true); + // The stream terminates normally. + expect(events.some(e => e.type === "done")).toBe(true); + }); + + test("non-streaming: sibling text + bad inline yields text_delta AND error event", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [ + { text: "hello" }, + { inlineData: { mimeType: "image/png", data: "!!!not-base64!!!" } }, + ] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + })); + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.some(e => e.text === "hello")).toBe(true); + expect(events.some(e => e.type === "error" && /materialize/.test(e.message))).toBe(true); + }); +}); diff --git a/tests/oauth-provider-reconcile.test.ts b/tests/oauth-provider-reconcile.test.ts index c9bc5272c..246755268 100644 --- a/tests/oauth-provider-reconcile.test.ts +++ b/tests/oauth-provider-reconcile.test.ts @@ -51,6 +51,7 @@ describe("OAuth provider reconciliation", () => { expect(provider.models).toEqual([ "gemini-3.6-flash", "gemini-3.1-pro", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 9dfa5e5e0..95a5d3c04 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -608,7 +608,8 @@ describe("provider registry parity", () => { expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("claude-sonnet-4-6"); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("claude-opus-4-6-thinking"); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gpt-oss-120b-medium"); - expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toHaveLength(5); + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gemini-3.1-flash-image"); + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toHaveLength(6); // Effort ladders on collapsed base models. expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["gemini-3.6-flash"]).toEqual(["low", "medium", "high"]); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["gemini-3.1-pro"]).toEqual(["low", "high"]); diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index ee24640e0..437c76c25 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -12,7 +12,9 @@ import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../src/codex/ro import { saveConfig } from "../src/config"; import { selectImagesProvider } from "../src/providers/openai-sidecar"; import { startServer } from "../src/server"; +import { saveCredential } from "../src/oauth/store"; import type { OcxConfig } from "../src/types"; +import { ANTIGRAVITY_REQUEST_UA } from "../src/adapters/google-antigravity-wire"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -821,16 +823,1049 @@ test("the proxy admission secret is never relayed to the forward upstream", asyn const server = startServer(0); try { // Authorization carries the proxy's OWN admission token — it authenticates the caller to the - // proxy, but must be stripped before upstream selection (else it would leak to chatgpt.com). + // proxy, but must never be forwarded as ChatGPT credentials. OpenAI forward is skipped; a + // configured keyed provider may still serve the request with its own apiKey. const response = await fetch(`http://127.0.0.1:${server.port}/v1/images/generations`, { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer local-secret" }, body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), }); - expect(response.status).toBe(401); - expect(captured).toHaveLength(0); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0].headers.get("authorization")).toBe("Bearer sk-platform-key"); + expect([...captured[0].headers.values()].some(v => v.includes("local-secret"))).toBe(false); } finally { await server.stop(true); await upstream.stop(true); } }); + +// ── Google Antigravity (CCA) image generation fallback ── + +/** + * CCA config for image tests. The config-level baseUrl is deliberately set to an + * attacker host to prove the CCA path pins to the registry entry + * (daily-cloudcode-pa.googleapis.com) and ignores this override. The OAuth token + * comes from the credential store via getValidAccessToken, not from config apiKey. + */ +function ccaConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "google-antigravity", + openaiProviderTierVersion: 2, + providers: { + openai: disabledOpenAiProvider, + "google-antigravity": { + adapter: "google", + baseUrl: "https://attacker.example.com", + googleMode: "cloud-code-assist", + } as OcxConfig["providers"][string], + }, + } as OcxConfig; +} + +interface CcaFetchRequest { + url: string; + headers: Headers; + body: unknown; +} + +const CCA_TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="; + +/** + * Stub globalThis.fetch for CCA image tests: requests to the registry host + * (daily-cloudcode-pa.googleapis.com) get a canned response and are recorded in + * `registryHits`; requests to any other non-localhost host are recorded in + * `otherHits` (to prove the attacker host is never contacted); localhost requests + * pass through to the real network stack (the test proxy server). + */ +function ccaFetchMock( + registryHits: CcaFetchRequest[], + otherHits: CcaFetchRequest[], + response?: { status?: number; payload?: unknown }, +) { + const status = response?.status ?? 200; + const payload = response?.payload ?? { + response: { + candidates: [{ + content: { parts: [{ inlineData: { mimeType: "image/png", data: CCA_TINY_PNG } }] }, + }], + }, + }; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + const headers = new Headers(init?.headers); + let parsedBody: unknown; + if (init?.body && typeof init.body === "string") { + try { parsedBody = JSON.parse(init.body); } catch { /* non-JSON body */ } + } + if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + registryHits.push({ url: requestUrl, headers, body: parsedBody }); + return Response.json(payload, { status }); + } + if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") { + otherHits.push({ url: requestUrl, headers, body: parsedBody }); + } + return originalFetch(input, init); + }) as typeof fetch; +} + +const CCA_CREDENTIAL = { + access: "cca-access-token", + refresh: "cca-refresh-token", + expires: Date.now() + 3_600_000, + projectId: "cca-project-123", +} as const; + +test("CCA image fallback generates images via Google Antigravity when no OpenAI upstream exists", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a neon cat", model: "gpt-image-2" }), + }); + expect(response.status).toBe(200); + const json = await response.json() as { data: { b64_json: string }[] }; + expect(json.data).toHaveLength(1); + expect(json.data[0].b64_json).toBe(CCA_TINY_PNG); + + // The CCA call MUST hit the registry host, not the config-level baseUrl. + expect(registryHits).toHaveLength(1); + expect(registryHits[0].url).toContain("daily-cloudcode-pa.googleapis.com"); + expect(registryHits[0].url).toContain("generateContent"); + const body = registryHits[0].body as { model?: string; request?: { generationConfig?: { responseModalities?: string[] } } }; + expect(body.model).toBe("gemini-3.1-flash-image"); + expect(body.request?.generationConfig?.responseModalities).toEqual(["TEXT", "IMAGE"]); + expect(registryHits[0].headers.get("authorization")).toBe("Bearer cca-access-token"); + // The CCA image request must use the shared Antigravity User-Agent (not a + // bespoke "opencodex-images/1.0"), so the request fingerprint matches the + // OAuth credential. + expect(registryHits[0].headers.get("user-agent")).toBe(ANTIGRAVITY_REQUEST_UA); + + // The attacker host (config baseUrl) must NOT receive any request. + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA image fallback preserves upstream 429 status", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { status: 429, payload: { error: { message: "Rate limited" } } }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(429); + // The registry host was hit, not the attacker host. + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA fallback does not serve image edits", async () => { + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/edits", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "edit this", model: "gpt-image-2" }), + }); + // Edits should NOT hit the CCA fallback — it's text-to-image only. + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("image generation"); + } finally { + await server.stop(true); + } +}); + +test("CCA image fallback never sends Authorization to a tampered config baseUrl (sink-host regression)", async () => { + const registryHits: CcaFetchRequest[] = []; + const attackerHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, attackerHits); + + // ccaConfig already sets baseUrl to https://attacker.example.com — if the pin + // were ever removed, this host would receive the OAuth bearer token. + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(200); + + // The registry host received the request with the OAuth bearer token. + expect(registryHits).toHaveLength(1); + expect(registryHits[0].url).toContain("daily-cloudcode-pa.googleapis.com"); + expect(registryHits[0].headers.get("authorization")).toBe("Bearer cca-access-token"); + + // The attacker host received ZERO requests — no Authorization header leak. + const authLeak = attackerHits.filter(r => r.headers.get("authorization")); + expect(attackerHits).toHaveLength(0); + expect(authLeak).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA image fallback rejects an empty prompt with 400 before any OAuth work", async () => { + saveConfig(ccaConfig()); + // Deliberately do NOT save a google-antigravity credential: if the prompt + // check did not fire first, getValidAccessToken would throw, and the request + // would fall through to the misleading "no provider configured" 400 — not the + // "prompt is required" message asserted below. + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "", model: "gpt-image-2" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("prompt is required"); + } finally { + await server.stop(true); + } +}); + +test("CCA image fallback rejects a whitespace-only prompt with 400", async () => { + saveConfig(ccaConfig()); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: " ", model: "gpt-image-2" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("prompt is required"); + } finally { + await server.stop(true); + } +}); + +test("CCA fallback serves images when OpenAI forward auth fails but Google Antigravity is logged in", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits); + + // OpenAI forward provider in pool mode, but pool-a has NO stored credential → + // forward auth resolution throws CodexAuthContextError. Without the CCA + // fallback the user gets a 401 even though they have a valid Google login. + saveConfig({ + port: 0, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { ...canonicalOpenAiProvider, codexAccountMode: "pool" }, + "google-antigravity": { + adapter: "google", + baseUrl: "https://attacker.example.com", + googleMode: "cloud-code-assist", + } as OcxConfig["providers"][string], + }, + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "pool@example.test", isMain: false, chatgptAccountId: "acct-pool-a" }, + ], + activeCodexAccountId: "pool-a", + } as OcxConfig); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer caller-token" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + // OpenAI forward auth failed, but CCA picked up the slack. + expect(response.status).toBe(200); + const json = await response.json() as { data: { b64_json: string }[] }; + expect(json.data).toHaveLength(1); + expect(json.data[0].b64_json).toBe(CCA_TINY_PNG); + + // CCA was called on the registry host, not the attacker host. + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA OAuth no credential saved returns 401 (login required), not a misleading 502/400", async () => { + saveConfig(ccaConfig()); + // Deliberately do NOT save a google-antigravity credential. The provider IS + // configured, but getValidAccessToken will throw OAuthLoginRequiredError. + // This must surface as a 401 "login required" — NOT 502 (which implies a + // transient refresh/network failure) or the permanent 400 "none configured" + // message that implies the user forgot to add a provider. + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat", model: "gpt-image-2" }), + }); + expect(response.status).toBe(401); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("login required"); + } finally { + await server.stop(true); + } +}); + +test("CCA fetch network failure returns 502 without leaking the timeout timer", async () => { + // Mock: CCA fetch always fails with a network error. The bug was that the + // fetch catch returned 502 without calling linkedSignal.cleanup(), leaving + // the timeout timer alive. With a short timeout this would keep the process + // alive. The fix wraps everything in try/finally so cleanup always runs. + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + throw new TypeError("fetch failed: connection refused"); + } + return originalFetch(input, init); + }) as typeof fetch; + + saveConfig({ ...ccaConfig(), images: { timeoutMs: 10_000 } } as OcxConfig); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("CCA image generation failed"); + } finally { + await server.stop(true); + } +}, 5_000); + +test("CCA body-read timeout returns 504 when upstream stalls after sending headers", async () => { + // Mock: CCA returns 200 OK headers immediately but the body stream never + // produces data. The linked signal's timeout aborts reader.read(), which + // must be caught and mapped to 504. The abort surfaces as a generic + // AbortError (not TimeoutError) — just like in production Bun — so the + // signal-state check (linkedSignal.signal.aborted) is what maps it, not + // err.name matching. + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + const fetchSignal = init?.signal; + const stalledBody = new ReadableStream({ + start(controller) { + // Never produce data — stall until the fetch signal aborts, then + // error the stream as AbortError (the typical rejection Bun's + // stream layer produces on linked-signal abort, NOT TimeoutError). + const abortError = new DOMException("The operation was aborted.", "AbortError"); + if (fetchSignal) { + if (fetchSignal.aborted) { + controller.error(abortError); + } else { + fetchSignal.addEventListener("abort", () => controller.error(abortError), { once: true }); + } + } + }, + }); + return new Response(stalledBody, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + saveConfig({ ...ccaConfig(), images: { timeoutMs: 100 } } as OcxConfig); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(504); + const json = await response.json() as { error: { message: string } }; + // Either the body-read timeout message or the general timeout message. + expect(json.error.message).toMatch(/body read|timed out/i); + } finally { + await server.stop(true); + } +}, 5_000); + +test("CCA body-read client cancellation returns 499, not 504", async () => { + // Regression for Wibias R4 finding 1: when the client aborts during the body-read + // phase, both the parent signal and the linked signal are aborted (parent abort + // propagates into the linked signal). The body-read catch must check the PARENT + // signal first (499) before the linked signal (504), otherwise client cancellation + // is misreported as an upstream timeout. + // + // We call handleImages directly (not via server fetch) because a client-side + // fetch abort tears down the connection before the server response can be read. + const { handleImages } = await import("../src/server/images"); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "daily-cloudcode-pa.googleapis.com") { + const fetchSignal = init?.signal; + const stalledBody = new ReadableStream({ + start(controller) { + const abortError = new DOMException("The operation was aborted.", "AbortError"); + if (fetchSignal) { + if (fetchSignal.aborted) controller.error(abortError); + else fetchSignal.addEventListener("abort", () => controller.error(abortError), { once: true }); + } + }, + }); + return new Response(stalledBody, { status: 200, headers: { "content-type": "application/json" } }); + } + return originalFetch(input, init); + }) as typeof fetch; + + // Use a long timeout so the deadline does NOT fire — only the client abort triggers. + const cfg = { ...ccaConfig(), images: { timeoutMs: 30_000 } } as OcxConfig; + saveConfig(cfg); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const ctrl = new AbortController(); + const req = new Request("http://localhost:0/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + signal: ctrl.signal, + }); + const logCtx = { model: "", provider: "" } as never; + + // Start the handler — it enters the body-read loop and stalls on the mocked body. + const responsePromise = handleImages(req, cfg, "generations", logCtx); + // Abort the parent signal after the body read has started. + setTimeout(() => ctrl.abort(), 100); + const response = await responsePromise; + expect(response.status).toBe(499); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("canceled"); +}, 5_000); + +test("CCA image fallback preserves upstream 400 (not collapsed to 502)", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { status: 400, payload: { error: { message: "Invalid prompt content" } } }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + // 400 must be forwarded, not collapsed to 502. + expect(response.status).toBe(400); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA image response with malformed inlineData.data (non-string) returns 502, not fake image data", async () => { + // Regression for codex1-malformed-inlinedata: inlineData.data that is not a + // non-empty string (e.g. a number, object, or empty string) used to pass the + // truthiness check and was forwarded as a fake b64_json. Now it must be + // silently skipped, and when no valid images remain the response is 502. + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + content: { parts: [ + { inlineData: { mimeType: "image/png", data: 12345 } }, // number + { inlineData: { mimeType: "image/png", data: { foo: "bar" } } }, // object + { inlineData: { mimeType: "image/png", data: "" } }, // empty string + { inlineData: { mimeType: "image/png", data: null } }, // null + ] }, + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("no image data"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA image response skips malformed inlineData.data but keeps valid string image", async () => { + // When some parts have non-string data and at least one has a valid non-empty + // string, the valid image is extracted and the malformed ones are skipped. + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + content: { parts: [ + { inlineData: { mimeType: "image/png", data: 42 } }, + { inlineData: { mimeType: "image/png", data: CCA_TINY_PNG } }, + { inlineData: { mimeType: "image/png", data: "" } }, + ] }, + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(200); + const json = await response.json() as { data: { b64_json: string }[] }; + expect(json.data).toHaveLength(1); + expect(json.data[0].b64_json).toBe(CCA_TINY_PNG); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA image response with non-array parts returns 502 (envelope validation)", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + // A truthy but non-array parts object would throw inside for...of before the + // Array.isArray guard was added. It must be caught and surfaced as 502. + ccaFetchMock(registryHits, otherHits, { + payload: { response: { candidates: [{ content: { parts: "not-an-array" } }] } }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("no valid parts array"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +// ── OAuth preflight cancellation (finding2-oauth-signal) ── +// getValidAccessToken chains through 4 layers of non-cancellable OAuth functions. +// The fix wraps it in abortableRace against linkedSignal so client cancellation +// and deadline expiry are surfaced promptly instead of hanging on the refresh. + +/** Expired credential that forces getValidAccessToken to trigger a token refresh. */ +const CCA_CREDENTIAL_EXPIRED = { + access: "cca-expired-token", + refresh: "cca-refresh-token", + expires: Date.now() - 60_000, + projectId: "cca-project-123", +} as const; + +/** + * Mock fetch so the Google OAuth token endpoint (oauth2.googleapis.com/token) + * hangs indefinitely — simulating a hung OAuth refresh. The registry host and + * all other hosts pass through to the real network stack (they should never be + * reached during these tests). + */ +function hungOauthFetchMock() { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "oauth2.googleapis.com") { + // Never resolve until the fetch signal aborts (just like production). + return new Promise((_resolve, reject) => { + const sig = init?.signal; + if (sig) { + if (sig.aborted) reject(new DOMException("The operation was aborted.", "AbortError")); + else sig.addEventListener("abort", () => reject(new DOMException("The operation was aborted.", "AbortError")), { once: true }); + } + }); + } + return originalFetch(input, init); + }) as typeof fetch; +} + +test("CCA client abort during OAuth preflight returns 499, not a hung response", async () => { + // Regression for finding2-oauth-signal: when the client disconnects while + // getValidAccessToken is refreshing the token, the request must return 499 + // promptly, not hang waiting for the refresh HTTP call to complete. + const { handleImages } = await import("../src/server/images"); + hungOauthFetchMock(); + + // Long timeout so the deadline does NOT fire — only the client abort triggers. + const cfg = { ...ccaConfig(), images: { timeoutMs: 30_000 } } as OcxConfig; + saveConfig(cfg); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL_EXPIRED }); + + const ctrl = new AbortController(); + const req = new Request("http://localhost:0/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + signal: ctrl.signal, + }); + const logCtx = { model: "", provider: "" } as never; + + // Start the handler — it enters getValidAccessToken which hangs on the token refresh. + const responsePromise = handleImages(req, cfg, "generations", logCtx); + // Abort the parent signal after the OAuth preflight has started. + setTimeout(() => ctrl.abort(), 100); + const response = await responsePromise; + expect(response.status).toBe(499); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("canceled"); +}, 5_000); + +test("CCA deadline expiry during OAuth preflight returns 504, not a hung response", async () => { + // Regression for finding2-oauth-signal: when the deadline fires while + // getValidAccessToken is refreshing the token, the request must return 504 + // promptly, not hang waiting for the refresh HTTP call to complete. + const { handleImages } = await import("../src/server/images"); + hungOauthFetchMock(); + + // Short timeout so the deadline fires during the hung OAuth refresh. + const cfg = { ...ccaConfig(), images: { timeoutMs: 100 } } as OcxConfig; + saveConfig(cfg); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL_EXPIRED }); + + const req = new Request("http://localhost:0/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + const logCtx = { model: "", provider: "" } as never; + + const response = await handleImages(req, cfg, "generations", logCtx); + expect(response.status).toBe(504); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toContain("timed out"); +}, 5_000); + +// ── codex4-cca-safety-blocks ── +// Safety blocks are permanent for the same prompt. Returning 502 (upstream_error) +// causes codex to retry up to 5 times, wasting paid quota on a prompt that will +// never succeed. These must return 400 (invalid_request_error, non-retryable). + +test("CCA finishReason SAFETY returns 400 (non-retryable), not 502", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + content: { parts: [] }, + finishReason: "SAFETY", + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { type: string; message: string } }; + expect(json.error.type).toBe("invalid_request_error"); + expect(json.error.message).toContain("safety filter"); + expect(json.error.message).toContain("SAFETY"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA promptFeedback.blockReason returns 400 (non-retryable)", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + promptFeedback: { blockReason: "SAFETY" }, + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { type: string; message: string } }; + expect(json.error.type).toBe("invalid_request_error"); + expect(json.error.message).toContain("safety filter"); + expect(json.error.message).toContain("promptFeedback"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA finishReason BLOCKLIST returns 400 (non-retryable)", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ finishReason: "BLOCKLIST" }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { type: string; message: string } }; + expect(json.error.type).toBe("invalid_request_error"); + expect(json.error.message).toContain("BLOCKLIST"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA blocked candidate with empty content.parts returns 400, not 502", async () => { + // When content is blocked, candidates may exist but content/parts is missing + // or empty. The safety check must fire before the "no valid parts" 502 path. + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + finishReason: "PROHIBITED_CONTENT", + // content is entirely absent — common when generation is blocked + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { type: string; message: string } }; + expect(json.error.type).toBe("invalid_request_error"); + expect(json.error.message).toContain("PROHIBITED_CONTENT"); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA finishReason STOP with valid image is not affected by safety block logic", async () => { + // Regression: a normal STOP finishReason with a valid inline image must still + // return 200 — the safety check must not false-positive on non-blocking reasons. + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + content: { parts: [{ inlineData: { mimeType: "image/png", data: CCA_TINY_PNG } }] }, + finishReason: "STOP", + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(200); + const json = await response.json() as { data: { b64_json: string }[] }; + expect(json.data).toHaveLength(1); + expect(json.data[0].b64_json).toBe(CCA_TINY_PNG); + expect(registryHits).toHaveLength(1); + expect(otherHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA-only request with proxy admission bearer succeeds and never sends it upstream", async () => { + process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-admission-secret"; + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer proxy-admission-secret", + }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(200); + expect(registryHits).toHaveLength(1); + expect([...registryHits[0].headers.values()].some(v => v.includes("proxy-admission-secret"))).toBe(false); + expect(registryHits[0].headers.get("authorization")).toBe("Bearer cca-access-token"); + } finally { + await server.stop(true); + delete process.env.OPENCODEX_API_AUTH_TOKEN; + } +}); + +test("CCA logged-in without projectId returns project-discovery error, not provider-missing 400", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits); + + saveConfig(ccaConfig()); + const { projectId: _omit, ...noProject } = CCA_CREDENTIAL; + await saveCredential("google-antigravity", { ...noProject }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toMatch(/Cloud Code Assist project/i); + expect(json.error.message).not.toMatch(/none is configured/i); + expect(registryHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA rejects n>1 before contacting Google", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat", n: 2 }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toMatch(/n=1/i); + expect(registryHits).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + +test("CCA RECITATION finishReason returns non-retryable 400", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ content: { parts: [] }, finishReason: "RECITATION" }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "copyrighted stuff" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toMatch(/RECITATION|safety/i); + } finally { + await server.stop(true); + } +}); + +test("CCA rejects invalid base64 and non-image bytes instead of returning b64_json", async () => { + const registryHits: CcaFetchRequest[] = []; + const otherHits: CcaFetchRequest[] = []; + ccaFetchMock(registryHits, otherHits, { + payload: { + response: { + candidates: [{ + content: { parts: [{ inlineData: { mimeType: "image/png", data: "aGVsbG8=" } }] }, + }], + }, + }, + }); + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + expect(response.status).toBe(502); + const json = await response.json() as { error: { message: string } }; + expect(json.error.message).toMatch(/base64|magic|validation/i); + } finally { + await server.stop(true); + } +}); + +test("GET /v1/opencodex/artifacts/:id serves opaque artifacts with API auth", async () => { + process.env.OPENCODEX_API_AUTH_TOKEN = "proxy-admission-secret"; + const { materializeInlineImage, createImageBudget, artifactHttpUrl } = await import("../src/images/artifacts"); + const filePath = await materializeInlineImage(CCA_TINY_PNG, createImageBudget()); + const urlPath = artifactHttpUrl(filePath); + + // Non-loopback bind makes data-plane auth mandatory (same as production remote binds). + saveConfig({ ...ccaConfig(), hostname: "0.0.0.0" }); + const server = startServer(0); + try { + const denied = await fetch(`http://127.0.0.1:${server.port}${urlPath}`); + expect(denied.status).toBe(401); + + const wrong = await fetch(`http://127.0.0.1:${server.port}${urlPath}`, { + headers: { authorization: "Bearer wrong-secret" }, + }); + expect(wrong.status).toBe(401); + + const ok = await fetch(`http://127.0.0.1:${server.port}${urlPath}`, { + headers: { authorization: "Bearer proxy-admission-secret" }, + }); + expect(ok.status).toBe(200); + expect(ok.headers.get("content-type")).toBe("image/png"); + const bytes = new Uint8Array(await ok.arrayBuffer()); + expect(bytes[0]).toBe(0x89); + expect(bytes[1]).toBe(0x50); + + const traversal = await fetch(`http://127.0.0.1:${server.port}/v1/opencodex/artifacts/../package.json`, { + headers: { authorization: "Bearer proxy-admission-secret" }, + }); + expect(traversal.status).toBe(404); + } finally { + await server.stop(true); + delete process.env.OPENCODEX_API_AUTH_TOKEN; + } +}); From de35caa4d899e68f42e1e54e95501c5917663103 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:48:33 +0200 Subject: [PATCH 020/129] feat(images): Grok image bridge (maintainer takeover of #424) (#577) Credit: @tizerluo for the original feature on #424. Folds stacked #528 hardening and maintainer review/security follow-ups. Reconciled with #355 artifact helpers on latest dev. Closes #424 Closes #528 --- .../content/docs/guides/codex-integration.md | 6 +- .../src/content/docs/guides/image-bridge.md | 95 +++ src/images/artifacts.ts | 247 ++++++- src/images/fulfill.ts | 111 +++ src/images/index.ts | 4 + src/images/loop.ts | 659 ++++++++++++++++++ src/images/plan.ts | 80 +++ src/images/synthetic-tool.ts | 88 +++ src/images/types.ts | 26 + src/images/xai-client.ts | 141 ++++ src/lib/destination-policy.ts | 68 +- src/responses/parser.ts | 11 + src/server/responses/core.ts | 221 ++++-- src/types.ts | 14 +- tests/api-storage-cleanup.test.ts | 4 +- tests/destination-policy-resolved.test.ts | 17 + tests/images/artifacts-prune.test.ts | 91 +++ tests/images/artifacts-ssrf.test.ts | 254 +++++++ tests/images/loop.test.ts | 578 +++++++++++++++ tests/images/pinned-https-get.test.ts | 321 +++++++++ tests/images/plan.test.ts | 189 +++++ tests/images/synthetic-tool.test.ts | 86 +++ tests/images/xai-client.test.ts | 161 +++++ tests/images/z-fulfill.test.ts | 251 +++++++ tests/images/z-handler-activation.test.ts | 214 ++++++ tests/kiro-review-regressions.test.ts | 2 +- tests/responses-parser.test.ts | 26 + tests/storage-cleanup.test.ts | 4 +- 28 files changed, 3865 insertions(+), 104 deletions(-) create mode 100644 docs-site/src/content/docs/guides/image-bridge.md create mode 100644 src/images/fulfill.ts create mode 100644 src/images/index.ts create mode 100644 src/images/loop.ts create mode 100644 src/images/plan.ts create mode 100644 src/images/synthetic-tool.ts create mode 100644 src/images/types.ts create mode 100644 src/images/xai-client.ts create mode 100644 tests/images/artifacts-prune.test.ts create mode 100644 tests/images/artifacts-ssrf.test.ts create mode 100644 tests/images/loop.test.ts create mode 100644 tests/images/pinned-https-get.test.ts create mode 100644 tests/images/plan.test.ts create mode 100644 tests/images/synthetic-tool.test.ts create mode 100644 tests/images/xai-client.test.ts create mode 100644 tests/images/z-fulfill.test.ts create mode 100644 tests/images/z-handler-activation.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index fab358a74..08cd90838 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -36,7 +36,11 @@ The proxy listens on port `10100` by default and serves `POST /v1/responses`, Codex's built-in `image_gen` tool does not go through `/v1/responses` — the codex-rs extension POSTs `{base_url}/images/generations` (or `/images/edits` when reference images are attached) directly, with the same ChatGPT bearer auth it uses for chat. Because the injected `base_url` -points at opencodex, the proxy relays those calls to the OpenAI upstream: +points at opencodex, the proxy relays those calls to the OpenAI upstream. + +This is separate from the [Image Bridge](/guides/image-bridge/), which only activates when a +**Responses** turn lists the hosted `image_generation` tool while a non-OpenAI model is selected. +Standalone `/images/generations` calls never enter that bridge. - **One mode-aware forward candidate:** Pool selects an eligible main/added account; Direct uses the caller OAuth bearer. The configured mode applies consistently to the image request. diff --git a/docs-site/src/content/docs/guides/image-bridge.md b/docs-site/src/content/docs/guides/image-bridge.md new file mode 100644 index 000000000..6606fee55 --- /dev/null +++ b/docs-site/src/content/docs/guides/image-bridge.md @@ -0,0 +1,95 @@ +--- +title: Image Bridge +description: Route image_generation hosted-tool calls to xAI Grok Imagine when using a non-OpenAI provider. +--- + +## Overview + +When you route Codex through a non-OpenAI model (Claude, Gemini, Grok, etc.), the +`image_generation` **hosted tool** normally doesn't work — it requires OpenAI's server-side +execution environment. The Image Bridge detects these calls and transparently reroutes them to +xAI Grok Imagine, so the model you're actually chatting with can still generate images. + +## Prerequisites + +- **Enable the bridge** by setting `images.bridgeEnabled: true` in your config (it is off by + default to avoid unexpected xAI charges — see [Configuration](#configuration) below). +- An `xai` provider entry with an **API key**. The bridge pins fulfillment to the registry xAI + Images endpoint (`https://api.x.ai/v1`); any configured `baseUrl` override is ignored for image + calls. OAuth / `ocx login xai` alone does **not** arm the bridge (the Grok CLI OAuth transport is + chat-oriented and is not used for `/images/*`). + + ```json + { + "providers": { + "xai": { "adapter": "openai-chat", "apiKey": "xai-…", "authMode": "key" } + } + } + ``` + +- A non-OpenAI model selected as your active provider. (When the active provider is OpenAI, + the native hosted tool is used directly and the bridge is bypassed.) + +## Configuration + +Image Bridge options live under `images` in `~/.opencodex/config.json`. Bridging is +**opt-in** — you must set `bridgeEnabled: true` to enable paid xAI Grok Imagine generation: + +```json +{ + "images": { + "bridgeEnabled": true, + "bridgeModel": "grok-imagine-image-quality", + "maxRounds": 3, + "timeoutMs": 60000 + } +} +``` + +| Option | Default | Description | +| --- | --- | --- | +| `bridgeEnabled` | `false` | Master switch. Set `true` to enable bridging. Off by default to avoid unexpected xAI charges. | +| `bridgeModel` | `grok-imagine-image-quality` | The xAI image model id to send prompts to. | +| `maxRounds` | `3` | Maximum image-generation loop iterations per turn. Floored to an integer and clamped to `[0, 10]`; non-finite values fall back to `3`. | +| `timeoutMs` | `60000` | Per-call xAI deadline in milliseconds. Finite positive values are floored and passed to the xAI request. | +| `artifactsKeepCount` | `200` | Maximum number of files retained under `artifacts/`. When exceeded, the oldest files are deleted after each fulfilled call. Set to `0` or a negative value to disable pruning. | + +## Artifact Retention + +Generated images are written to `~/.opencodex/artifacts/`. To prevent unbounded disk +growth in long-running sessions, the directory is pruned automatically after each fulfilled +image call (once the full batch for that call is on disk) — the oldest files (by modification +time) are deleted when the count exceeds the configured maximum (default 200, configurable via +`images.artifactsKeepCount`). Only paths that survive pruning are returned to the model. + +## How It Works + +The Image Bridge activates only on **Responses** turns that include the hosted +`image_generation` tool in the `/v1/responses` tools array while a **non-OpenAI** +model is selected. It does **not** intercept Codex's built-in `image_gen` tool, +which POSTs directly to `/v1/images/generations` (or `/images/edits`) — that path +is covered separately in [Codex Integration](/guides/codex-integration/#built-in-image-generation-image_gen). + +1. When a Responses request lists `image_generation` in `tools`, OpenCodex detects it + during request preprocessing. +2. The hosted tool is replaced with a **synthetic function tool** that the routed model can call + normally — the model sees a callable tool rather than an opaque hosted tool it can't execute. +3. When the model invokes that tool, OpenCodex intercepts the call and sends the prompt to xAI's + image generation API. +4. Generated images are saved to `~/.opencodex/artifacts/` and the **local file path** is returned + to the model as the tool result. +5. The model continues the conversation with knowledge of the generated image and its location. + +From the model's perspective nothing changed — it called a tool and got a result. From the user's +perspective, image generation works with any routed provider instead of silently failing. + +## Limitations + +- **Only xAI Grok Imagine is supported.** DALL-E and other image providers may be added later. +- **Web search takes priority** on adapters that support the web-search sidecar loop. If both web + search and image generation are requested in the same turn, web-search runs and image + generation is skipped. Cursor/`runTurn` adapters cannot use that sidecar today, so the image + bridge may still run for those dual-tool turns. +- **xAI costs apply.** Image generation via xAI requires an active xAI subscription or API credits. +- **Streaming only.** The bridge works by intercepting the SSE response stream; requests with + `stream: false` are rejected with a 400 error. diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index 3d8d95227..d6c887e8a 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -1,12 +1,18 @@ -import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"; +import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; +import type { IncomingMessage } from "node:http"; +import https from "node:https"; +import type { RequestOptions } from "node:https"; import { basename, join, resolve, sep } from "node:path"; import { getConfigDir } from "../config"; -import { assessUrlDestination, assertUrlResolvesPublic } from "../lib/destination-policy"; +import { assessUrlDestination, resolvePublicAddresses } from "../lib/destination-policy"; const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024; const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024; -const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MiB +/** Hard cap for remote image downloads (also enforced inside pinnedHttpsGet). */ +export const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MiB +/** Idle timeout for pinned HTTPS connect/headers/body when no AbortSignal is provided. */ +export const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000; /** * Upper bound on the raw base64 string length before it is decoded. Base64 @@ -33,10 +39,28 @@ export interface ImageBudget { spent: number; } +export type PinnedAddress = { address: string; family: number }; + +/** Test seam / custom transport: must connect to `pinned`, not re-resolve `url`'s hostname. */ +export type PinnedDownloadFn = ( + url: string, + pinned: PinnedAddress, + signal?: AbortSignal, +) => Promise; + export function createImageBudget(): ImageBudget { return { spent: 0 }; } +/** Atomically reserve `bytes` against the per-response budget (no await between check and charge). */ +export function chargeImageBudget(budget: ImageBudget | undefined, bytes: number): void { + if (!budget) return; + if (budget.spent + bytes > MAX_DECODED_BYTES_PER_RESPONSE) { + throw new Error(`image response exceeds ${MAX_DECODED_BYTES_PER_RESPONSE} byte per-response cap`); + } + budget.spent += bytes; +} + export function getArtifactsDir(): string { return join(getConfigDir(), "artifacts"); } @@ -115,6 +139,8 @@ export function decodeValidatedImageBase64(base64Data: string): Buffer { * of files. All errors are swallowed and logged so a prune failure never breaks an image write. */ export function pruneOldArtifacts(dir: string, maxFiles: number): void { + // A non-positive maxFiles disables pruning entirely (do not delete everything). + if (maxFiles <= 0) return; let entries: string[]; try { entries = readdirSync(dir); @@ -187,50 +213,210 @@ async function writeArtifactUnique( } } -export function guessExtFromMagic(bytes: Uint8Array): string { +/** Sniff a recognized image extension, or null when the payload is empty/non-image. */ +export function sniffImageExtension(bytes: Uint8Array): "png" | "jpg" | "webp" | "gif" | null { + if (bytes.byteLength === 0) return null; const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1"); + // Full 8-byte PNG signature (89 50 4E 47 0D 0A 1A 0A) — reject truncated/malformed prefixes. if (sig.startsWith("\x89PNG\r\n\x1a\n")) return "png"; if (sig.startsWith("\xff\xd8\xff")) return "jpg"; if (sig.startsWith("RIFF") && sig.slice(8, 12) === "WEBP") return "webp"; if (sig.startsWith("GIF8")) return "gif"; - throw new Error("unrecognized image format — magic bytes do not match PNG, JPEG, WebP, or GIF"); + return null; +} + +export function guessExtFromMagic(bytes: Uint8Array): string { + const ext = sniffImageExtension(bytes); + if (!ext) { + throw new Error("unrecognized image format — magic bytes do not match PNG, JPEG, WebP, or GIF"); + } + return ext; +} + +/** Prune `OPENCODEX_HOME/artifacts` after a full image batch has been written. */ +export function pruneArtifacts(keepCount?: number): void { + pruneOldArtifacts(getArtifactsDir(), keepCount ?? DEFAULT_ARTIFACT_KEEP_COUNT); } export async function materializeInlineImage( base64Data: string, budget?: ImageBudget, - keepCount?: number, ): Promise { const dir = getArtifactsDir(); await mkdir(dir, { recursive: true, mode: 0o700 }); const buf = decodeValidatedImageBase64(base64Data); - if (budget && budget.spent + buf.length > MAX_DECODED_BYTES_PER_RESPONSE) { - throw new Error(`inline image response exceeds ${MAX_DECODED_BYTES_PER_RESPONSE} byte per-response cap`); - } - if (budget) budget.spent += buf.length; + chargeImageBudget(budget, buf.length); // Sniff actual format from decoded bytes rather than trusting the declared mimeType. - const ext = guessExtFromMagic(buf); - const filePath = await writeArtifactUnique(dir, "img-", buf, ext); - pruneOldArtifacts(dir, keepCount ?? DEFAULT_ARTIFACT_KEEP_COUNT); - return filePath; + const ext = sniffImageExtension(buf); + if (!ext) throw new Error("inline image data is not a recognized image"); + // Retention is post-batch via pruneArtifacts (see fulfill.ts) so a tight keepCount + // cannot delete earlier images from the same call before their paths are returned. + return writeArtifactUnique(dir, "img-", buf, ext); +} + +/** + * HTTPS GET that connects to a previously validated address while keeping the + * original hostname for SNI / Host. The custom `lookup` never asks the OS + * resolver again, so a rebinding answer cannot redirect the TCP peer. + * + * Returns a streaming Response so callers can enforce byte caps while reading; + * the transport also destroys the request if `maxBytes` is exceeded mid-stream. + */ +export function pinnedHttpsGet( + url: string, + pinned: PinnedAddress, + signal?: AbortSignal, + options?: { + maxBytes?: number; + idleTimeoutMs?: number; + rejectUnauthorized?: boolean; + }, +): Promise { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new Error(`image URL must use HTTPS, got ${parsed.protocol}`); + } + const maxBytes = options?.maxBytes ?? MAX_DOWNLOAD_BYTES; + const idleTimeoutMs = options?.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS; + + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason instanceof Error ? signal.reason : new Error("aborted")); + return; + } + + let settled = false; + const fail = (err: unknown) => { + try { req.destroy(); } catch { /* ignore */ } + if (settled) return; + settled = true; + reject(err instanceof Error ? err : new Error(String(err))); + }; + + const optionsHttps: RequestOptions & { servername?: string } = { + protocol: "https:", + hostname: parsed.hostname, + servername: parsed.hostname, + port: parsed.port || 443, + path: `${parsed.pathname}${parsed.search}`, + method: "GET", + headers: { Host: parsed.host }, + rejectUnauthorized: options?.rejectUnauthorized, + lookup(_hostname, lookupOptions, callback) { + const opts = typeof lookupOptions === "function" ? undefined : lookupOptions; + const cb = typeof lookupOptions === "function" ? lookupOptions : callback; + if (!cb) return; + // Pin the validated peer — do not call dns.lookup again. + // Honor `{ all: true }` array shape used by some Node/Bun https paths. + if (opts && typeof opts === "object" && "all" in opts && opts.all) { + (cb as (err: NodeJS.ErrnoException | null, addresses: PinnedAddress[]) => void)( + null, + [{ address: pinned.address, family: pinned.family }], + ); + return; + } + (cb as (err: NodeJS.ErrnoException | null, address: string, family: 4 | 6) => void)( + null, + pinned.address, + pinned.family as 4 | 6, + ); + }, + }; + + const req = https.request(optionsHttps, (res: IncomingMessage) => { + const status = res.statusCode ?? 0; + // Any non-2xx must destroy immediately. Returning a streaming Response for + // 4xx/5xx (or 3xx) lets callers that only check `Response.ok` abandon an + // unread body while the peer keeps sending — a failed-response socket leak. + if (status < 200 || status >= 300) { + try { res.destroy(); } catch { /* ignore */ } + fail(new Error("image download failed: " + status)); + return; + } + const headers = new Headers(); + for (const [key, value] of Object.entries(res.headers)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const item of value) headers.append(key, String(item)); + } else { + headers.set(key, String(value)); + } + } + + let received = 0; + const stream = new ReadableStream({ + start(controller) { + res.setTimeout(idleTimeoutMs, () => { + fail(new Error("image download stalled")); + try { controller.error(new Error("image download stalled")); } catch { /* closed */ } + }); + res.on("data", (chunk: Buffer | string) => { + const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + received += buf.byteLength; + if (received > maxBytes) { + const err = new Error(`image download exceeds ${maxBytes} byte cap`); + fail(err); + try { controller.error(err); } catch { /* closed */ } + return; + } + try { controller.enqueue(buf); } catch { /* closed */ } + }); + res.on("end", () => { + try { controller.close(); } catch { /* closed */ } + }); + res.on("error", (err: Error) => { + fail(err); + try { controller.error(err); } catch { /* closed */ } + }); + }, + cancel() { + req.destroy(); + }, + }); + + if (settled) return; + settled = true; + resolve(new Response(stream, { status, headers })); + }); + + const onAbort = () => { + fail(signal?.reason instanceof Error ? signal.reason : new Error("aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + req.setTimeout(idleTimeoutMs, () => { + fail(new Error("image download timed out")); + }); + req.on("error", (err) => { + signal?.removeEventListener("abort", onAbort); + fail(err); + }); + req.on("close", () => signal?.removeEventListener("abort", onAbort)); + req.end(); + }); +} + +function pickPinnedAddress(addresses: PinnedAddress[]): PinnedAddress { + return addresses.find(a => a.family === 4) ?? addresses[0]!; } export async function downloadImageToArtifact( url: string, budget?: ImageBudget, signal?: AbortSignal, - keepCount?: number, + options?: { pinnedDownload?: PinnedDownloadFn }, ): Promise { if (url.startsWith("data:")) { const m = /^data:([^;]+);base64,(.+)$/.exec(url); if (!m) throw new Error("data URL is not a valid base64 image"); - return materializeInlineImage(m[2], budget, keepCount); + return materializeInlineImage(m[2], budget); } // SSRF protection: validate the provider-returned URL before fetching. // Require HTTPS strictly — plain HTTP and all other schemes (ftp, file, …) are rejected. + // Resolve DNS once, then pin that public address for the HTTPS connect (SNI/Host keep + // the original hostname) so a rebinding answer cannot retarget the TCP peer. let parsedUrl: URL; try { parsedUrl = new URL(url); } catch { throw new Error("image URL is not valid"); } if (parsedUrl.protocol !== "https:") { @@ -241,10 +427,16 @@ export async function downloadImageToArtifact( if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") { throw new Error(`image URL targets ${assessment.detail}`); } - // DNS check: resolve hostname and reject if it points at private/internal space. - await assertUrlResolvesPublic(url); - const resp = await fetch(url, { signal, redirect: "error" }); - if (!resp.ok) throw new Error("image download failed: " + resp.status); + const resolved = await resolvePublicAddresses(url); + const pinned = pickPinnedAddress(resolved.addresses); + const download = options?.pinnedDownload ?? pinnedHttpsGet; + const resp = await download(url, pinned, signal); + if (!resp.ok) { + // Custom `pinnedDownload` seams may still return a failed Response with a + // live body; cancel it so unread error payloads cannot keep the socket warm. + try { await resp.body?.cancel(); } catch { /* ignore */ } + throw new Error("image download failed: " + resp.status); + } // Stream the body with a hard byte cap so a missing/lying Content-Length or a // compromised CDN URL cannot exhaust memory before the size check runs. @@ -271,16 +463,15 @@ export async function downloadImageToArtifact( let offset = 0; for (const c of chunks) { bytes.set(c, offset); offset += c.byteLength; } - if (budget && budget.spent + bytes.length > MAX_DECODED_BYTES_PER_RESPONSE) { - throw new Error(`image download exceeds ${MAX_DECODED_BYTES_PER_RESPONSE} byte per-response budget`); - } + if (bytes.byteLength === 0) throw new Error("image download returned empty body"); + const ext = sniffImageExtension(bytes); + if (!ext) throw new Error("image download did not contain a recognized image"); + + chargeImageBudget(budget, bytes.length); - const ext = guessExtFromMagic(bytes); const dir = getArtifactsDir(); await mkdir(dir, { recursive: true, mode: 0o700 }); - if (budget) budget.spent += bytes.length; - const filePath = await writeArtifactUnique(dir, "dl-", bytes, ext); - pruneOldArtifacts(dir, keepCount ?? DEFAULT_ARTIFACT_KEEP_COUNT); - return filePath; + // Retention is post-batch via pruneArtifacts (see fulfill.ts). + return writeArtifactUnique(dir, "dl-", bytes, ext); } diff --git a/src/images/fulfill.ts b/src/images/fulfill.ts new file mode 100644 index 000000000..4c5ad0f5d --- /dev/null +++ b/src/images/fulfill.ts @@ -0,0 +1,111 @@ +import { existsSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import type { ImageBridgePlan, ImageCallResult } from "./types"; +import { callXaiImages } from "./xai-client"; +import { + materializeInlineImage, + downloadImageToArtifact, + pruneArtifacts, + type ImageBudget, +} from "./artifacts"; + +/** Serialize write→prune→retain across concurrent fulfillments sharing artifacts/. */ +let retentionTail: Promise = Promise.resolve(); + +async function retainAfterBatch(paths: string[], keepCount?: number): Promise { + const run = retentionTail.then(() => { + pruneArtifacts(keepCount); + return paths.filter((p) => existsSync(p)); + }); + retentionTail = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +/** + * Fulfill ONE image-generation tool call end-to-end: parse args, call xAI, materialize the returned + * images to disk, and return a structured result. NEVER throws — all errors become `{ ok: false }` + * so the caller can inject the error as a tool result and let the model respond gracefully. + */ +export async function fulfillImageCall( + call: { id: string; name: string; arguments: string }, + plan: ImageBridgePlan, + budget: ImageBudget, + signal?: AbortSignal, +): Promise { + let args: unknown; + try { + args = JSON.parse(call.arguments || "{}"); + } catch { + return { ok: false, model: plan.model, prompt: "", files: [], count: 0, error: "invalid arguments JSON" }; + } + if (typeof args !== "object" || args === null) { + return { ok: false, model: plan.model, prompt: "", files: [], count: 0, error: "invalid arguments JSON" }; + } + const obj = args as Record; + + const prompt = + typeof obj.prompt === "string" ? obj.prompt : typeof obj.input === "string" ? obj.input : ""; + if (!prompt) { + return { ok: false, model: plan.model, prompt: "", files: [], count: 0, error: "missing prompt" }; + } + + const n = typeof obj.n === "number" ? Math.max(1, Math.min(4, Math.floor(obj.n))) : 1; + const imageUrl = + typeof obj.image_url === "string" ? obj.image_url : typeof obj.image === "string" ? obj.image : undefined; + const size = typeof obj.size === "string" ? obj.size : plan.defaultSize; + const quality = typeof obj.quality === "string" ? obj.quality : plan.defaultQuality; + + let result; + try { + result = await callXaiImages( + { prompt, model: plan.model, n, imageUrl, size, quality }, + plan.auth, + signal, + plan.timeoutMs, + ); + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + return { ok: false, model: plan.model, prompt, files: [], count: 0, error }; + } + + const files: string[] = []; + for (const img of result.images ?? []) { + try { + if (img.b64_json) { + files.push(await materializeInlineImage(img.b64_json, budget)); + } else if (img.url) { + files.push(await downloadImageToArtifact(img.url, budget, signal)); + } + } catch { + // Keep warnings URL-free — error messages may embed provider CDN URLs. + // Partial success is OK — silently skip this image and continue. + console.warn("[images] failed to materialize image"); + } + } + + // Prune only after the full batch is on disk so a tight keepCount cannot delete + // an earlier image from this same call before we return its path. Concurrent + // fulfillments share one retention chain so they cannot prune each other's + // just-written paths mid-filter. + const retained = await retainAfterBatch(files, plan.artifactsKeepCount); + + if (retained.length === 0) { + return { ok: false, model: plan.model, prompt, files: [], count: 0, error: "image generation returned no usable images" }; + } + + const primary = retained[0]!; + return { + ok: true, + model: plan.model, + prompt, + path: primary, + files: retained, + count: retained.length, + // Keep path/files as native FS paths; Markdown needs a file: URI so Windows + // backslashes are not treated as escapes by renderers. + markdown: `![image](${pathToFileURL(primary).href})`, + }; +} diff --git a/src/images/index.ts b/src/images/index.ts new file mode 100644 index 000000000..fc1f2bff1 --- /dev/null +++ b/src/images/index.ts @@ -0,0 +1,4 @@ +export { planImageBridge, findXaiProvider, resolveXaiImageApiKey } from "./plan"; +export { runWithImageBridge, clampImageMaxRounds, DEFAULT_MAX_ROUNDS, MAX_ROUNDS_HARD_LIMIT } from "./loop"; +export type { ImageBridgePlan, ImageCallResult } from "./types"; +export { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME, isImageGenName } from "./synthetic-tool"; diff --git a/src/images/loop.ts b/src/images/loop.ts new file mode 100644 index 000000000..e9240f74a --- /dev/null +++ b/src/images/loop.ts @@ -0,0 +1,659 @@ +/** + * Image bridge agentic loop — adapted from src/web-search/loop.ts but significantly simpler. + * + * The routed (non-OpenAI) model runs in a bounded loop. Each iteration is streamed and fully + * buffered internally. If the model calls an image-generation tool, the bridge fulfills it via + * the xAI sidecar, injects the result as a tool_result, and loops (bounded by maxRounds). When + * the model produces a real tool call or the budget is exhausted, the passthrough events are + * replayed to the bridge for final SSE output. + * + * Removed vs web-search: no sidecar backend selection, no forced-answer nudge, no failed-query + * dedup, no describeImages/structuredOutput, no recordSidecarOutcome. + */ +import type { AdapterRequest, ProviderAdapter } from "../adapters/base"; +import { createAdapterEventQueue } from "../adapters/run-turn-queue"; +import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage } from "../types"; +import { namespacedToolName } from "../types"; +import { bridgeToResponsesSSE } from "../bridge"; +import { clearableDeadline, idleDeadline } from "../lib/abort"; +import { readBoundedResponseBody } from "../lib/bounded-body"; +import { fetchWithResetRetry } from "../lib/upstream-retry"; +import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "../web-search/progress-stream"; +import { fulfillImageCall } from "./fulfill"; +import { createImageBudget } from "./artifacts"; +import { IMAGE_GEN_TOOL_NAME } from "./synthetic-tool"; +import type { ImageBridgePlan } from "./types"; + +const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", +}; + +const CONNECT_TIMEOUT_MS = 200_000; +const STALL_TIMEOUT_MS = 200_000; +export const DEFAULT_MAX_ROUNDS = 3; +/** Absolute ceiling so a hand-edited `images.maxRounds: 10000` cannot unbound paid xAI calls. */ +export const MAX_ROUNDS_HARD_LIMIT = 10; +/** Cap paid xAI fulfillments per turn (parallel calls in one round count separately). */ +export const MAX_IMAGE_CALLS_PER_TURN = 10; + +/** + * Clamp a configured maxRounds value to a safe integer in [0, MAX_ROUNDS_HARD_LIMIT]. + * Non-finite / non-number inputs fall back to DEFAULT_MAX_ROUNDS. + */ +export function clampImageMaxRounds(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_MAX_ROUNDS; + return Math.max(0, Math.min(MAX_ROUNDS_HARD_LIMIT, Math.floor(value))); +} + +/** Drop image-specific tool_choice when image tools are stripped for a forced-final pass. */ +function stripImageToolChoice( + options: OcxRequestOptions, + plan: ImageBridgePlan, +): OcxRequestOptions { + const tc = options.toolChoice; + if (!tc || typeof tc !== "object") return options; + if ("name" in tc && typeof tc.name === "string") { + if (tc.name === IMAGE_GEN_TOOL_NAME || plan.toolNames.has(tc.name)) { + return { ...options, toolChoice: "auto" }; + } + return options; + } + if ("allowedTools" in tc && Array.isArray(tc.allowedTools)) { + const filtered = tc.allowedTools.filter( + (name) => name !== IMAGE_GEN_TOOL_NAME && !plan.toolNames.has(name), + ); + if (filtered.length === tc.allowedTools.length) return options; + if (filtered.length === 0) return { ...options, toolChoice: "auto" }; + return { ...options, toolChoice: { ...tc, allowedTools: filtered } }; + } + return options; +} + +interface ImageCall { + id: string; + name: string; + args: string; +} + +/** + * Split an iteration's adapter events into (a) the image-generation tool calls to intercept and + * (b) the events to pass through to Codex. An image tool-call's own start/delta/end events are + * dropped (Codex never sees the synthetic tool); every other event — text, thinking, real tool + * calls, done — is preserved in order. + */ +function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set): { + calls: ImageCall[]; + passthrough: AdapterEvent[]; + hasRealToolCall: boolean; +} { + const calls: ImageCall[] = []; + const passthrough: AdapterEvent[] = []; + let hasRealToolCall = false; + let pending: { name: string; id: string; argsBuf: string; events: AdapterEvent[] } | null = null; + const flushPending = (): void => { + if (!pending) return; + if (toolNames.has(pending.name)) { + // Unterminated image call still carries buffered args — fulfill so malformed JSON + // becomes a normal tool_result error instead of silently vanishing. + calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf }); + } else { + passthrough.push(...pending.events); + hasRealToolCall = true; + } + pending = null; + }; + for (const e of events) { + if (e.type === "tool_call_start") { + flushPending(); + pending = { name: e.name, id: e.id, argsBuf: "", events: [e] }; + } else if (e.type === "tool_call_delta" && pending) { + pending.argsBuf += e.arguments; + pending.events.push(e); + } else if (e.type === "tool_call_end" && pending) { + pending.events.push(e); + if (toolNames.has(pending.name)) { + calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf }); + } else { + passthrough.push(...pending.events); + hasRealToolCall = true; + } + pending = null; + } else { + flushPending(); + passthrough.push(e); + } + } + flushPending(); + return { calls, passthrough, hasRealToolCall }; +} + +async function* replay(events: AdapterEvent[]): AsyncGenerator { + for (const e of events) yield e; +} + +/** + * Collect thinking / redacted_thinking blocks that preceded an image tool call, preserving + * stream order and per-block signatures. Anthropic extended thinking REQUIRES the assistant + * message containing tool_use to start with its signed thinking blocks — flattening multiple + * blocks into one signature 400s on replay. + */ +function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[] { + const parts: OcxThinkingContent[] = []; + let thinking = ""; + let signature: string | undefined; + + const flushVisible = () => { + if (!thinking && !signature) return; + parts.push({ + type: "thinking", + thinking, + ...(signature ? { signature } : {}), + }); + thinking = ""; + signature = undefined; + }; + + for (const e of events) { + if (e.type === "thinking_delta") { + thinking += e.thinking; + } else if (e.type === "thinking_signature") { + signature = e.signature; + flushVisible(); + } else if (e.type === "redacted_thinking") { + flushVisible(); + parts.push({ type: "thinking", thinking: "", redacted: [e.data] }); + } + } + flushVisible(); + return parts; +} + +function jsonError(status: number, message: string): Response { + return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a + * non-2xx jsonError; later (already-streaming) iterations surface it as an in-stream error event. */ +class LoopError extends Error { + constructor(readonly status: number, message: string) { + super(message); + this.name = "LoopError"; + } +} + +export interface ImageBridgeDeps { + parsed: OcxParsedRequest; + adapter: ProviderAdapter; + plan: ImageBridgePlan; + /** Headers forwarded from the original request (e.g. Codex auth). Cloned per iteration. */ + forwardHeaders?: Headers; + /** Called before each routed-model dispatch in the bridge loop, for attempt telemetry. */ + onAttemptSend?: () => void; + /** Called after each upstream request is built (parity with web-search / normal path). */ + onRequestBuilt?: (request: AdapterRequest) => void; + abortSignal?: AbortSignal; + onFirstOutput?: () => void; + /** Max image-generation rounds before forcing a final answer. Defaults to 3; clamped to [0, 10]. */ + maxRounds?: number; + /** Connect / response-header budget for non-runTurn iterations. */ + connectTimeoutMs?: number; + /** Stall budget (seconds) forwarded to bridgeToResponsesSSE; also bounds runTurn collect. */ + stallTimeoutSec?: number; + /** Provider-specific fetch (e.g. xAI transport wrapper). Falls back to global fetch. */ + fetchImpl?: typeof globalThis.fetch; + /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ + onUsage?: (usage: OcxUsage | undefined) => void; + /** + * Optional 429 key-failover for the routed (non-xAI) model. Return a rebuilt adapter for the + * rotated key, or null when the pool is exhausted. + */ + on429?: (retryAfterHeader: string | null) => ProviderAdapter | null; + /** Called when the bridged Responses stream completes (parity with runTurn / routed paths). */ + onCompletedResponse?: (response: Record, providerState?: OcxProviderContinuationState) => void; + /** WebSocket Responses path only — leave response id empty for protocol compatibility. */ + forceEmptyResponseId?: boolean; +} + +/** + * Run the main (non-OpenAI) model in a small agentic loop. Each upstream iteration is streamed and + * fully buffered internally so raw byte progress is observable without leaking the synthetic tool or + * preliminary assistant output. If the model invokes image generation, run it via the xAI sidecar, + * inject the answer as a tool_result, and loop (bounded by `maxRounds`). + */ +export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + const { parsed, plan, abortSignal } = deps; + let adapter = deps.adapter; + const maxRounds = clampImageMaxRounds(deps.maxRounds ?? DEFAULT_MAX_ROUNDS); + const HARD_CAP = maxRounds + 1; + const connectTimeoutMs = typeof deps.connectTimeoutMs === "number" && Number.isFinite(deps.connectTimeoutMs) && deps.connectTimeoutMs > 0 + ? Math.floor(deps.connectTimeoutMs) + : CONNECT_TIMEOUT_MS; + const stallTimeoutMs = typeof deps.stallTimeoutSec === "number" && Number.isFinite(deps.stallTimeoutSec) && deps.stallTimeoutSec > 0 + ? Math.floor(deps.stallTimeoutSec * 1000) + : STALL_TIMEOUT_MS; + const fetchImpl = deps.fetchImpl ?? globalThis.fetch; + let paidImageCalls = 0; + let hiddenUsage: OcxUsage | undefined; + + const addUsage = (a: OcxUsage | undefined, b: OcxUsage | undefined): OcxUsage | undefined => { + if (!a) return b; + if (!b) return a; + return { + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + ...(a.contextTotalTokens !== undefined || b.contextTotalTokens !== undefined + ? { contextTotalTokens: Math.max(a.contextTotalTokens ?? 0, b.contextTotalTokens ?? 0) } + : {}), + ...(a.cachedInputTokens !== undefined || b.cachedInputTokens !== undefined + ? { cachedInputTokens: (a.cachedInputTokens ?? 0) + (b.cachedInputTokens ?? 0) } + : {}), + ...(a.cacheReadInputTokens !== undefined || b.cacheReadInputTokens !== undefined + ? { cacheReadInputTokens: (a.cacheReadInputTokens ?? 0) + (b.cacheReadInputTokens ?? 0) } + : {}), + ...(a.cacheCreationInputTokens !== undefined || b.cacheCreationInputTokens !== undefined + ? { cacheCreationInputTokens: (a.cacheCreationInputTokens ?? 0) + (b.cacheCreationInputTokens ?? 0) } + : {}), + ...(a.reasoningOutputTokens !== undefined || b.reasoningOutputTokens !== undefined + ? { reasoningOutputTokens: (a.reasoningOutputTokens ?? 0) + (b.reasoningOutputTokens ?? 0) } + : {}), + ...(a.estimated || b.estimated ? { estimated: true } : {}), + }; + }; + const takeUsageFrom = (events: AdapterEvent[]): void => { + for (const e of events) { + if ((e.type === "done" || e.type === "incomplete") && e.usage) { + hiddenUsage = addUsage(hiddenUsage, e.usage); + } + } + }; + + const messages: OcxMessage[] = [...parsed.context.messages]; + const allTools = parsed.context.tools ?? []; + // Forced-final must strip every image-generation alias the plan knows about — not only tools + // flagged `imageGeneration:true`. Hosted `image_generation` / function aliases would otherwise + // remain callable; scanEventsForImageCall would strip the call while forceFinal blocks fulfillment, + // leaving the client an empty completion. + const toolsNoImage = allTools.filter(t => { + if (t.imageGeneration) return false; + if (plan.toolNames.has(t.name)) return false; + if (t.namespace && plan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; + return true; + }); + const budget = createImageBudget(); + + // Link an internal AbortController to the turn signal so a client cancel of the SSE body aborts + // in-flight model fetches AND the sidecar. + const internalAbort = new AbortController(); + const linkAbort = (): void => internalAbort.abort(abortSignal?.reason); + if (abortSignal) { + if (abortSignal.aborted) linkAbort(); + else abortSignal.addEventListener("abort", linkAbort, { once: true }); + } + const signal = internalAbort.signal; + + interface IterationResponse { + response: Response; + responseAdapter: ProviderAdapter; + } + type IterationSplit = ReturnType; + + // Acquire one iteration's final response headers. The first call is drained eagerly so an initial + // connect/header/HTTP failure stays a non-2xx JSON response — except for runTurn adapters, which + // have no HTTP status surface and must not block SSE headers behind queue.collect(). + const prepareIterationEvents = async function* (forceFinal: boolean): AsyncGenerator { + const iterParsed: OcxParsedRequest = { + ...parsed, + stream: true, + context: { ...parsed.context, messages, tools: forceFinal ? toolsNoImage : allTools }, + options: forceFinal ? stripImageToolChoice(parsed.options, plan) : parsed.options, + }; + + // runTurn adapters (Cursor) own all upstream communication via an emit callback. They don't + // expose buildRequest/fetchResponse/parseStream to the bridge, so collect their events through + // an AdapterEventQueue and wrap them in a pseudo-response whose parseStream replays them. + if (adapter.runTurn) { + const queue = createAdapterEventQueue({ + onBacklogExceeded: () => internalAbort.abort("runTurn backlog exceeded"), + }); + // Attempt telemetry must fire at dispatch time (parity with fetchOnce), not after collect. + deps.onAttemptSend?.(); + void adapter + .runTurn( + iterParsed, + { headers: deps.forwardHeaders ? new Headers(deps.forwardHeaders) : new Headers(), abortSignal: signal }, + queue.push, + ) + .then(() => queue.close()) + .catch(err => { + queue.push({ type: "error", message: err instanceof Error ? err.message : String(err) }); + queue.close(); + }); + + // Bound collect with a real *idle* deadline that resets on each emitted event. + // A fixed wall-clock race would abort legitimate long Cursor turns that keep + // producing tokens. Do NOT manufacture adapter heartbeats here — bridgeToResponsesSSE + // treats those as upstream activity and would defeat the stall guard. SSE keepalives + // come from the bridge heartbeat interval instead. + // + // On idle expiry: abort the runTurn signal AND close the queue so the consumer + // unblocks even when adapter.runTurn ignores cancellation and never settles. + let timedOut = false; + const idle = idleDeadline(stallTimeoutMs, () => { + timedOut = true; + // Cancel the fire-and-forget runTurn so a well-behaved adapter can stop. + internalAbort.abort(`runTurn inactivity timeout after ${stallTimeoutMs}ms`); + // Independently unblock queue.stream() — do not wait for runTurn to observe abort. + queue.close(); + }); + const events: AdapterEvent[] = []; + try { + idle.reset(); + for await (const event of queue.stream()) { + if (timedOut) break; + idle.reset(); + events.push(event); + } + } finally { + idle.cancel(); + } + if (timedOut) { + throw new LoopError(504, `runTurn inactivity timeout after ${stallTimeoutMs}ms during image-bridge`); + } + + // Preserve Cursor conversation continuity across image-loop iterations. runTurn mutates + // iterParsed (shallow copy); copy the id back onto the shared parsed request. + if (iterParsed._cursorConversationId) { + parsed._cursorConversationId = iterParsed._cursorConversationId; + } + + // runTurn adapters signal errors via {type:"error"} events, not HTTP status codes. + const errorEvent = events.find(e => e.type === "error"); + if (errorEvent && errorEvent.type === "error") { + throw new LoopError(502, errorEvent.message); + } + + const wrappedAdapter: ProviderAdapter = { + ...adapter, + async *parseStream() { + for (const e of events) yield e; + }, + }; + return { response: new Response(new Uint8Array(0), { status: 200 }), responseAdapter: wrappedAdapter }; + } + + const headerDeadline = clearableDeadline(connectTimeoutMs, signal); + try { + const fetchOnce = async (requestAdapter: ProviderAdapter): Promise => { + const request = await requestAdapter.buildRequest(iterParsed, { + headers: deps.forwardHeaders ? new Headers(deps.forwardHeaders) : new Headers(), + abortSignal: headerDeadline.signal, + }); + try { deps.onRequestBuilt?.(request); } catch { /* diagnostics are best-effort */ } + deps.onAttemptSend?.(); + const response = requestAdapter.fetchResponse + ? await requestAdapter.fetchResponse(request, { + abortSignal: headerDeadline.signal, + timeoutMs: connectTimeoutMs, + returnRawErrors: true, + stream: true, + }) + : await fetchWithResetRetry( + () => { + const h = new Headers(request.headers); + if (!h.has("accept-encoding")) h.set("accept-encoding", "identity"); + return fetchImpl(request.url, { + method: request.method, + headers: h, + body: request.body, + signal: headerDeadline.signal, + }); + }, + { abortSignal: headerDeadline.signal, label: "image-bridge-loop" }, + ); + return { response, responseAdapter: requestAdapter }; + }; + + let prepared = await fetchOnce(adapter); + // 429 key-failover parity with web-search / normal routed path. + while (prepared.response.status === 429 && deps.on429) { + const rotated = deps.on429(prepared.response.headers.get("retry-after")); + if (!rotated) break; + try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* already closed */ } + adapter = rotated; + yield { type: "heartbeat" }; + prepared = await fetchOnce(adapter); + } + + // Final headers have arrived. Clear only the deadline timer before ANY body read. + headerDeadline.clear(); + if (!prepared.response.ok) { + let body: Awaited>; + try { + body = await readBoundedResponseBody(prepared.response, { signal }); + } catch { + if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge"); + throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`); + } + let formatted = ""; + if (body.displaySafe && !body.truncated && body.text.trim() && prepared.responseAdapter.formatErrorBody) { + try { + formatted = prepared.responseAdapter.formatErrorBody( + prepared.response.status, + prepared.response.headers, + body.text, + ).trim(); + } catch { /* formatter hooks are best-effort */ } + } + const suffix = formatted ? `: ${formatted.slice(0, 400)}` : ""; + throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`); + } + return prepared; + } catch (error) { + if (headerDeadline.didExpire()) { + throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during image-bridge`); + } + if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge"); + if (error instanceof LoopError) throw error; + throw new LoopError(502, `Provider unreachable: ${error instanceof Error ? error.message : String(error)}`); + } finally { + headerDeadline.clear(); + } + }; + + const prepareIterationDrained = async (forceFinal: boolean): Promise => { + const it = prepareIterationEvents(forceFinal); + let r = await it.next(); + while (!r.done) r = await it.next(); + return r.value; + }; + + // Consume and validate one successful response body. Only invisible heartbeat events escape while + // semantic output remains buffered for safe scanning. + const consumeIterationEvents = async function* (prepared: IterationResponse): AsyncGenerator { + const events: AdapterEvent[] = []; + try { + const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter); + for await (const event of parseStreamWithProgress(prepared.response, parse, { + signal, + inactivityTimeoutMs: stallTimeoutMs, + })) { + if (event.type === "heartbeat") yield event; + else events.push(event); + } + } catch (error) { + if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge"); + if (error instanceof RoutedModelInactivityError) throw new LoopError(504, error.message); + if (error instanceof WebSearchStreamProtocolError) throw new LoopError(502, error.message); + throw new LoopError(502, `Provider stream error: ${error instanceof Error ? error.message : String(error)}`); + } + + const terminalIndexes = events.flatMap((event, index) => + event.type === "done" || event.type === "incomplete" || event.type === "error" ? [index] : []); + if (terminalIndexes.length !== 1 || terminalIndexes[0] !== events.length - 1) { + throw new LoopError(502, `Image-bridge adapter stream protocol error: expected one final terminal event, received ${terminalIndexes.length}`); + } + const terminal = events[terminalIndexes[0]!]; + if (terminal.type === "error") throw new LoopError(502, terminal.message); + return scanEventsForImageCall(events, plan.toolNames); + }; + + // Eagerly acquire only the FIRST iteration's final headers so connect/header/HTTP failures remain + // non-2xx JSON. Skip for runTurn adapters: their "headers" are synthetic, and awaiting + // queue.collect() before returning SSE starves clients of headers/heartbeats on slow first turns. + const skipEagerDrain = !!adapter.runTurn; + let firstPrepared: IterationResponse | undefined; + if (!skipEagerDrain) { + try { + firstPrepared = await prepareIterationDrained(maxRounds <= 0); + } catch (e) { + if (abortSignal) abortSignal.removeEventListener("abort", linkAbort); + if (e instanceof LoopError) return jsonError(e.status, e.message); + throw e; + } + } + + const toolNsMap = new Map(); + const freeform = new Set(); + const toolSearch = new Set(); + for (const t of parsed.context.tools ?? []) { + if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name }); + if (t.freeform) freeform.add(t.name); + if (t.toolSearch) toolSearch.add(t.name); + } + + // Drive the remaining iterations live. Image generation runs interleaved with the real sidecar + // timing; the final answer's passthrough events come last. + async function* produce(): AsyncGenerator { + let prepared = firstPrepared; + try { + for (let i = 0; i < HARD_CAP; i++) { + const forceFinal = i >= maxRounds; + try { + // First loop turn reuses the eager HEADERS when present. runTurn (and later iterations) + // acquire headers inside the live SSE stream so clients already have the response open. + if (!prepared || i > 0) { + yield { type: "heartbeat" }; + prepared = yield* prepareIterationEvents(forceFinal); + } + // Raw-byte progress heartbeats reach the bridge; semantic events remain buffered. + const split = yield* consumeIterationEvents(prepared); + prepared = undefined; + + // Loop (fulfill + re-ask) ONLY when the model's actionable output is purely image_gen. A + // real tool call means this turn is terminal for Codex — finalize so those calls reach + // Codex. forceFinal also finalizes. + const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceFinal; + if (!shouldLoop) { + if (hiddenUsage) { + for (let i = split.passthrough.length - 1; i >= 0; i--) { + const e = split.passthrough[i]; + if (e?.type === "done" || e?.type === "incomplete") { + split.passthrough[i] = { ...e, usage: addUsage(hiddenUsage, e.usage) }; + break; + } + } + } + yield* replay(split.passthrough); + return; + } + + // Discarded iteration still contributed tokens — accumulate for the final onUsage. + takeUsageFrom(split.passthrough); + + // Fulfill each image call, then inject ONE assistant turn (thinking once + all tool + // calls) so Anthropic extended-thinking continuations stay valid across parallel calls. + const iterationThinking = extractIterationThinking(split.passthrough); + const fulfilled: Array<{ call: ImageCall; result: Awaited>; args: Record }> = []; + for (const call of split.calls) { + yield { type: "heartbeat" }; + let result: Awaited>; + if (paidImageCalls >= MAX_IMAGE_CALLS_PER_TURN) { + result = { + ok: false, + model: plan.model, + prompt: "", + files: [], + count: 0, + error: `image call budget exhausted (max ${MAX_IMAGE_CALLS_PER_TURN} per turn)`, + }; + } else { + paidImageCalls += 1; + result = await fulfillImageCall( + { id: call.id, name: call.name, arguments: call.args }, + plan, budget, signal, + ); + } + if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge"); + let parsedArgs: Record = {}; + try { + const raw: unknown = JSON.parse(call.args || "{}"); + if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { + parsedArgs = raw as Record; + } + } catch { /* malformed args */ } + fulfilled.push({ call, result, args: parsedArgs }); + } + const now = Date.now(); + messages.push({ + role: "assistant", + content: [ + ...iterationThinking, + ...fulfilled.map(({ call, args }) => ({ + type: "toolCall" as const, + id: call.id, + name: call.name, + arguments: args, + })), + ], + timestamp: now, + }); + for (const { call, result } of fulfilled) { + messages.push({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content: JSON.stringify(result), + isError: !result.ok, + timestamp: now, + }); + } + } catch (e) { + yield { + type: "error", + message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)), + ...(e instanceof LoopError ? { status: e.status } : {}), + ...(hiddenUsage ? { usage: hiddenUsage } : {}), + }; + return; + } + } + } finally { + if (abortSignal) abortSignal.removeEventListener("abort", linkAbort); + } + } + + const sse = bridgeToResponsesSSE( + produce(), parsed.modelId, toolNsMap, freeform, toolSearch, () => { + internalAbort.abort("client closed responses stream"); + }, 2_000, + { + ...(deps.forceEmptyResponseId ? { responseId: "" } : {}), + hideThinkingSummary: parsed.options.hideThinkingSummary, + stallTimeoutSec: deps.stallTimeoutSec, + ...(deps.onFirstOutput ? { onFirstOutput: deps.onFirstOutput } : {}), + ...(deps.onUsage ? { + // Terminal done/incomplete already includes hiddenUsage (merged above). Do not + // add it again here or request logs double-count multi-iteration image turns. + onUsage: (usage: OcxUsage | undefined) => deps.onUsage?.(usage), + } : {}), + ...(deps.onCompletedResponse ? { onCompletedResponse: deps.onCompletedResponse } : {}), + }, + ); + return new Response(sse, { headers: SSE_HEADERS }); +} diff --git a/src/images/plan.ts b/src/images/plan.ts new file mode 100644 index 000000000..ce59a67e5 --- /dev/null +++ b/src/images/plan.ts @@ -0,0 +1,80 @@ +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; +import type { ImageBridgePlan } from "./types"; +import { resolveEnvValue } from "../config"; +import { getProviderRegistryEntry } from "../providers/registry"; +import { IMAGE_GEN_TOOL_NAME } from "./synthetic-tool"; + +const DEFAULT_MODEL = "grok-imagine-image-quality"; +/** Absolute ceiling for `images.timeoutMs` (matches /v1/images relay budget). */ +export const MAX_IMAGE_TIMEOUT_MS = 300_000; + +function clampImageTimeoutMs(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; + return Math.max(1, Math.min(MAX_IMAGE_TIMEOUT_MS, Math.floor(value))); +} + +export function findXaiProvider(config: OcxConfig): { name: string; provider: OcxProviderConfig } | undefined { + // Primary: well-known name "xai" + const xai = config.providers["xai"]; + if (xai && xai.disabled !== true) return { name: "xai", provider: xai }; + // Fallback: hostname match for custom-named xAI configs + for (const [name, p] of Object.entries(config.providers)) { + if (p.disabled) continue; + try { + const host = new URL(p.baseUrl).hostname; + if (host === "api.x.ai" || host === "cli-chat-proxy.grok.com") return { name, provider: p }; + } catch { /* invalid baseUrl */ } + } + return undefined; +} + +/** + * Image Bridge fulfillment talks to the public Images API on api.x.ai with a Bearer API key. + * OAuth / Grok CLI proxy transport is not used here (that path is chat-oriented and not a + * supported Images transport), so oauth-only configs deliberately do not arm the bridge. + */ +export function resolveXaiImageApiKey(provider: OcxProviderConfig): string | undefined { + if (provider.authMode === "oauth") return undefined; + const apiKey = resolveEnvValue(provider.apiKey)?.trim(); + return apiKey || undefined; +} + +export async function planImageBridge( + config: OcxConfig, + parsed: OcxParsedRequest, + routedProvider: OcxProviderConfig, +): Promise { + if (config.images?.bridgeEnabled !== true) return undefined; + if (!parsed._imageGeneration) return undefined; + // Don't intercept for OpenAI native passthrough + const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })(); + if (host === "api.openai.com") return undefined; + const found = findXaiProvider(config); + if (!found) return undefined; + const token = resolveXaiImageApiKey(found.provider); + if (!token) return undefined; + // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override. + const registryEntry = getProviderRegistryEntry("xai"); + const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, ""); + // The synthetic tool injected into the conversation is named IMAGE_GEN_TOOL_NAME, + // which is what the model will actually call. Merge it with any original hosted tool names. + const toolNames = new Set(parsed._imageGeneration.toolNames); + toolNames.add(IMAGE_GEN_TOOL_NAME); + const original = parsed._imageGeneration.originalTool; + const hostedSize = typeof original?.size === "string" ? original.size : undefined; + const hostedQuality = typeof original?.quality === "string" ? original.quality : undefined; + const timeoutMs = clampImageTimeoutMs(config.images?.timeoutMs); + const keepRaw = config.images?.artifactsKeepCount; + const artifactsKeepCount = + typeof keepRaw === "number" && Number.isFinite(keepRaw) ? Math.floor(keepRaw) : undefined; + return { + provider: found.provider, + auth: { baseUrl: pinnedBaseUrl, token }, + model: config.images?.bridgeModel ?? DEFAULT_MODEL, + toolNames, + ...(hostedSize ? { defaultSize: hostedSize } : {}), + ...(hostedQuality ? { defaultQuality: hostedQuality } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(artifactsKeepCount !== undefined ? { artifactsKeepCount } : {}), + }; +} diff --git a/src/images/synthetic-tool.ts b/src/images/synthetic-tool.ts new file mode 100644 index 000000000..8ba1fbda4 --- /dev/null +++ b/src/images/synthetic-tool.ts @@ -0,0 +1,88 @@ +import type { OcxTool } from "../types"; + +/** The function name the chat model sees + the name the loop intercepts. */ +export const IMAGE_GEN_TOOL_NAME = "image_gen"; + +/** Client function aliases collected only when a hosted image tool is also present. */ +const IMAGE_GEN_ALIASES = new Set([ + "imagegen", + "generate_image", + "generateimage", + IMAGE_GEN_TOOL_NAME, + "image_generation", +]); + +export function isImageGenName(name: string): boolean { + const lower = name.toLowerCase(); + return lower === IMAGE_GEN_TOOL_NAME || lower === "image_generation"; +} + +function isImageGenClientAlias(name: string): boolean { + return IMAGE_GEN_ALIASES.has(name.toLowerCase()); +} + +/** + * Scan a Responses request's `tools[]` for hosted image-generation entries + * (`{type:"image_generation"}` or `{type:"image_gen"}`). Ordinary `type:"function"` + * tools never activate the bridge by name alone — they are only collected as conflicting + * aliases to strip/replace when a hosted entry is present. + */ +export function extractHostedImageGeneration( + tools: unknown[] | undefined, +): { toolNames: Set; originalTool?: Record } | undefined { + if (!Array.isArray(tools)) return undefined; + let hasHostedEntry = false; + let originalTool: Record | undefined; + for (const t of tools) { + if (!t || typeof t !== "object") continue; + const obj = t as Record; + if (obj.type === "image_generation" || obj.type === "image_gen") { + hasHostedEntry = true; + if (!originalTool) originalTool = obj; + } + } + if (!hasHostedEntry) return undefined; + + const toolNames = new Set(); + for (const t of tools) { + if (!t || typeof t !== "object") continue; + const obj = t as Record; + if (obj.type === "image_generation" || obj.type === "image_gen") { + const name = typeof obj.name === "string" ? obj.name : (obj.type as string); + toolNames.add(name); + } else if (obj.type === "function") { + // Responses API function tools have a flat shape: {type:"function", name:"...", parameters:{...}}. + // Also handle the nested Chat Completions shape {type:"function", function:{name:"..."}} for safety. + const fnName = + typeof obj.name === "string" ? obj.name : (obj as { function?: { name?: string } }).function?.name; + if (fnName && isImageGenClientAlias(fnName)) { + toolNames.add(fnName); + } + } + } + if (toolNames.size === 0) return undefined; + return { toolNames, originalTool }; +} + +/** + * The synthetic function tool exposed to a chat/anthropic model in place of the dropped hosted + * image_generation. The model calls it like any function; the proxy intercepts the call and runs the + * real generation via the sidecar (the call is never relayed to Codex). `imageGeneration:true` flags it. + */ +export function buildImageTool(): OcxTool { + return { + name: IMAGE_GEN_TOOL_NAME, + description: + "Generate an image from a text prompt. Returns absolute local filesystem path(s). " + + "Use when the user asks to create or draw an image.", + parameters: { + type: "object", + properties: { + prompt: { type: "string", description: "Detailed image generation prompt. Required." }, + n: { type: "integer", minimum: 1, maximum: 4 }, + }, + required: ["prompt"], + }, + imageGeneration: true, + }; +} diff --git a/src/images/types.ts b/src/images/types.ts new file mode 100644 index 000000000..e72177746 --- /dev/null +++ b/src/images/types.ts @@ -0,0 +1,26 @@ +import type { OcxProviderConfig } from "../types"; + +export interface ImageBridgePlan { + provider: OcxProviderConfig; + auth: { baseUrl: string; token: string }; + model: string; + toolNames: Set; + /** Per-call xAI deadline (ms). Defaults inside callXaiImages when omitted. */ + timeoutMs?: number; + /** Defaults from the hosted image_generation tool when the model omits size/quality. */ + defaultSize?: string; + defaultQuality?: string; + /** Max artifact files to retain (from config.images.artifactsKeepCount). Default 200. ≤0 disables prune. */ + artifactsKeepCount?: number; +} + +export interface ImageCallResult { + ok: boolean; + model: string; + prompt: string; + path?: string; + files: string[]; + count: number; + markdown?: string; + error?: string; +} diff --git a/src/images/xai-client.ts b/src/images/xai-client.ts new file mode 100644 index 000000000..3695bcde5 --- /dev/null +++ b/src/images/xai-client.ts @@ -0,0 +1,141 @@ +/** + * xAI image generation/editing client. + * + * Calls xAI's `/images/generations` or `/images/edits` endpoint, composing a + * 60 s timeout with the caller's abort signal so the deadline covers the entire + * response body read. Non-2xx responses throw with the original status code — + * no 502 compression — so callers can distinguish rate-limit / auth failures + * from transient errors. + */ + +export interface XaiImageRequest { + prompt: string; + model?: string; // default "grok-imagine-image-quality" + n?: number; // 1-4 + size?: string; + quality?: string; + imageUrl?: string; // if set → /images/edits +} + +export interface XaiImageResult { + images: Array<{ b64_json?: string; url?: string }>; +} + +const XAI_IMAGES_TIMEOUT_MS = 60_000; +const XAI_DEFAULT_MODEL = "grok-imagine-image-quality"; + +// xAI only accepts aspect_ratio + resolution, not OpenAI's size/quality. Map +// OpenAI's "WxH" size to the closest standard ratio (log-space distance) and +// OpenAI quality buckets onto xAI's 1k/2k resolution. Unknown values are +// dropped rather than forwarded, to avoid sending parameters xAI rejects. +const XAI_ASPECT_RATIOS: ReadonlyArray = [ + ["1:1", 1], + ["3:4", 0.75], + ["4:3", 4 / 3], + ["9:16", 0.5625], + ["16:9", 16 / 9], +]; + +function mapSizeToAspectRatio(size?: string): string | undefined { + if (!size) return undefined; + const m = /^(\d+)x(\d+)$/.exec(size); + if (!m) return undefined; + const ratio = parseInt(m[1], 10) / parseInt(m[2], 10); + let best = XAI_ASPECT_RATIOS[0]!; + let bestDiff = Infinity; + for (const [label, r] of XAI_ASPECT_RATIOS) { + const diff = Math.abs(Math.log(ratio / r)); + if (diff < bestDiff) { + bestDiff = diff; + best = [label, r] as const; + } + } + return best[0]; +} + +function mapQualityToResolution(quality?: string): string | undefined { + if (!quality) return undefined; + const q = quality.toLowerCase(); + if (q === "hd" || q === "high") return "2k"; + if (q === "standard" || q === "low" || q === "auto") return "1k"; + return undefined; +} + +export async function callXaiImages( + req: XaiImageRequest, + auth: { baseUrl: string; token: string }, + signal?: AbortSignal, + timeoutMs: number = XAI_IMAGES_TIMEOUT_MS, +): Promise { + const isEdit = typeof req.imageUrl === "string" && req.imageUrl.length > 0; + const endpoint = isEdit ? "/images/edits" : "/images/generations"; + + const body: Record = { + model: req.model ?? XAI_DEFAULT_MODEL, + prompt: req.prompt, + n: req.n ?? 1, + }; + const aspectRatio = mapSizeToAspectRatio(req.size); + const resolution = mapQualityToResolution(req.quality); + if (aspectRatio) body.aspect_ratio = aspectRatio; + if (resolution) body.resolution = resolution; + if (isEdit) { + body.image = { url: req.imageUrl as string, type: "image_url" }; + } + + const deadlineMs = Number.isFinite(timeoutMs) && timeoutMs > 0 + ? Math.floor(timeoutMs) + : XAI_IMAGES_TIMEOUT_MS; + const timeout = AbortSignal.timeout(deadlineMs); + const linkedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; + + const baseUrl = auth.baseUrl.replace(/\/+$/, ""); + const resp = await fetch(`${baseUrl}${endpoint}`, { + method: "POST", + headers: { + "Authorization": `Bearer ${auth.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: linkedSignal, + }); + + if (!resp.ok) { + try { await resp.body?.cancel(); } catch { /* ignore */ } + const err = new Error("xAI images API returned " + resp.status) as Error & { status: number }; + err.status = resp.status; + throw err; + } + + // Read the body as text under the linked signal, then parse. The 60 s timeout + // and caller abort cover the read. A 200 MiB hard cap on the text prevents a + // runaway response from exhausting memory before materialization caps apply. + const MAX_RESPONSE_BYTES = 200 * 1024 * 1024; + const reader = resp.body?.getReader(); + if (!reader) throw new Error("xAI images API returned no body"); + const decoder = new TextDecoder(); + let text = ""; + let totalBytes = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_RESPONSE_BYTES) throw new Error("xAI images API response exceeds size cap"); + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } finally { + try { await reader.cancel(); } catch { /* ignore cancel errors */ } + reader.releaseLock(); + } + + const json = JSON.parse(text) as { data?: Array<{ b64_json?: string; url?: string }> }; + + const images = (json.data ?? []).map((entry) => ({ + b64_json: entry.b64_json, + url: entry.url, + })); + + return { images }; +} diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 1ec932bd4..b2731df68 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -79,13 +79,33 @@ function classifyIpv6(hostname: string): DestinationAssessment { if (BLOCKED_METADATA_IPV6.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" }; const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)?.[1]; if (mappedIpv4) return classifyIpv4(mappedIpv4); + // Decode hex IPv4-mapped IPv6: ::ffff:7f00:1 → 127.0.0.1 + // The dotted-decimal regex above only matches ::ffff:127.0.0.1; without this, + // hex form bypasses all private/loopback checks (hextet is 0 → classified "public"). + const hexMapped = hostname.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + if (hexMapped) { + const hi = Number.parseInt(hexMapped[1], 16); + const lo = Number.parseInt(hexMapped[2], 16); + const ipv4 = `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`; + return classifyIpv4(ipv4); + } if (hostname === "::1") return { kind: "loopback", detail: "loopback address" }; if (hostname === "::") return { kind: "unspecified", detail: "unspecified address" }; const hextet = firstIpv6Hextet(hostname); - if (hextet === null) return { kind: "public", detail: "public IP" }; - if (hextet >= 0xfc00 && hextet <= 0xfdff) return { kind: "private", detail: "private-network address" }; + if (hextet === null) return { kind: "private", detail: "non-global address" }; + // Multicast ff00::/8, deprecated site-local fec0::/10, ULA fc00::/7, link-local fe80::/10. + if (hextet >= 0xff00) return { kind: "private", detail: "multicast address" }; if (hextet >= 0xfe80 && hextet <= 0xfebf) return { kind: "link-local", detail: "link-local address" }; - return { kind: "public", detail: "public IP" }; + if (hextet >= 0xfec0 && hextet <= 0xfeff) return { kind: "private", detail: "site-local address" }; + if (hextet >= 0xfc00 && hextet <= 0xfdff) return { kind: "private", detail: "private-network address" }; + // Documentation 2001:db8::/32 (inside global-unicast 2000::/3). + if (hextet === 0x2001) { + const second = Number.parseInt(hostname.split(":")[1] || "0", 16); + if (second === 0xdb8) return { kind: "private", detail: "documentation address" }; + } + // Only global unicast 2000::/3 is treated as a public image/CDN peer. + if (hextet >= 0x2000 && hextet <= 0x3fff) return { kind: "public", detail: "public IP" }; + return { kind: "private", detail: "non-global address" }; } function assessDestination(baseUrl: string): DestinationAssessment | null { @@ -195,10 +215,14 @@ export function assessUrlDestination(url: string): UrlDestinationAssessment | nu /** * Async DNS-resolved URL safety check. Resolves A/AAAA records and rejects * if any address is loopback, private, link-local, unspecified, or metadata. - * Throws on unsafe destination; returns void on safe/public destination. + * Throws on unsafe destination; returns the validated public addresses on success + * so callers can pin the connect peer and avoid a second, rebindable resolution. * DNS resolution failures are treated as unsafe (fail-closed). */ -export async function assertUrlResolvesPublic(url: string): Promise { +export async function resolvePublicAddresses(url: string): Promise<{ + hostname: string; + addresses: { address: string; family: number }[]; +}> { let hostname: string; try { hostname = normalizeHostname(new URL(url.trim()).hostname); @@ -210,9 +234,12 @@ export async function assertUrlResolvesPublic(url: string): Promise { if (literalAssessment && literalAssessment.kind !== "public" && literalAssessment.kind !== "hostname") { throw new Error(`image URL targets ${literalAssessment.detail}`); } - // For literal IPs and localhost, the sync path already classified them. - if (isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) return; - let addresses: { address: string }[]; + // Literal public IPs: no DNS round-trip; pin the literal itself. + const literalKind = isIP(hostname); + if (literalKind !== 0) { + return { hostname, addresses: [{ address: hostname, family: literalKind }] }; + } + let addresses: { address: string; family: number }[]; try { addresses = await lookup(hostname, { all: true, verbatim: true }); } catch { @@ -220,10 +247,27 @@ export async function assertUrlResolvesPublic(url: string): Promise { // this is a runtime fetch to an untrusted URL, so be conservative). throw new Error(`image URL hostname ${hostname} could not be resolved`); } - for (const { address } of addresses) { - const ipKind = isIP(address); + if (addresses.length === 0) { + throw new Error(`image URL hostname ${hostname} could not be resolved`); + } + const publicAddresses: { address: string; family: number }[] = []; + for (const { address, family } of addresses) { + // Prefer classifying from the address string itself — do not trust a mislabeled + // resolver `family` that could skip IPv4/IPv6 private checks. + const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0); const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; - if (!assessment || assessment.kind === "public") continue; - throw new Error(`image URL hostname ${hostname} resolves to ${assessment.detail} (${address})`); + if (!assessment || assessment.kind !== "public") { + throw new Error(`image URL hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`); + } + publicAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) }); } + return { hostname, addresses: publicAddresses }; +} + +/** + * Void assertion wrapper around {@link resolvePublicAddresses} for call sites + * that only need the safety check. + */ +export async function assertUrlResolvesPublic(url: string): Promise { + await resolvePublicAddresses(url); } diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 3c13b477e..1a3d3e49c 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -16,6 +16,7 @@ import { compactionItemToText } from "./compaction"; import { previousResponseReplayPrefixLength } from "./state"; import { decodeReasoningEnvelope } from "./reasoning-envelope"; import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool"; +import { extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool"; function isObj(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); @@ -107,6 +108,10 @@ function mapToolChoice(value: unknown): OcxRequestOptions["toolChoice"] { if ((t === "function" || t === "custom") && "name" in value) { return { name: (value as { name: string }).name }; } + // Hosted image tool types (with or without a name) map to the synthetic image_gen wire name. + if (t === "image_generation" || t === "image_gen") { + return { name: IMAGE_GEN_TOOL_NAME }; + } if (t === "allowed_tools" && Array.isArray(value.tools)) { const names = value.tools .map(allowedToolName) @@ -124,6 +129,7 @@ function allowedToolName(tool: unknown): string | undefined { if (!isObj(tool)) return undefined; if (typeof tool.name === "string" && tool.name.length > 0) return tool.name; if (tool.type === "web_search" || tool.type === "web_search_preview") return WEB_SEARCH_TOOL_NAME; + if (tool.type === "image_generation" || tool.type === "image_gen") return IMAGE_GEN_TOOL_NAME; if (tool.type === "tool_search") return "tool_search"; return undefined; } @@ -612,6 +618,10 @@ export function parseRequest(body: unknown): OcxParsedRequest { // gpt-mini sidecar for routed providers. buildTools still drops the hosted tool; the sidecar path // re-injects a synthetic function tool only when it will actually handle the call. const webSearch = extractHostedWebSearch(data.tools as unknown[] | undefined); + const imageGen = extractHostedImageGeneration([ + ...(data.tools as unknown[] ?? []), + ...loadedToolSpecs, + ]); // Detect structured-output mode (Responses `text.format`) so the web-search sidecar can render its // tool_result as JSON rather than prose that could corrupt the model's schema-constrained answer. const structuredOutput = detectStructuredOutput(data.text); @@ -625,6 +635,7 @@ export function parseRequest(body: unknown): OcxParsedRequest { _rawBody: body, ...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}), ...(webSearch ? { _webSearch: webSearch } : {}), + ...(imageGen ? { _imageGeneration: imageGen } : {}), ...(structuredOutput ? { _structuredOutput: true } : {}), ...(compactionRequest ? { _compactionRequest: true } : {}), ...(contextCompactionBoundary ? { _contextCompactionBoundary: true } : {}), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2472b6608..3d396c86e 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -39,6 +39,7 @@ import { UnsupportedOAuthProviderError, } from "../../oauth"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; +import { buildImageTool, planImageBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue"; import { @@ -1531,6 +1532,168 @@ export async function handleResponses( }); } + // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. + // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but + // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses + // completion instead of the synthetic compaction item Codex expects (#424). + // + // Web-search's loop only supports buildRequest/fetch/parseStream — NOT adapter.runTurn. Sending + // Cursor/runTurn requests into runWithWebSearch produces empty HTTP failures. So: + // - non-runTurn: web-search wins over image when both eligible (documented priority) + // - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn + // can proceed for web-search-only turns + const wsPlan = !routedCompaction + ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar) + : undefined; + const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; + const canRunWebSearch = !!wsPlan && !adapter.runTurn; + if (imgPlan && (!wsPlan || adapter.runTurn)) { + // The bridge forces stream:true internally and returns SSE. Non-streaming requests can't be + // served — reject explicitly rather than returning SSE to a client expecting JSON. + if (!parsed.stream) { + return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true"); + } + // Replace any pre-existing image_gen alias instead of appending a duplicate wire name. + const priorTools = parsed.context.tools ?? []; + parsed.context.tools = [ + ...priorTools.filter(t => { + if (t.imageGeneration) return false; + if (imgPlan.toolNames.has(t.name)) return false; + if (t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false; + return true; + }), + buildImageTool(), + ]; + // Hosted image_generation tool_choice / allowed_tools must target the synthetic function name. + const tc = parsed.options.toolChoice; + if (tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) { + const mapped = tc.allowedTools.map(name => + name === "image_generation" || name === "image_gen" || imgPlan.toolNames.has(name) + ? IMAGE_GEN_TOOL_NAME + : name, + ); + parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] }; + } else if (tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string" + && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) { + parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME }; + } + const imgResponse = await runWithImageBridge({ + parsed, adapter, + plan: imgPlan, + forwardHeaders: selectedForwardHeaders, + onAttemptSend: () => noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens), + abortSignal: options.abortSignal, + maxRounds: clampImageMaxRounds(config.images?.maxRounds), + connectTimeoutMs: config.connectTimeoutMs ?? 200_000, + stallTimeoutSec: config.stallTimeoutSec, + fetchImpl: providerFetch(route.provider), + onRequestBuilt: request => recordAdapterReasoning(logCtx, request), + onUsage: usage => { + // Cursor may assign _cursorConversationId inside the image loop's first runTurn; + // backfill so Logs can filter/total that opening request (parity with the normal + // runTurn branch). + if (!logCtx.conversationId && parsed._cursorConversationId) { + logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId); + } + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, + on429: retryAfter => { + const rotated = rotateProviderTransportOn429(config, route.providerName, { + retryAfter, + now: Date.now(), + attemptedKey: route.provider.apiKey, + promptCacheKey: parsed.options.promptCacheKey, + }); + if (!rotated) return null; + route.provider = rotated; + return resolveAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider), + config.cacheRetention, + ); + }, + ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), + ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}), + onCompletedResponse: (response, providerState) => + rememberResponseState( + parsed._rawBody, + response, + continuationStateForResponse(providerState), + adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined, + ), + }); + if (imgResponse.body) { + const imgTurnAc = new AbortController(); + return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc), { + status: imgResponse.status, + headers: imgResponse.headers, + }); + } + return imgResponse; + } + + // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't + // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar + // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path. + // Placed BEFORE the runTurn early-return for non-runTurn adapters so dual-tool turns dispatch + // through web-search instead of being swallowed. runTurn adapters never enter this branch. + if (canRunWebSearch && wsPlan) { + parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()]; + noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens); + const wsResponse = await runWithWebSearch({ + parsed, adapter, + backend: wsPlan.backend, + forwardProvider: wsPlan.forwardSidecar?.provider, + anthropicSidecar: wsPlan.anthropicSidecar, + hostedTool: wsPlan.hostedTool, + selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders, + settings: wsPlan.settings, + maxSearches: wsPlan.maxSearches, + forceEmptyResponseId: true, + abortSignal: options.abortSignal, + ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), + onRequestBuilt: request => recordAdapterReasoning(logCtx, request), + onUsage: usage => { + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, + recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome, + connectTimeoutMs: config.connectTimeoutMs ?? 200_000, + routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs, + stallTimeoutSec: wsPlan.stallTimeoutSec, + on429: retryAfter => { + const rotated = rotateProviderTransportOn429(config, route.providerName, { + retryAfter, + now: Date.now(), + attemptedKey: route.provider.apiKey, + promptCacheKey: parsed.options.promptCacheKey, + }); + if (!rotated) return null; + route.provider = rotated; + return resolveAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider), + config.cacheRetention, + ); + }, + }); + // Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts) + // in-flight web-search turns instead of skipping them during graceful shutdown. + if (wsResponse.body) { + const wsTurnAc = new AbortController(); + return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc), { + status: wsResponse.status, + headers: wsResponse.headers, + }); + } + return wsResponse; + } + if (adapter.runTurn) { const runTurnAbort = new AbortController(); linkAbortSignal(runTurnAbort, options.abortSignal); @@ -1651,64 +1814,6 @@ export async function handleResponses( return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } }); } - // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't - // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar - // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path. - const wsPlan = planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar); - if (wsPlan) { - parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()]; - noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens); - const wsResponse = await runWithWebSearch({ - parsed, adapter, - backend: wsPlan.backend, - forwardProvider: wsPlan.forwardSidecar?.provider, - anthropicSidecar: wsPlan.anthropicSidecar, - hostedTool: wsPlan.hostedTool, - selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders, - settings: wsPlan.settings, - maxSearches: wsPlan.maxSearches, - forceEmptyResponseId: true, - abortSignal: options.abortSignal, - ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), - onRequestBuilt: request => recordAdapterReasoning(logCtx, request), - onUsage: usage => { - logCtx.usageFromBridge = true; - if (usage) { - logCtx.usage = usage; - if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; - } - }, - recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome, - connectTimeoutMs: config.connectTimeoutMs ?? 200_000, - routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs, - stallTimeoutSec: wsPlan.stallTimeoutSec, - on429: retryAfter => { - const rotated = rotateProviderTransportOn429(config, route.providerName, { - retryAfter, - now: Date.now(), - attemptedKey: route.provider.apiKey, - promptCacheKey: parsed.options.promptCacheKey, - }); - if (!rotated) return null; - route.provider = rotated; - return resolveAdapter( - resolveWireProtocolOverride(route.providerName, route.modelId, route.provider), - config.cacheRetention, - ); - }, - }); - // Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts) - // in-flight web-search turns instead of skipping them during graceful shutdown. - if (wsResponse.body) { - const wsTurnAc = new AbortController(); - return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc), { - status: wsResponse.status, - headers: wsResponse.headers, - }); - } - return wsResponse; - } - const upstream = new AbortController(); const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal); const connectMs = config.connectTimeoutMs ?? 200_000; diff --git a/src/types.ts b/src/types.ts index 373177049..fcb30338c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,8 @@ export interface OcxParsedRequest { * executes searches via the gpt-5.4-mini sidecar (see src/web-search). Absent when not requested. */ _webSearch?: Record; + /** Hosted image_generation tool config stashed for the image bridge sidecar (see src/images). */ + _imageGeneration?: { toolNames: Set; originalTool?: Record }; /** * True when Codex requested structured output (`text.format` = json_schema/json_object). The * web-search tool_result is then rendered as compact JSON instead of markdown prose, so its @@ -156,6 +158,8 @@ export interface OcxTool { loadedFromToolSearch?: boolean; /** Synthetic web_search tool: the model's call is executed by the gpt-5.4-mini sidecar, not relayed to Codex. */ webSearch?: boolean; + /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */ + imageGeneration?: boolean; } /** @@ -748,8 +752,16 @@ export interface OcxTokenGuardianConfig { export interface OcxImagesConfig { /** Optional custom API-key provider for /v1/images relays. Built-in OpenAI tiers remain automatic. */ provider?: string; - /** Upstream timeout (ms) for one /v1/images relay. Default 300000 — generation is slow. */ + /** Upstream timeout (ms) for one image generation/edit call (bridge xAI + /v1/images relay). Default 60000 for the bridge; relay may use a higher default (300000). */ timeoutMs?: number; + /** Master switch for the image bridge. Default false — set true to enable paid xAI Grok Imagine generation. */ + bridgeEnabled?: boolean; + /** xAI image model id. Default "grok-imagine-image-quality" (see DEFAULT_MODEL in images/plan.ts). */ + bridgeModel?: string; + /** Max image-generation loop iterations before forced-final. Default 3; clamped to [0, 10]. */ + maxRounds?: number; + /** Max files retained under artifacts/. Oldest deleted when exceeded. Default 200. */ + artifactsKeepCount?: number; } export interface OcxSearchConfig { diff --git a/tests/api-storage-cleanup.test.ts b/tests/api-storage-cleanup.test.ts index b52a30e3a..1559c3dd9 100644 --- a/tests/api-storage-cleanup.test.ts +++ b/tests/api-storage-cleanup.test.ts @@ -297,7 +297,7 @@ describe("GET /api/storage/trash + POST restore", () => { } finally { await server.stop(true); } - }); + }, { timeout: 20_000 }); test("restore returns 409 when Codex DB is busy", async () => { seedArchived(isolatedCodexHome!.path); @@ -335,7 +335,7 @@ describe("GET /api/storage/trash + POST restore", () => { } finally { await server.stop(true); } - }); + }, { timeout: 20_000 }); test("rejects invalid and missing trash ids", async () => { const server = startServer(0); diff --git a/tests/destination-policy-resolved.test.ts b/tests/destination-policy-resolved.test.ts index e700c3a57..d47df9416 100644 --- a/tests/destination-policy-resolved.test.ts +++ b/tests/destination-policy-resolved.test.ts @@ -28,6 +28,11 @@ describe("providerDestinationConfigError — reserved IPv4 ranges (review findin test("still passes ordinary public literals", () => { expect(providerDestinationConfigError("custom", provider("https://93.184.216.34/v1"))).toBeNull(); }); + + test("rejects IPv6 site-local and multicast literals", () => { + expect(providerDestinationConfigError("custom", provider("http://[fec0::1]/v1"))).toContain("allowPrivateNetwork"); + expect(providerDestinationConfigError("custom", provider("http://[ff02::1]/v1"))).toContain("allowPrivateNetwork"); + }); }); describe("providerDestinationResolvedError — DNS-resolved SSRF check (activation)", () => { @@ -58,6 +63,18 @@ describe("providerDestinationResolvedError — DNS-resolved SSRF check (activati expect(error).toContain("private-network address (fd00::1)"); }); + test("blocks a hostname resolving to IPv6 site-local space", async () => { + lookupMock.mockResolvedValueOnce([{ address: "fec0::1", family: 6 }]); + const error = await providerDestinationResolvedError("custom", provider("https://v6-site.example.com/v1")); + expect(error).toMatch(/site-local address \(fec0::1\)/); + }); + + test("blocks a hostname resolving to IPv6 multicast space", async () => { + lookupMock.mockResolvedValueOnce([{ address: "ff02::1", family: 6 }]); + const error = await providerDestinationResolvedError("custom", provider("https://v6-mcast.example.com/v1")); + expect(error).toMatch(/multicast address \(ff02::1\)/); + }); + test("passes a hostname resolving only to public addresses", async () => { lookupMock.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]); expect(await providerDestinationResolvedError("custom", provider("https://api.example.com/v1"))).toBeNull(); diff --git a/tests/images/artifacts-prune.test.ts b/tests/images/artifacts-prune.test.ts new file mode 100644 index 000000000..43705262d --- /dev/null +++ b/tests/images/artifacts-prune.test.ts @@ -0,0 +1,91 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, utimesSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; + +const PREV_HOME = process.env.OPENCODEX_HOME; +beforeAll(() => { process.env.OPENCODEX_HOME = join(tmpdir(), "ocx-prune-" + randomUUID()); }); +afterAll(() => { if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; }); + +const { pruneOldArtifacts, pruneArtifacts, DEFAULT_ARTIFACT_KEEP_COUNT } = await import("../../src/images/artifacts"); + +function touch(path: string, content: string = "x", ageMs = 0): void { + writeFileSync(path, content); + if (ageMs > 0) { + const t = (Date.now() - ageMs) / 1000; + utimesSync(path, t, t); + } +} + +// Minimal valid 1×1 PNG (full 8-byte signature). +const MIN_PNG_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +describe("pruneOldArtifacts", () => { + test("count <= maxFiles → no deletion", () => { + const dir = mkdtempSync(join(tmpdir(), "prune-keep-")); + for (let i = 0; i < 5; i++) touch(join(dir, `f${i}.png`)); + pruneOldArtifacts(dir, 10); + expect(readdirSync(dir).length).toBe(5); + }); + + test("count > maxFiles → oldest deleted until under limit", () => { + const dir = mkdtempSync(join(tmpdir(), "prune-trim-")); + for (let i = 0; i < 10; i++) { + touch(join(dir, `f${i}.png`), "data", (10 - i) * 1000); + } + pruneOldArtifacts(dir, 5); + const remaining = readdirSync(dir); + expect(remaining.length).toBe(5); + for (let i = 0; i < 5; i++) { + expect(remaining).not.toContain(`f${i}.png`); + } + for (let i = 5; i < 10; i++) { + expect(remaining).toContain(`f${i}.png`); + } + }); + + test("maxFiles <= 0 disables pruning (does not wipe directory)", () => { + const dir = mkdtempSync(join(tmpdir(), "prune-disable-")); + for (let i = 0; i < 4; i++) touch(join(dir, `f${i}.png`)); + pruneOldArtifacts(dir, 0); + expect(readdirSync(dir).length).toBe(4); + pruneOldArtifacts(dir, -1); + expect(readdirSync(dir).length).toBe(4); + }); + + test("nonexistent dir → logs warn, no throw", () => { + expect(() => pruneOldArtifacts(join(tmpdir(), "does-not-exist-" + randomUUID()), 10)).not.toThrow(); + }); + + test("default keep count is 200", () => { + expect(DEFAULT_ARTIFACT_KEEP_COUNT).toBe(200); + }); +}); + +describe("pruneArtifacts: integration with materializeInlineImage", () => { + test("writing >keepCount images then pruning keeps newest", async () => { + const { materializeInlineImage } = await import("../../src/images/artifacts"); + const KEEP = 3; + const TOTAL = 6; + const written: string[] = []; + for (let i = 0; i < TOTAL; i++) { + const path = await materializeInlineImage(MIN_PNG_B64); + written.push(path); + await new Promise(r => setTimeout(r, 5)); + } + pruneArtifacts(KEEP); + const dir = dirname(written[0]!); + const remaining = readdirSync(dir); + expect(remaining.length).toBe(KEEP); + for (let i = 0; i < TOTAL; i++) { + const fname = basename(written[i]!); + const shouldExist = i >= TOTAL - KEEP; + expect(remaining.includes(fname)).toBe(shouldExist); + expect(existsSync(written[i]!)).toBe(shouldExist); + } + await rm(dir, { recursive: true, force: true }).catch(() => {}); + }); +}); diff --git a/tests/images/artifacts-ssrf.test.ts b/tests/images/artifacts-ssrf.test.ts new file mode 100644 index 000000000..5583678d4 --- /dev/null +++ b/tests/images/artifacts-ssrf.test.ts @@ -0,0 +1,254 @@ +import { rm } from "node:fs/promises"; +import { describe, expect, mock, test } from "bun:test"; + +// Mock DNS before importing destination-policy — it binds `lookup` at load time. +const lookupMock = mock(async (_hostname: string, _opts: unknown): Promise<{ address: string; family: number }[]> => []); +mock.module("node:dns/promises", () => ({ lookup: lookupMock })); + +const { assessUrlDestination, assertUrlResolvesPublic, resolvePublicAddresses } = await import("../../src/lib/destination-policy"); +const { downloadImageToArtifact } = await import("../../src/images/artifacts"); + +const MIN_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +describe("SSRF: assessUrlDestination", () => { + test("loopback IPv4 → loopback", () => { + expect(assessUrlDestination("http://127.0.0.1/test")?.kind).toBe("loopback"); + }); + test("link-local → link-local", () => { + expect(assessUrlDestination("http://169.254.1.1/latest")?.kind).toBe("link-local"); + }); + test("private 10.x → private", () => { + expect(assessUrlDestination("http://10.0.0.1/test")?.kind).toBe("private"); + }); + test("private 192.168 → private", () => { + expect(assessUrlDestination("http://192.168.1.1/test")?.kind).toBe("private"); + }); + test("private 172.16 → private", () => { + expect(assessUrlDestination("http://172.16.0.1/test")?.kind).toBe("private"); + }); + test("metadata endpoint → metadata", () => { + expect(assessUrlDestination("http://169.254.170.2/test")?.kind).toBe("metadata"); + }); + test("localhost → localhost", () => { + expect(assessUrlDestination("http://localhost/test")?.kind).toBe("localhost"); + }); + test("shorthand / decimal IPv4 literals normalize via URL and stay non-public", () => { + // WHATWG URL expands these before our classifier runs (e.g. 127.1 → 127.0.0.1). + expect(assessUrlDestination("https://127.1/image.png")?.kind).toBe("loopback"); + expect(assessUrlDestination("https://127.0.1/image.png")?.kind).toBe("loopback"); + expect(assessUrlDestination("https://0x7f.0.0.1/image.png")?.kind).toBe("loopback"); + expect(assessUrlDestination("https://2130706433/image.png")?.kind).toBe("loopback"); + expect(assessUrlDestination("https://10.1/image.png")?.kind).toBe("private"); + }); + test("IPv6 site-local [fec0::1] → private", () => { + expect(assessUrlDestination("https://[fec0::1]/image.png")?.kind).toBe("private"); + expect(assessUrlDestination("https://[fec0::1]/image.png")?.detail).toContain("site-local"); + }); + test("IPv6 multicast [ff02::1] → private", () => { + expect(assessUrlDestination("https://[ff02::1]/image.png")?.kind).toBe("private"); + expect(assessUrlDestination("https://[ff02::1]/image.png")?.detail).toContain("multicast"); + }); + test("IPv6 documentation [2001:db8::1] → private", () => { + expect(assessUrlDestination("https://[2001:db8::1]/image.png")?.kind).toBe("private"); + expect(assessUrlDestination("https://[2001:db8::1]/image.png")?.detail).toContain("documentation"); + }); + test("IPv6 global unicast [2001:4860:4860::8888] → public", () => { + expect(assessUrlDestination("https://[2001:4860:4860::8888]/image.png")?.kind).toBe("public"); + }); + test("public HTTPS → hostname or public", () => { + const kind = assessUrlDestination("https://example.com/image.png")?.kind; + expect(kind === "hostname" || kind === "public").toBe(true); + }); + test("public IP → public", () => { + expect(assessUrlDestination("https://8.8.8.8/image.png")?.kind).toBe("public"); + }); + test("IPv4-mapped IPv6 dotted-decimal [::ffff:127.0.0.1] → loopback", () => { + expect(assessUrlDestination("https://[::ffff:127.0.0.1]/image.png")?.kind).toBe("loopback"); + }); + test("IPv4-mapped IPv6 hex [::ffff:7f00:1] → loopback", () => { + expect(assessUrlDestination("https://[::ffff:7f00:1]/image.png")?.kind).toBe("loopback"); + }); + test("IPv4-mapped IPv6 hex private [::ffff:0a00:1] (10.0.0.1) → private", () => { + expect(assessUrlDestination("https://[::ffff:0a00:1]/image.png")?.kind).toBe("private"); + }); + test("invalid URL → null", () => { + expect(assessUrlDestination("not a url")).toBeNull(); + }); +}); + +describe("SSRF: assertUrlResolvesPublic", () => { + test("loopback IP → throws", async () => { + await expect(assertUrlResolvesPublic("http://127.0.0.1/x")).rejects.toThrow(); + }); + test("metadata endpoint → throws", async () => { + await expect(assertUrlResolvesPublic("http://169.254.169.254/x")).rejects.toThrow(); + }); + test("private 10.x → throws", async () => { + await expect(assertUrlResolvesPublic("http://10.0.0.1/x")).rejects.toThrow(); + }); + test("invalid URL → throws", async () => { + await expect(assertUrlResolvesPublic("not-a-url")).rejects.toThrow(); + }); +}); + +describe("SSRF: resolvePublicAddresses", () => { + test("hostname with public A record → returns that address", async () => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + try { + const resolved = await resolvePublicAddresses("https://public-host/img.png"); + expect(resolved.hostname).toBe("public-host"); + expect(resolved.addresses).toEqual([{ address: "93.184.216.34", family: 4 }]); + } finally { + lookupMock.mockClear(); + } + }); + + test("hostname that also resolves private → throws (fail closed on any unsafe answer)", async () => { + lookupMock.mockResolvedValue([ + { address: "93.184.216.34", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ]); + try { + await expect(resolvePublicAddresses("https://mixed-host/img.png")).rejects.toThrow(/loopback|127\.0\.0\.1/); + } finally { + lookupMock.mockClear(); + } + }); + + test("hostname resolving to IPv6 site-local → throws", async () => { + lookupMock.mockResolvedValue([{ address: "fec0::1", family: 6 }]); + try { + await expect(resolvePublicAddresses("https://v6-site.example/img.png")).rejects.toThrow(/site-local|fec0/); + } finally { + lookupMock.mockClear(); + } + }); + + test("hostname resolving to IPv6 multicast → throws", async () => { + lookupMock.mockResolvedValue([{ address: "ff02::1", family: 6 }]); + try { + await expect(resolvePublicAddresses("https://v6-mcast.example/img.png")).rejects.toThrow(/multicast|ff02/); + } finally { + lookupMock.mockClear(); + } + }); +}); + +describe("SSRF: downloadImageToArtifact scheme enforcement", () => { + test("http:// → rejects (non-HTTPS)", async () => { + await expect(downloadImageToArtifact("http://public-host/path")).rejects.toThrow(/HTTPS/); + }); + + test("ftp:// → rejects", async () => { + await expect(downloadImageToArtifact("ftp://host/path")).rejects.toThrow(/HTTPS/); + }); + + test("gopher:// → rejects (non-HTTPS)", async () => { + await expect(downloadImageToArtifact("gopher://host/path")).rejects.toThrow(/HTTPS/); + }); + + test("private 10.x via download helper → rejects", async () => { + await expect(downloadImageToArtifact("https://10.0.0.1/img.png")).rejects.toThrow(); + }); + + test("shorthand IPv4 (127.1 / decimal) via download helper → rejects", async () => { + await expect(downloadImageToArtifact("https://127.1/img.png")).rejects.toThrow(/loopback/); + await expect(downloadImageToArtifact("https://2130706433/img.png")).rejects.toThrow(/loopback/); + }); + + test("IPv4-mapped IPv6 [::ffff:127.0.0.1] via download helper → rejects", async () => { + await expect(downloadImageToArtifact("https://[::ffff:127.0.0.1]/image.png")).rejects.toThrow(); + }); + + test("IPv4-mapped IPv6 hex [::ffff:7f00:1] via download helper → rejects", async () => { + await expect(downloadImageToArtifact("https://[::ffff:7f00:1]/image.png")).rejects.toThrow(); + }); + + test(`3xx redirect response → rejects without following`, async () => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + try { + await expect(downloadImageToArtifact("https://public-host/redirect-img", undefined, undefined, { + pinnedDownload: async () => new Response("", { + status: 301, + headers: { Location: "https://evil.example/redirect" }, + }), + })).rejects.toThrow(/301|failed/); + } finally { + lookupMock.mockClear(); + } + }); + + test("https:// public host → succeeds with pinned download", async () => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + let downloadedPath: string | undefined; + let seenPinned: { address: string; family: number } | undefined; + try { + downloadedPath = await downloadImageToArtifact("https://public-host/valid-image", undefined, undefined, { + pinnedDownload: async (_url, pinned) => { + seenPinned = pinned; + return new Response(MIN_PNG, { status: 200 }); + }, + }); + expect(downloadedPath).toMatch(/dl-.*\.png$/); + expect(seenPinned).toEqual({ address: "93.184.216.34", family: 4 }); + } finally { + lookupMock.mockClear(); + if (downloadedPath) await rm(downloadedPath).catch(() => {}); + } + }); + + test("empty 200 body → rejects", async () => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + try { + await expect(downloadImageToArtifact("https://public-host/empty", undefined, undefined, { + pinnedDownload: async () => new Response(new Uint8Array(0), { status: 200 }), + })).rejects.toThrow(/empty/); + } finally { + lookupMock.mockClear(); + } + }); + + test("non-image 200 body (HTML/JSON) → rejects", async () => { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + try { + await expect(downloadImageToArtifact("https://public-host/not-image", undefined, undefined, { + pinnedDownload: async () => new Response("error", { status: 200 }), + })).rejects.toThrow(/recognized image/); + } finally { + lookupMock.mockClear(); + } + }); + + test("DNS rebinding: connection uses the validated public address, not a later private resolve", async () => { + // Validation lookup returns public; any subsequent OS resolve would return loopback. + // The download must pin the first answer and must not call dns.lookup again. + // (Transport lookup-shape + byte-cap coverage lives in pinned-https-get.test.ts.) + let lookups = 0; + lookupMock.mockImplementation(async () => { + lookups += 1; + if (lookups === 1) return [{ address: "93.184.216.34", family: 4 }]; + return [{ address: "127.0.0.1", family: 4 }]; + }); + + let downloadedPath: string | undefined; + let seenPinned: { address: string; family: number } | undefined; + try { + downloadedPath = await downloadImageToArtifact("https://rebind.example/img.png", undefined, undefined, { + pinnedDownload: async (_url, pinned) => { + // Still only one DNS resolve at connect time — pin came from validation. + expect(lookups).toBe(1); + seenPinned = pinned; + expect(pinned.address).toBe("93.184.216.34"); + expect(pinned.address).not.toBe("127.0.0.1"); + return new Response(MIN_PNG, { status: 200 }); + }, + }); + expect(downloadedPath).toMatch(/dl-.*\.png$/); + expect(seenPinned?.address).toBe("93.184.216.34"); + expect(lookups).toBe(1); + } finally { + lookupMock.mockClear(); + if (downloadedPath) await rm(downloadedPath).catch(() => {}); + } + }); +}); diff --git a/tests/images/loop.test.ts b/tests/images/loop.test.ts new file mode 100644 index 000000000..c6fd08fa6 --- /dev/null +++ b/tests/images/loop.test.ts @@ -0,0 +1,578 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import type { ProviderAdapter, IncomingMeta } from "../../src/adapters/base"; +import type { AdapterEvent, OcxParsedRequest } from "../../src/types"; +import type { ImageBridgePlan, ImageCallResult } from "../../src/images/types"; + +const PREV_HOME = process.env.OPENCODEX_HOME; +let runWithImageBridge: typeof import("../../src/images/loop")["runWithImageBridge"]; +let clampImageMaxRounds: typeof import("../../src/images/loop")["clampImageMaxRounds"]; +let DEFAULT_MAX_ROUNDS: typeof import("../../src/images/loop")["DEFAULT_MAX_ROUNDS"]; +let MAX_ROUNDS_HARD_LIMIT: typeof import("../../src/images/loop")["MAX_ROUNDS_HARD_LIMIT"]; + +let fulfillResult: ImageCallResult = { + ok: true, model: "grok-imagine-image-quality", prompt: "a cat", + files: ["/test/img.png"], count: 1, markdown: "![image](/test/img.png)", +}; + +beforeAll(async () => { + process.env.OPENCODEX_HOME = join(tmpdir(), "ocx-test-" + randomUUID()); + mock.restore(); + mock.module("../../src/web-search/progress-stream", () => ({ + parseStreamWithProgress: async function* (_resp: Response, parse: (r: Response) => AsyncGenerator, _opts: unknown) { + for await (const e of parse(_resp)) yield e; + }, + RoutedModelInactivityError: class extends Error { readonly timeoutMs = 0; }, + WebSearchStreamProtocolError: class extends Error { /* */ }, + })); + mock.module("../../src/images/fulfill", () => ({ + fulfillImageCall: async (): Promise => fulfillResult, + })); + ({ + runWithImageBridge, + clampImageMaxRounds, + DEFAULT_MAX_ROUNDS, + MAX_ROUNDS_HARD_LIMIT, + } = await import("../../src/images/loop")); +}); +afterAll(() => { if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; mock.restore(); }); + +// --- Mock adapter: yields canned events per iteration from a queue --- +let streamQueue: AdapterEvent[][] = []; +let buildRequestCalls = 0; + +const defaultFulfillResult: ImageCallResult = { + ok: true, model: "grok-imagine-image-quality", prompt: "a cat", + files: ["/test/img.png"], count: 1, markdown: "![image](/test/img.png)", +}; +beforeEach(() => { + fulfillResult = { ...defaultFulfillResult, files: [...defaultFulfillResult.files] }; + buildRequestCalls = 0; + streamQueue = []; +}); + +const mockAdapter: ProviderAdapter = { + name: "test", + buildRequest: async () => { buildRequestCalls++; return { url: "https://test/v1/chat", method: "POST", headers: {}, body: "{}" }; }, + fetchResponse: async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), + parseStream: async function* (): AsyncGenerator { + const events = streamQueue.shift(); + if (events) for (const e of events) yield e; + }, +}; + +const plan = { + provider: {} as never, + auth: { baseUrl: "https://api.x.ai", token: "test-token" }, + model: "grok-imagine-image-quality", + toolNames: new Set(["image_gen"]), +} as ImageBridgePlan; + +function makeParsed(): OcxParsedRequest { + return { modelId: "test-model", context: { messages: [], tools: [] }, stream: true, options: {} } as OcxParsedRequest; +} + +const imageCallEvents: AdapterEvent[] = [ + { type: "tool_call_start", id: "call_1", name: "image_gen" }, + { type: "tool_call_delta", arguments: '{"prompt":"a cat"}' }, + { type: "tool_call_end" }, + { type: "done" }, +]; + +async function runAndGetSSE(streams: AdapterEvent[][], fulfill?: ImageCallResult): Promise { + streamQueue = streams.map(s => [...s]); + if (fulfill) fulfillResult = fulfill; + const response = await runWithImageBridge({ parsed: makeParsed(), adapter: mockAdapter, plan }); + return await response.text(); +} + +describe("runWithImageBridge", () => { + test("no image tool call → passthrough text + done", async () => { + const sse = await runAndGetSSE([ + [{ type: "text_delta", text: "hello world" }, { type: "done" }], + ]); + expect(sse).toContain("hello world"); + }); + + test("single image call → fulfilled, second iteration yields text", async () => { + const sse = await runAndGetSSE( + [imageCallEvents, [{ type: "text_delta", text: "Here is your image" }, { type: "done" }]], + { ok: true, model: "grok-imagine-image-quality", prompt: "a cat", files: ["/test/img.png"], count: 1, markdown: "![image](/test/img.png)" }, + ); + expect(sse).toContain("Here is your image"); + }); + + test("fulfillImageCall error → model responds about failure", async () => { + const sse = await runAndGetSSE( + [imageCallEvents, [{ type: "text_delta", text: "Sorry, image generation failed" }, { type: "done" }]], + { ok: false, model: "grok-imagine-image-quality", prompt: "a cat", files: [], count: 0, error: "xAI unreachable" }, + ); + expect(sse).toContain("Sorry, image generation failed"); + }); + + test("image_gen tool call is intercepted — not visible in client SSE", async () => { + const sse = await runAndGetSSE( + [imageCallEvents, [{ type: "text_delta", text: "done" }, { type: "done" }]], + ); + // The tool_call_start event for image_gen should NOT appear in client-facing SSE + expect(sse).not.toContain("image_gen"); + expect(sse).not.toContain("tool_call_start"); + }); + + test("maxRounds: 1 bounds upstream requests and forces final after limit", async () => { + buildRequestCalls = 0; + // Round 0: model calls image_gen (within limit → fulfill + loop) + // Round 1: forced-final pass (forceFinal, image tools stripped from request) + streamQueue = [ + [...imageCallEvents], + [{ type: "text_delta" as const, text: "final answer" }, { type: "done" as const }], + ]; + const response = await runWithImageBridge({ parsed: makeParsed(), adapter: mockAdapter, plan, maxRounds: 1 }); + const sse = await response.text(); + expect(sse).toContain("final answer"); + // Exactly 2 upstream requests: round 0 (image call) + round 1 (forced final) + expect(buildRequestCalls).toBe(2); + }); + + test("maxRounds: 0 forces final immediately — no image tool offered", async () => { + buildRequestCalls = 0; + streamQueue = [ + [{ type: "text_delta" as const, text: "direct answer" }, { type: "done" as const }], + ]; + const response = await runWithImageBridge({ parsed: makeParsed(), adapter: mockAdapter, plan, maxRounds: 0 }); + const sse = await response.text(); + expect(sse).toContain("direct answer"); + // Single upstream request — first iteration is already forced-final + expect(buildRequestCalls).toBe(1); + }); + + test("forced-final clears named image tool_choice", async () => { + streamQueue = [ + [{ type: "text_delta" as const, text: "done" }, { type: "done" as const }], + ]; + const seenChoices: unknown[] = []; + const capturingAdapter: ProviderAdapter = { + ...mockAdapter, + buildRequest: async (parsed) => { + buildRequestCalls++; + seenChoices.push(parsed.options.toolChoice); + return { url: "https://test/v1/chat", method: "POST", headers: {}, body: "{}" }; + }, + }; + const parsed = makeParsed(); + parsed.options.toolChoice = { name: "image_gen" }; + parsed.context.tools = [ + { name: "image_gen", parameters: {}, description: "img", imageGeneration: true }, + { name: "Bash", parameters: {}, description: "shell" }, + ]; + const response = await runWithImageBridge({ parsed, adapter: capturingAdapter, plan, maxRounds: 0 }); + await response.text(); + expect(seenChoices[0]).toBe("auto"); + }); + + test("clampImageMaxRounds bounds hand-edited / fractional values", () => { + expect(clampImageMaxRounds(10000)).toBe(MAX_ROUNDS_HARD_LIMIT); + expect(clampImageMaxRounds(2.9)).toBe(2); + expect(clampImageMaxRounds(-1)).toBe(0); + expect(clampImageMaxRounds(Number.NaN)).toBe(DEFAULT_MAX_ROUNDS); + expect(clampImageMaxRounds(undefined)).toBe(DEFAULT_MAX_ROUNDS); + }); + + test("maxRounds: 10000 is clamped — hits hard limit when every round calls image_gen", async () => { + buildRequestCalls = 0; + streamQueue = []; + for (let i = 0; i < MAX_ROUNDS_HARD_LIMIT; i++) { + streamQueue.push([...imageCallEvents]); + } + // Forced-final pass after the hard cap. + streamQueue.push([{ type: "text_delta" as const, text: "clamped final" }, { type: "done" as const }]); + const response = await runWithImageBridge({ + parsed: makeParsed(), adapter: mockAdapter, plan, maxRounds: 10000, + }); + const sse = await response.text(); + expect(sse).toContain("clamped final"); + // Clamped to 10 → HARD_CAP = 11 upstream requests (10 image rounds + 1 forced final). + expect(buildRequestCalls).toBe(MAX_ROUNDS_HARD_LIMIT + 1); + }); + + test("forced-final strips image aliases from plan.toolNames, not only imageGeneration flag", async () => { + const seenTools: Array = []; + const capturingAdapter: ProviderAdapter = { + ...mockAdapter, + buildRequest: async (parsed) => { + buildRequestCalls++; + seenTools.push(parsed.context.tools?.map(t => t.name)); + return { url: "https://test/v1/chat", method: "POST", headers: {}, body: "{}" }; + }, + }; + streamQueue = [ + [ + { type: "tool_call_start", id: "call_1", name: "image_generation" }, + { type: "tool_call_delta", arguments: '{"prompt":"a cat"}' }, + { type: "tool_call_end" }, + { type: "done" }, + ], + [{ type: "text_delta", text: "done after strip" }, { type: "done" }], + ]; + const aliasPlan = { + ...plan, + toolNames: new Set(["image_generation", "image_gen"]), + } as ImageBridgePlan; + const parsed = makeParsed(); + parsed.context.tools = [ + { name: "image_generation", parameters: {}, description: "hosted" }, + { name: "Bash", parameters: {}, description: "shell" }, + ]; + const response = await runWithImageBridge({ + parsed, adapter: capturingAdapter, plan: aliasPlan, maxRounds: 1, + }); + await response.text(); + // Second request is forceFinal — image_generation must be gone, Bash remains. + expect(seenTools[1]).toEqual(["Bash"]); + }); + + test("parallel image calls share one assistant turn with thinking attached once", async () => { + const seenMessages: unknown[] = []; + const capturingAdapter: ProviderAdapter = { + ...mockAdapter, + buildRequest: async (parsed) => { + buildRequestCalls++; + seenMessages.push(parsed.context.messages.map(m => ({ + role: m.role, + contentTypes: Array.isArray(m.content) + ? m.content.map((c: { type?: string }) => c.type) + : typeof m.content, + }))); + return { url: "https://test/v1/chat", method: "POST", headers: {}, body: "{}" }; + }, + }; + streamQueue = [ + [ + { type: "thinking_delta", thinking: "planning" }, + { type: "thinking_signature", signature: "sig" }, + { type: "tool_call_start", id: "call_a", name: "image_gen" }, + { type: "tool_call_delta", arguments: '{"prompt":"a"}' }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "call_b", name: "image_gen" }, + { type: "tool_call_delta", arguments: '{"prompt":"b"}' }, + { type: "tool_call_end" }, + { type: "done" }, + ], + [{ type: "text_delta", text: "both images ready" }, { type: "done" }], + ]; + const response = await runWithImageBridge({ + parsed: makeParsed(), adapter: capturingAdapter, plan, maxRounds: 1, + }); + await response.text(); + // Second iteration messages should include exactly one assistant turn with thinking + 2 toolCalls. + const second = seenMessages[1] as Array<{ role: string; contentTypes: string[] }>; + const assistants = second.filter(m => m.role === "assistant"); + expect(assistants.length).toBe(1); + expect(assistants[0]!.contentTypes).toEqual(["thinking", "toolCall", "toolCall"]); + }); + + test("multi-block thinking and redacted blocks preserve order and signatures", async () => { + const seenContent: Array<{ type?: string; thinking?: string; signature?: string; redacted?: string[] }>[] = []; + const capturingAdapter: ProviderAdapter = { + ...mockAdapter, + buildRequest: async (parsed) => { + buildRequestCalls++; + const assistant = parsed.context.messages.find(m => m.role === "assistant"); + if (assistant && Array.isArray(assistant.content)) { + seenContent.push(assistant.content as Array<{ type?: string; thinking?: string; signature?: string; redacted?: string[] }>); + } + return { url: "https://test/v1/chat", method: "POST", headers: {}, body: "{}" }; + }, + }; + streamQueue = [ + [ + { type: "redacted_thinking", data: "redacted-a" }, + { type: "thinking_delta", thinking: "first" }, + { type: "thinking_signature", signature: "sig-1" }, + { type: "thinking_delta", thinking: "second" }, + { type: "thinking_signature", signature: "sig-2" }, + { type: "tool_call_start", id: "call_1", name: "image_gen" }, + { type: "tool_call_delta", arguments: '{"prompt":"a cat"}' }, + { type: "tool_call_end" }, + { type: "done" }, + ], + [{ type: "text_delta", text: "ready" }, { type: "done" }], + ]; + const response = await runWithImageBridge({ + parsed: makeParsed(), adapter: capturingAdapter, plan, maxRounds: 1, + }); + await response.text(); + expect(seenContent.length).toBeGreaterThan(0); + const thinkingParts = seenContent[0]!.filter(p => p.type === "thinking"); + expect(thinkingParts).toEqual([ + { type: "thinking", thinking: "", redacted: ["redacted-a"] }, + { type: "thinking", thinking: "first", signature: "sig-1" }, + { type: "thinking", thinking: "second", signature: "sig-2" }, + ]); + }); + + test("onUsage is forwarded from bridge terminal events", async () => { + let seen: unknown = "unset"; + streamQueue = [ + [{ type: "text_delta", text: "hi" }, { type: "done", usage: { inputTokens: 1, outputTokens: 2 } }], + ]; + const response = await runWithImageBridge({ + parsed: makeParsed(), + adapter: mockAdapter, + plan, + onUsage: usage => { seen = usage; }, + }); + await response.text(); + expect(seen).toEqual({ inputTokens: 1, outputTokens: 2 }); + }); + + test("onUsage does not double-count hiddenUsage across image iterations", async () => { + let seen: unknown = "unset"; + streamQueue = [ + [ + { type: "tool_call_start", id: "call_1", name: "image_gen" }, + { type: "tool_call_delta", arguments: '{"prompt":"a cat"}' }, + { type: "tool_call_end" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 4 } }, + ], + [{ type: "text_delta", text: "ready" }, { type: "done", usage: { inputTokens: 3, outputTokens: 2 } }], + ]; + const response = await runWithImageBridge({ + parsed: makeParsed(), + adapter: mockAdapter, + plan, + maxRounds: 1, + onUsage: usage => { seen = usage; }, + }); + await response.text(); + // Hidden iter (10/4) + final (3/2) once — not 2*hidden + final. + expect(seen).toEqual({ inputTokens: 13, outputTokens: 6 }); + }); + + test("429 key rotation rebuilds the adapter and retries the iteration", async () => { + let fetchCalls = 0; + let rotations = 0; + let activeAdapter: ProviderAdapter | undefined; + const makeRotatingAdapter = (label: string): ProviderAdapter => ({ + name: label, + buildRequest: async () => ({ url: "https://test/v1/chat", method: "POST", headers: {}, body: "{}" }), + fetchResponse: async () => { + fetchCalls++; + if (fetchCalls === 1) return new Response("rate limited", { status: 429, headers: { "retry-after": "1" } }); + streamQueue = [[{ type: "text_delta", text: "after rotate" }, { type: "done" }]]; + return new Response("{}", { status: 200 }); + }, + parseStream: async function* (): AsyncGenerator { + const events = streamQueue.shift(); + if (events) for (const e of events) yield e; + }, + }); + const firstAdapter = makeRotatingAdapter("before-rotate"); + const secondAdapter = makeRotatingAdapter("after-rotate"); + activeAdapter = firstAdapter; + const response = await runWithImageBridge({ + parsed: makeParsed(), + adapter: firstAdapter, + plan, + on429: () => { + rotations++; + activeAdapter = secondAdapter; + return secondAdapter; + }, + }); + const sse = await response.text(); + expect(rotations).toBe(1); + expect(fetchCalls).toBe(2); + expect(activeAdapter).toBe(secondAdapter); + expect(sse).toContain("after rotate"); + }); +}); + +// --------------------------------------------------------------------------- +// runTurn adapter path (Cursor) — events arrive via an emit callback, not +// buildRequest/fetchResponse/parseStream. +// --------------------------------------------------------------------------- + +describe("runWithImageBridge — runTurn adapter", () => { + let runTurnEventQueue: AdapterEvent[][] = []; + const runTurnAdapter: ProviderAdapter = { + ...mockAdapter, + runTurn: async (_parsed: OcxParsedRequest, _incoming: IncomingMeta, emit: (e: AdapterEvent) => void) => { + const events = runTurnEventQueue.shift(); + if (events) for (const e of events) emit(e); + }, + }; + + test("runTurn adapter → image call intercepted and fulfilled", async () => { + runTurnEventQueue = [ + [...imageCallEvents], + [{ type: "text_delta", text: "Here is your image" }, { type: "done" }], + ]; + fulfillResult = { + ok: true, model: "grok-imagine-image-quality", prompt: "a cat", + files: ["/test/img.png"], count: 1, markdown: "![image](/test/img.png)", + }; + const response = await runWithImageBridge({ + parsed: makeParsed(), adapter: runTurnAdapter, plan, maxRounds: 1, + }); + const sse = await response.text(); + expect(sse).toContain("Here is your image"); + // The synthetic image_gen tool call must NOT leak to the client + expect(sse).not.toContain("image_gen"); + expect(sse).not.toContain("tool_call_start"); + }); + + test("runTurn adapter → text passthrough (no image call)", async () => { + runTurnEventQueue = [ + [{ type: "text_delta", text: "hello from runTurn" }, { type: "done" }], + ]; + const response = await runWithImageBridge({ parsed: makeParsed(), adapter: runTurnAdapter, plan }); + const sse = await response.text(); + expect(sse).toContain("hello from runTurn"); + }); + + test("runTurn adapter → error event surfaces as upstream failure", async () => { + runTurnEventQueue = [ + [{ type: "error", message: "cursor blew up" }], + ]; + const response = await runWithImageBridge({ parsed: makeParsed(), adapter: runTurnAdapter, plan }); + const sse = await response.text(); + expect(sse).toContain("cursor blew up"); + }); + + test("runTurn adapter → SSE headers return before slow collect completes", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const slowAdapter: ProviderAdapter = { + ...mockAdapter, + runTurn: async (_parsed, _incoming, emit) => { + await gate; + emit({ type: "text_delta", text: "slow ok" }); + emit({ type: "done" }); + }, + }; + const responsePromise = runWithImageBridge({ parsed: makeParsed(), adapter: slowAdapter, plan }); + // Headers must resolve without waiting for runTurn to finish. + const response = await responsePromise; + expect(response.headers.get("content-type")).toBe("text/event-stream"); + release(); + const sse = await response.text(); + expect(sse).toContain("slow ok"); + }); + + test("runTurn adapter → stall deadline aborts the in-flight runTurn signal", async () => { + let seenSignal: AbortSignal | undefined; + let aborted = false; + const hangingAdapter: ProviderAdapter = { + ...mockAdapter, + runTurn: async (_parsed, incoming) => { + seenSignal = incoming.abortSignal; + await new Promise((resolve, reject) => { + if (!incoming.abortSignal) { + reject(new Error("missing abortSignal")); + return; + } + if (incoming.abortSignal.aborted) { + aborted = true; + resolve(); + return; + } + incoming.abortSignal.addEventListener("abort", () => { + aborted = true; + resolve(); + }, { once: true }); + }); + }, + }; + const response = await runWithImageBridge({ + parsed: makeParsed(), + adapter: hangingAdapter, + plan, + // Short stall so the collect deadline wins without waiting on real upstream silence. + stallTimeoutSec: 0.05, + }); + const sse = await response.text(); + expect(seenSignal).toBeDefined(); + expect(aborted).toBe(true); + expect(sse).toContain("runTurn inactivity timeout"); + }); + + test("runTurn adapter → idle timeout completes when runTurn ignores abort and never settles", async () => { + // Regression: aborting internalAbort alone is not enough — if runTurn never observes + // cancellation and never closes the queue, queue.stream() would hang forever. The idle + // handler must close the queue to unblock the consumer independently. + const neverSettlingAdapter: ProviderAdapter = { + ...mockAdapter, + runTurn: async () => { + await new Promise(() => { /* never settles; ignores abort entirely */ }); + }, + }; + const started = Date.now(); + const response = await runWithImageBridge({ + parsed: makeParsed(), + adapter: neverSettlingAdapter, + plan, + stallTimeoutSec: 0.05, + }); + const sse = await response.text(); + const elapsedMs = Date.now() - started; + const timeoutFrames = sse.split("\n\n").filter(frame => frame.includes("runTurn inactivity timeout")); + expect(timeoutFrames).toHaveLength(1); + expect(timeoutFrames[0]).toContain("response.failed"); + // Must finish near the idle deadline, not hang on the never-settling runTurn. + expect(elapsedMs).toBeLessThan(2_000); + }); + + test("runTurn adapter → continuous progress resets the idle stall deadline", async () => { + // Wall-clock for the whole turn exceeds stallTimeoutSec, but each idle gap is shorter. + const progressingAdapter: ProviderAdapter = { + ...mockAdapter, + runTurn: async (_parsed, incoming, emit) => { + for (let i = 0; i < 6; i++) { + if (incoming.abortSignal?.aborted) return; + emit({ type: "text_delta", text: `chunk${i}` }); + await new Promise(r => setTimeout(r, 40)); + } + if (!incoming.abortSignal?.aborted) emit({ type: "done" }); + }, + }; + const response = await runWithImageBridge({ + parsed: makeParsed(), + adapter: progressingAdapter, + plan, + stallTimeoutSec: 0.1, // 100ms idle; total emit span ~240ms + }); + const sse = await response.text(); + expect(sse).toContain("chunk5"); + expect(sse).not.toContain("inactivity timeout"); + }); + + test("runTurn adapter → preserves _cursorConversationId across iterations", async () => { + const seenIds: Array = []; + const cursorAdapter: ProviderAdapter = { + ...mockAdapter, + runTurn: async (parsed, _incoming, emit) => { + seenIds.push(parsed._cursorConversationId); + if (!parsed._cursorConversationId) { + parsed._cursorConversationId = "conv-from-first-turn"; + } + const events = runTurnEventQueue.shift(); + if (events) for (const e of events) emit(e); + }, + }; + runTurnEventQueue = [ + [...imageCallEvents], + [{ type: "text_delta", text: "second turn" }, { type: "done" }], + ]; + const parsed = makeParsed(); + const response = await runWithImageBridge({ + parsed, adapter: cursorAdapter, plan, maxRounds: 1, + }); + await response.text(); + expect(seenIds[0]).toBeUndefined(); + expect(seenIds[1]).toBe("conv-from-first-turn"); + expect(parsed._cursorConversationId).toBe("conv-from-first-turn"); + }); +}); diff --git a/tests/images/pinned-https-get.test.ts b/tests/images/pinned-https-get.test.ts new file mode 100644 index 000000000..e3d1f8017 --- /dev/null +++ b/tests/images/pinned-https-get.test.ts @@ -0,0 +1,321 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, mock, test } from "bun:test"; + +type LookupCb = + | ((err: Error | null, address: string, family: number) => void) + | ((err: Error | null, addresses: { address: string; family: number }[]) => void); + +/** + * Capture the custom lookup + response-stream path used by pinnedHttpsGet without + * opening a real TLS socket (Windows CI friendly). + */ +function installHttpsMock(bodyChunks: Buffer[], statusCode = 200) { + const requestMock = mock(( + _options: unknown, + onResponse?: (res: EventEmitter & { statusCode: number; headers: Record; setTimeout: Function; resume: Function }) => void, + ) => { + const req = new EventEmitter() as EventEmitter & { + setTimeout: Function; + end: Function; + destroy: Function; + destroyed: boolean; + }; + req.destroyed = false; + req.setTimeout = mock(() => {}); + req.destroy = mock(() => { req.destroyed = true; }); + req.end = mock(() => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + setTimeout: Function; + resume: Function; + }; + res.statusCode = statusCode; + res.headers = { "content-type": "image/png" }; + res.setTimeout = mock(() => {}); + res.resume = mock(() => {}); + queueMicrotask(() => { + onResponse?.(res); + queueMicrotask(() => { + for (const chunk of bodyChunks) res.emit("data", chunk); + res.emit("end"); + }); + }); + }); + return req; + }); + + mock.module("node:https", () => ({ + default: { request: requestMock }, + request: requestMock, + })); + + return requestMock; +} + +describe("pinnedHttpsGet transport", () => { + test("lookup honors scalar and { all: true } callback shapes", async () => { + let capturedLookup: ((hostname: string, opts: unknown, cb?: LookupCb) => void) | undefined; + const requestMock = mock((options: { lookup?: typeof capturedLookup }, onResponse?: Function) => { + capturedLookup = options.lookup; + const req = new EventEmitter() as EventEmitter & { setTimeout: Function; end: Function; destroy: Function }; + req.setTimeout = () => {}; + req.destroy = () => {}; + req.end = () => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + setTimeout: Function; + resume: Function; + }; + res.statusCode = 200; + res.headers = {}; + res.setTimeout = () => {}; + res.resume = () => {}; + queueMicrotask(() => { + onResponse?.(res); + queueMicrotask(() => res.emit("end")); + }); + }; + return req; + }); + mock.module("node:https", () => ({ default: { request: requestMock }, request: requestMock })); + + const { pinnedHttpsGet } = await import("../../src/images/artifacts"); + const pinned = { address: "93.184.216.34", family: 4 }; + const respPromise = pinnedHttpsGet("https://cdn.example/img.png", pinned); + // Give request() a tick to store lookup. + await Promise.resolve(); + expect(capturedLookup).toBeTypeOf("function"); + + let scalar: { address?: string; family?: number } = {}; + capturedLookup!("cdn.example", {}, ((err, address, family) => { + expect(err).toBeNull(); + scalar = { address: address as string, family: family as number }; + }) as LookupCb); + expect(scalar).toEqual({ address: "93.184.216.34", family: 4 }); + + let allAddrs: { address: string; family: number }[] | undefined; + capturedLookup!("cdn.example", { all: true }, ((err, addresses) => { + expect(err).toBeNull(); + allAddrs = addresses as { address: string; family: number }[]; + }) as LookupCb); + expect(allAddrs).toEqual([{ address: "93.184.216.34", family: 4 }]); + + const resp = await respPromise; + expect(resp.ok).toBe(true); + await resp.arrayBuffer(); // drain stream + }); + + test("exceeding maxBytes aborts mid-stream without buffering the full body", async () => { + const small = Buffer.alloc(1024, 1); + const chunks = [small, small, small]; // 3 KiB total + installHttpsMock(chunks); + + const { pinnedHttpsGet } = await import("../../src/images/artifacts"); + const maxBytes = 1500; // trip on the second chunk + const resp = await pinnedHttpsGet( + "https://cdn.example/big.png", + { address: "93.184.216.34", family: 4 }, + undefined, + { maxBytes }, + ); + expect(resp.body).toBeTruthy(); + const reader = resp.body!.getReader(); + let sawError = false; + let received = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + received += value.byteLength; + } + } catch { + sawError = true; + } + expect(sawError).toBe(true); + // Must fail before absorbing all three chunks (3 KiB). + expect(received).toBeLessThan(chunks.reduce((n, c) => n + c.byteLength, 0)); + expect(received).toBeLessThanOrEqual(maxBytes + small.byteLength); + }); + + test("3xx redirect status rejects without following", async () => { + let resDestroyed = false; + const requestMock = mock(( + _options: unknown, + onResponse?: (res: EventEmitter & { + statusCode: number; + headers: Record; + destroy: () => void; + }) => void, + ) => { + const req = new EventEmitter() as EventEmitter & { setTimeout: () => void; end: () => void; destroy: () => void }; + req.setTimeout = () => {}; + req.destroy = () => {}; + req.end = () => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + destroy: () => void; + }; + res.statusCode = 301; + res.headers = { location: "https://evil.example/redirect" }; + res.destroy = () => { resDestroyed = true; }; + queueMicrotask(() => onResponse?.(res)); + }; + return req; + }); + mock.module("node:https", () => ({ default: { request: requestMock }, request: requestMock })); + + const { pinnedHttpsGet } = await import("../../src/images/artifacts"); + await expect(pinnedHttpsGet( + "https://cdn.example/redirect.png", + { address: "93.184.216.34", family: 4 }, + )).rejects.toThrow(/301/); + expect(resDestroyed).toBe(true); + }); + + test("non-2xx destroys the transport immediately without buffering body chunks", async () => { + // Regression: a 500 that keeps emitting must not resolve a streaming Response + // whose body nobody will read — destroy on status and reject before any data + // listener is attached. + let dataListeners = 0; + let reqDestroyed = false; + let resDestroyed = false; + const requestMock = mock(( + _options: unknown, + onResponse?: (res: EventEmitter & { + statusCode: number; + headers: Record; + setTimeout: Function; + resume: Function; + destroy: Function; + on: Function; + }) => void, + ) => { + const req = new EventEmitter() as EventEmitter & { + setTimeout: Function; + end: Function; + destroy: Function; + }; + req.setTimeout = mock(() => {}); + req.destroy = mock(() => { reqDestroyed = true; }); + req.end = mock(() => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + setTimeout: Function; + resume: Function; + destroy: Function; + }; + res.statusCode = 500; + res.headers = { "content-type": "text/plain" }; + res.setTimeout = mock(() => {}); + res.resume = mock(() => {}); + res.destroy = mock(() => { resDestroyed = true; }); + const originalOn = res.on.bind(res); + res.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => { + if (event === "data") dataListeners += 1; + return originalOn(event, listener); + }) as typeof res.on; + queueMicrotask(() => { + onResponse?.(res); + // Keep dumping body after headers — must not be buffered by pinnedHttpsGet. + queueMicrotask(() => { + for (let i = 0; i < 32; i++) res.emit("data", Buffer.alloc(64 * 1024, 7)); + res.emit("end"); + }); + }); + }); + return req; + }); + mock.module("node:https", () => ({ default: { request: requestMock }, request: requestMock })); + + const { pinnedHttpsGet } = await import("../../src/images/artifacts"); + await expect(pinnedHttpsGet( + "https://cdn.example/fail.png", + { address: "93.184.216.34", family: 4 }, + )).rejects.toThrow(/image download failed: 500/); + + expect(resDestroyed).toBe(true); + expect(reqDestroyed).toBe(true); + expect(dataListeners).toBe(0); + }); + + test("idle timeout fires when no AbortSignal is supplied", async () => { + const requestMock = mock(( + _options: unknown, + _onResponse?: Function, + ) => { + const req = new EventEmitter() as EventEmitter & { + setTimeout: (ms: number, cb: () => void) => void; + end: Function; + destroy: Function; + }; + req.destroy = mock(() => {}); + req.setTimeout = (_ms, cb) => { queueMicrotask(cb); }; + req.end = mock(() => { /* never respond */ }); + return req; + }); + mock.module("node:https", () => ({ default: { request: requestMock }, request: requestMock })); + + const { pinnedHttpsGet } = await import("../../src/images/artifacts"); + await expect(pinnedHttpsGet( + "https://cdn.example/hang.png", + { address: "93.184.216.34", family: 4 }, + undefined, + { idleTimeoutMs: 1 }, + )).rejects.toThrow(/timed out/); + }); + + test("forwards idleTimeoutMs to request and response socket timers", async () => { + let reqIdleMs: number | undefined; + let resIdleMs: number | undefined; + const requestMock = mock(( + _options: unknown, + onResponse?: (res: EventEmitter & { + statusCode: number; + headers: Record; + setTimeout: (ms: number, cb: () => void) => void; + resume: () => void; + }) => void, + ) => { + const req = new EventEmitter() as EventEmitter & { + setTimeout: (ms: number, cb: () => void) => void; + end: () => void; + destroy: () => void; + }; + req.destroy = () => {}; + req.setTimeout = (ms) => { reqIdleMs = ms; }; + req.end = () => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + setTimeout: (ms: number, cb: () => void) => void; + resume: () => void; + }; + res.statusCode = 200; + res.headers = { "content-type": "image/png" }; + res.resume = () => {}; + res.setTimeout = (ms) => { resIdleMs = ms; }; + queueMicrotask(() => { + onResponse?.(res); + queueMicrotask(() => res.emit("end")); + }); + }; + return req; + }); + mock.module("node:https", () => ({ default: { request: requestMock }, request: requestMock })); + + const { pinnedHttpsGet } = await import("../../src/images/artifacts"); + const resp = await pinnedHttpsGet( + "https://cdn.example/img.png", + { address: "93.184.216.34", family: 4 }, + undefined, + { idleTimeoutMs: 12_345 }, + ); + expect(reqIdleMs).toBe(12_345); + expect(resIdleMs).toBe(12_345); + await resp.arrayBuffer(); + }); +}); diff --git a/tests/images/plan.test.ts b/tests/images/plan.test.ts new file mode 100644 index 000000000..a25f21998 --- /dev/null +++ b/tests/images/plan.test.ts @@ -0,0 +1,189 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import type { OcxConfig, OcxProviderConfig, OcxParsedRequest } from "../../src/types"; + +const PREV_HOME = process.env.OPENCODEX_HOME; +let planImageBridge: typeof import("../../src/images/plan")["planImageBridge"]; +let MAX_IMAGE_TIMEOUT_MS: typeof import("../../src/images/plan")["MAX_IMAGE_TIMEOUT_MS"]; + +/** Mutable token that the mocked getValidAccessToken resolves to. */ +let tokenResult: string | null = null; + +beforeAll(async () => { + process.env.OPENCODEX_HOME = join(tmpdir(), "ocx-test-" + randomUUID()); + const actualOauth = await import("../../src/oauth/index"); + mock.module("../../src/oauth/index", () => ({ + ...actualOauth, + getValidAccessToken: async () => tokenResult, + })); + ({ planImageBridge, MAX_IMAGE_TIMEOUT_MS } = await import("../../src/images/plan")); +}); +afterAll(() => { if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; mock.restore(); }); + +beforeEach(() => { + tokenResult = null; +}); + +function makeConfig( + providers: Record>, + images?: { bridgeEnabled?: boolean; bridgeModel?: string; timeoutMs?: number }, +): OcxConfig { + return { + port: 0, + defaultProvider: "test", + providers: Object.fromEntries( + Object.entries(providers).map(([k, v]) => [k, { adapter: "openai-chat", baseUrl: "https://api.test.com", ...v }]), + ), + ...(images ? { images } : {}), + } as OcxConfig; +} + +function makeParsed(withImageGen: boolean): OcxParsedRequest { + return { + modelId: "test-model", + context: { messages: [], tools: [] }, + stream: true, + options: {}, + ...(withImageGen ? { _imageGeneration: { toolNames: new Set(["image_gen"]) } } : {}), + } as OcxParsedRequest; +} + +const routed = { adapter: "openai-chat", baseUrl: "https://api.anthropic.com" } as OcxProviderConfig; +const openaiRouted = { adapter: "openai-chat", baseUrl: "https://api.openai.com" } as OcxProviderConfig; + +describe("planImageBridge", () => { + test("bridgeEnabled false → undefined", async () => { + expect(await planImageBridge(makeConfig({ test: routed }, { bridgeEnabled: false }), makeParsed(true), routed)).toBeUndefined(); + }); + + test("bridgeEnabled not set → undefined (opt-in required)", async () => { + // xAI provider configured but images.bridgeEnabled is absent — must not bridge. + const cfg = makeConfig({ xai: { baseUrl: "https://api.x.ai", apiKey: "test-token" } }); + expect(await planImageBridge(cfg, makeParsed(true), routed)).toBeUndefined(); + }); + + test("_imageGeneration not set → undefined", async () => { + expect(await planImageBridge(makeConfig({ test: routed }, { bridgeEnabled: true }), makeParsed(false), routed)).toBeUndefined(); + }); + + test("routedProvider is api.openai.com → undefined", async () => { + const cfg = makeConfig({ xai: { baseUrl: "https://api.x.ai", apiKey: "test-token" } }, { bridgeEnabled: true }); + expect(await planImageBridge(cfg, makeParsed(true), openaiRouted)).toBeUndefined(); + }); + + test("no xAI provider → undefined", async () => { + expect(await planImageBridge(makeConfig({ test: routed }, { bridgeEnabled: true }), makeParsed(true), routed)).toBeUndefined(); + }); + + test("xAI provider but apiKey empty and no OAuth → undefined", async () => { + tokenResult = null; + const cfg = makeConfig({ xai: { baseUrl: "https://api.x.ai", apiKey: "" } }, { bridgeEnabled: true }); + expect(await planImageBridge(cfg, makeParsed(true), routed)).toBeUndefined(); + }); + + test("xAI provider with API key → returns plan with correct model", async () => { + const cfg = makeConfig({ xai: { baseUrl: "https://api.x.ai", apiKey: "test-token" } }, { bridgeEnabled: true }); + const plan = await planImageBridge(cfg, makeParsed(true), routed); + expect(plan).toBeDefined(); + expect(plan!.model).toBe("grok-imagine-image-quality"); + expect(plan!.auth.token).toBe("test-token"); + expect(plan!.auth.baseUrl).toBe("https://api.x.ai/v1"); + }); + + test("xAI provider with OAuth only (no API key) → undefined (API-key-only bridge)", async () => { + tokenResult = "fake-oauth-123"; + const cfg = makeConfig({ xai: { baseUrl: "https://api.x.ai" } }, { bridgeEnabled: true }); + expect(await planImageBridge(cfg, makeParsed(true), routed)).toBeUndefined(); + tokenResult = null; + }); + + test("custom-named provider with api.x.ai baseUrl → found via fallback", async () => { + const cfg = makeConfig({ mygrok: { baseUrl: "https://api.x.ai", apiKey: "test-token" } }, { bridgeEnabled: true }); + const plan = await planImageBridge(cfg, makeParsed(true), routed); + expect(plan).toBeDefined(); + expect(plan!.provider).toBe(cfg.providers.mygrok); + }); + + test("custom bridgeModel is honored", async () => { + const cfg = makeConfig( + { xai: { baseUrl: "https://api.x.ai", apiKey: "test-token" } }, + { bridgeEnabled: true, bridgeModel: "custom-img-model" }, + ); + expect((await planImageBridge(cfg, makeParsed(true), routed))!.model).toBe("custom-img-model"); + }); + + test("images.timeoutMs is forwarded onto the plan", async () => { + const cfg = makeConfig( + { xai: { baseUrl: "https://api.x.ai", apiKey: "test-token" } }, + { bridgeEnabled: true, timeoutMs: 120_000 }, + ); + const plan = await planImageBridge(cfg, makeParsed(true), routed); + expect(plan!.timeoutMs).toBe(120_000); + }); + + test("images.timeoutMs above ceiling is clamped", async () => { + const cfg = makeConfig( + { xai: { baseUrl: "https://api.x.ai", apiKey: "test-token" } }, + { bridgeEnabled: true, timeoutMs: 999_999_999 }, + ); + const plan = await planImageBridge(cfg, makeParsed(true), routed); + expect(plan!.timeoutMs).toBe(MAX_IMAGE_TIMEOUT_MS); + }); + + test("toolNames includes IMAGE_GEN_TOOL_NAME so the loop can intercept synthetic calls", async () => { + const { IMAGE_GEN_TOOL_NAME } = await import("../../src/images/synthetic-tool"); + const cfg = makeConfig({ xai: { baseUrl: "https://api.x.ai", apiKey: "test-token" } }, { bridgeEnabled: true }); + const plan = await planImageBridge(cfg, makeParsed(true), routed); + expect(plan).toBeDefined(); + // The plan always merges in IMAGE_GEN_TOOL_NAME, even if _imageGeneration.toolNames + // only contained the original hosted tool name. + expect(plan!.toolNames.has(IMAGE_GEN_TOOL_NAME)).toBe(true); + }); + + test("baseUrl is pinned to registry regardless of config override", async () => { + // config 里 xai provider 的 baseUrl 被改成恶意 host + const cfg = makeConfig( + { xai: { adapter: "openai-chat", baseUrl: "https://evil.example.com/v1", apiKey: "test-key" } }, + { bridgeEnabled: true }, + ); + const plan = await planImageBridge(cfg, makeParsed(true), routed); + expect(plan).toBeDefined(); + // auth.baseUrl 必须是 registry pin 的地址,不是 config 里的恶意地址 + expect(plan!.auth.baseUrl).toBe("https://api.x.ai/v1"); + }); + + test("custom-named provider with api.x.ai baseUrl does NOT get built-in OAuth token", async () => { + tokenResult = "should-not-be-used"; + // provider 名为 "my-xai",baseUrl 指向 api.x.ai,没有 apiKey + const cfg = makeConfig( + { "my-xai": { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1" } }, + { bridgeEnabled: true }, + ); + const plan = await planImageBridge(cfg, makeParsed(true), routed); + // 没有 apiKey 也没有 "xai" 的 OAuth → 没有 token → 没有 plan + expect(plan).toBeUndefined(); + tokenResult = null; + }); + + test("authMode oauth does not arm the bridge even with a stale apiKey", async () => { + tokenResult = "oauth-token"; + const cfg = makeConfig( + { xai: { baseUrl: "https://api.x.ai/v1", apiKey: "stale-key", authMode: "oauth" } }, + { bridgeEnabled: true }, + ); + expect(await planImageBridge(cfg, makeParsed(true), routed)).toBeUndefined(); + tokenResult = null; + }); + + test("authMode key does not fall back to stored OAuth", async () => { + tokenResult = "oauth-token"; + const cfg = makeConfig( + { xai: { baseUrl: "https://api.x.ai/v1", apiKey: "", authMode: "key" } }, + { bridgeEnabled: true }, + ); + expect(await planImageBridge(cfg, makeParsed(true), routed)).toBeUndefined(); + tokenResult = null; + }); +}); diff --git a/tests/images/synthetic-tool.test.ts b/tests/images/synthetic-tool.test.ts new file mode 100644 index 000000000..24e1fc931 --- /dev/null +++ b/tests/images/synthetic-tool.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { isImageGenName, extractHostedImageGeneration, buildImageTool } from "../../src/images/synthetic-tool"; + +describe("isImageGenName", () => { + test("'image_gen' → true", () => { + expect(isImageGenName("image_gen")).toBe(true); + }); + + test("'IMAGE_GENERATION' → true (case insensitive)", () => { + expect(isImageGenName("IMAGE_GENERATION")).toBe(true); + }); + + test("'imagegen' → false without hosted image tool context", () => { + expect(isImageGenName("imagegen")).toBe(false); + }); + + test("'not_image' → false", () => { + expect(isImageGenName("not_image")).toBe(false); + }); +}); + +describe("extractHostedImageGeneration", () => { + test("type 'image_generation' → returns toolNames with that name", () => { + const result = extractHostedImageGeneration([{ type: "image_generation" }]); + expect(result).toBeDefined(); + expect(result!.toolNames.has("image_generation")).toBe(true); + }); + + test("flat Responses function tool with 'image_gen' name alone → does not activate", () => { + const result = extractHostedImageGeneration([ + { type: "function", name: "image_gen", parameters: { type: "object" } }, + ]); + expect(result).toBeUndefined(); + }); + + test("nested Chat Completions function tool with 'image_gen' name alone → does not activate", () => { + const result = extractHostedImageGeneration([ + { type: "function", function: { name: "image_gen" } }, + ]); + expect(result).toBeUndefined(); + }); + + test("no matching tools → undefined", () => { + expect( + extractHostedImageGeneration([{ type: "function", function: { name: "shell" } }]), + ).toBeUndefined(); + }); + + test("generate_image alone → undefined (alias requires hosted entry)", () => { + expect( + extractHostedImageGeneration([{ type: "function", name: "generate_image", parameters: { type: "object" } }]), + ).toBeUndefined(); + }); + + test("generate_image with hosted image_generation → matched for strip/replace", () => { + const result = extractHostedImageGeneration([ + { type: "image_generation" }, + { type: "function", name: "generate_image", parameters: { type: "object" } }, + ]); + expect(result).toBeDefined(); + expect(result!.toolNames.has("generate_image")).toBe(true); + expect(result!.toolNames.has("image_generation")).toBe(true); + }); + + test("client image_gen with hosted image_generation → both collected", () => { + const result = extractHostedImageGeneration([ + { type: "image_generation" }, + { type: "function", name: "image_gen", parameters: { type: "object" } }, + ]); + expect(result).toBeDefined(); + expect(result!.toolNames.has("image_gen")).toBe(true); + expect(result!.toolNames.has("image_generation")).toBe(true); + }); + + test("undefined → undefined", () => { + expect(extractHostedImageGeneration(undefined)).toBeUndefined(); + }); +}); + +describe("buildImageTool", () => { + test("has name 'image_gen' and imageGeneration flag", () => { + const tool = buildImageTool(); + expect(tool.name).toBe("image_gen"); + expect(tool.imageGeneration).toBe(true); + }); +}); diff --git a/tests/images/xai-client.test.ts b/tests/images/xai-client.test.ts new file mode 100644 index 000000000..cec8f055e --- /dev/null +++ b/tests/images/xai-client.test.ts @@ -0,0 +1,161 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { callXaiImages } from "../../src/images/xai-client"; + +const PREV_HOME = process.env.OPENCODEX_HOME; +beforeAll(() => { process.env.OPENCODEX_HOME = join(tmpdir(), "ocx-test-" + randomUUID()); }); +afterAll(() => { if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; }); + +const AUTH = { baseUrl: "https://api.x.ai", token: "test-token" }; +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +/** Replace globalThis.fetch with a stub that captures the request and returns a canned response. */ +function stubFetch(status: number, body: unknown): { url: string; init?: RequestInit }[] { + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: input.toString(), init }); + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return calls; +} + +describe("callXaiImages", () => { + test("no imageUrl → POST /images/generations", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "a cat" }, AUTH); + expect(calls[0]!.url).toContain("/images/generations"); + expect(calls[0]!.init?.method).toBe("POST"); + }); + + test("with imageUrl → POST /images/edits", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "edit this", imageUrl: "https://example.com/img.png" }, AUTH); + expect(calls[0]!.url).toContain("/images/edits"); + }); + + test("request body has correct model, prompt, n", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "a dog", model: "grok-imagine-fast", n: 3 }, AUTH); + const body = JSON.parse((calls[0]!.init?.body as string) ?? "{}"); + expect(body.model).toBe("grok-imagine-fast"); + expect(body.prompt).toBe("a dog"); + expect(body.n).toBe(3); + }); + + test("non-2xx → throws Error containing status code", async () => { + stubFetch(429, { error: "rate limited" }); + await expect(callXaiImages({ prompt: "x" }, AUTH)).rejects.toThrow("429"); + }); + + test("non-2xx cancels the response body before throwing", async () => { + let cancelled = false; + globalThis.fetch = (async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"error":"rate limited"}')); + }, + cancel() { + cancelled = true; + }, + }); + return new Response(stream, { status: 429, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + await expect(callXaiImages({ prompt: "x" }, AUTH)).rejects.toThrow("429"); + expect(cancelled).toBe(true); + }); + + test("2xx with b64_json → returns normalized XaiImageResult", async () => { + stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + const result = await callXaiImages({ prompt: "x" }, AUTH); + expect(result.images.length).toBe(1); + expect(result.images[0]!.b64_json).toBe("dGVzdA=="); + }); + + test("2xx with url → returns images[0].url", async () => { + stubFetch(200, { data: [{ url: "https://cdn.example.com/img.png" }] }); + const result = await callXaiImages({ prompt: "x" }, AUTH); + expect(result.images[0]!.url).toBe("https://cdn.example.com/img.png"); + }); + + test("caller abort propagates into the composed signal", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + const controller = new AbortController(); + await callXaiImages({ prompt: "x" }, AUTH, controller.signal); + const passed = calls[0]!.init?.signal as AbortSignal; + expect(passed.aborted).toBe(false); + controller.abort("client gone"); + expect(passed.aborted).toBe(true); + }); + + test("custom timeoutMs is composed into the abort signal", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "x" }, AUTH, undefined, 5_000); + const passed = calls[0]!.init?.signal as AbortSignal; + expect(passed).toBeDefined(); + expect(passed.aborted).toBe(false); + }); + + test("timeoutMs composes a deadline that aborts the fetch signal", async () => { + let seenSignal: AbortSignal | undefined; + globalThis.fetch = (async (_input, init) => { + seenSignal = init?.signal; + return new Response(JSON.stringify({ data: [{ b64_json: "dGVzdA==" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + await callXaiImages({ prompt: "x" }, AUTH, undefined, 50); + expect(seenSignal).toBeDefined(); + expect(seenSignal!.aborted).toBe(false); + await new Promise(resolve => setTimeout(resolve, 60)); + expect(seenSignal!.aborted).toBe(true); + }); + + test("trailing slash on baseUrl does not produce double-slash URL", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "x" }, { baseUrl: "https://api.x.ai/v1/", token: "test-token" }); + expect(calls[0]!.url).toBe("https://api.x.ai/v1/images/generations"); + }); + + test("size/quality mapped to aspect_ratio/resolution, no passthrough", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "x", size: "1024x1792", quality: "hd" }, AUTH); + const body = JSON.parse((calls[0]!.init?.body as string) ?? "{}"); + expect(body.aspect_ratio).toBe("9:16"); + expect(body.resolution).toBe("2k"); + expect(body).not.toHaveProperty("size"); + expect(body).not.toHaveProperty("quality"); + }); + + test("square size → 1:1", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "x", size: "1024x1024" }, AUTH); + const body = JSON.parse((calls[0]!.init?.body as string) ?? "{}"); + expect(body.aspect_ratio).toBe("1:1"); + expect(body).not.toHaveProperty("resolution"); + }); + + test("quality: standard → 1k", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "x", quality: "standard" }, AUTH); + const body = JSON.parse((calls[0]!.init?.body as string) ?? "{}"); + expect(body.resolution).toBe("1k"); + expect(body).not.toHaveProperty("aspect_ratio"); + }); + + test("unknown size/quality dropped", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "x", size: "weird", quality: "ultra" }, AUTH); + const body = JSON.parse((calls[0]!.init?.body as string) ?? "{}"); + expect(body).not.toHaveProperty("aspect_ratio"); + expect(body).not.toHaveProperty("resolution"); + expect(body).not.toHaveProperty("size"); + expect(body).not.toHaveProperty("quality"); + }); +}); diff --git a/tests/images/z-fulfill.test.ts b/tests/images/z-fulfill.test.ts new file mode 100644 index 000000000..7a3985bb3 --- /dev/null +++ b/tests/images/z-fulfill.test.ts @@ -0,0 +1,251 @@ +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import type { ImageBridgePlan } from "../../src/images/types"; +import type { XaiImageRequest } from "../../src/images/xai-client"; + +const PREV_HOME = process.env.OPENCODEX_HOME; +let fulfillImageCall: typeof import("../../src/images/fulfill")["fulfillImageCall"]; +let testHome = ""; + +beforeAll(async () => { + testHome = join(tmpdir(), "ocx-test-" + randomUUID()); + process.env.OPENCODEX_HOME = testHome; + mock.restore(); + mock.module("../../src/images/xai-client", () => ({ + callXaiImages: async (req: XaiImageRequest, _auth: unknown, _signal?: AbortSignal, timeoutMs?: number) => { + xaiCalls.push(req); + capturedTimeoutMs = timeoutMs; + if (xaiError) throw xaiError; + return xaiResult; + }, + })); + mock.module("../../src/images/artifacts", () => ({ + createImageBudget: () => ({ spent: 0 }), + materializeInlineImage: async () => materializeFn(matIdx++), + downloadImageToArtifact: async () => downloadFn(dlIdx++), + pruneArtifacts: () => pruneImpl(), + })); + ({ fulfillImageCall } = await import(`../../src/images/fulfill?fulfill=${Date.now()}`)); +}); +afterAll(() => { if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; mock.restore(); }); + +// --- Mutable mock state (reset() restores defaults before each test) --- +let xaiResult: { images: Array<{ b64_json?: string; url?: string }> } = { images: [{ b64_json: "dGVzdA==" }] }; +let xaiError: Error | null = null; +const xaiCalls: XaiImageRequest[] = []; +let matIdx = 0; +let dlIdx = 0; +let pruneCalls = 0; +let pruneImpl: () => void = () => { pruneCalls++; }; +let materializeFn: (i: number) => Promise = async (i) => touchArtifact(`img-${i}.png`); +let downloadFn: (i: number) => Promise = async (i) => touchArtifact(`dl-${i}.png`); + +let capturedTimeoutMs: number | undefined; + +function touchArtifact(name: string): string { + const dir = join(testHome || process.env.OPENCODEX_HOME!, "artifacts"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, name); + writeFileSync(path, "x"); + return path; +} + +const plan = { + provider: {} as never, + auth: { baseUrl: "https://api.x.ai", token: "test-token" }, + model: "grok-imagine-image-quality", + toolNames: new Set(["image_gen"]), +} as ImageBridgePlan; + +function reset(): void { + xaiResult = { images: [{ b64_json: "dGVzdA==" }] }; + xaiError = null; + xaiCalls.length = 0; + capturedTimeoutMs = undefined; + matIdx = 0; + dlIdx = 0; + pruneCalls = 0; + pruneImpl = () => { pruneCalls++; }; + materializeFn = async (i) => touchArtifact(`img-${i}.png`); + downloadFn = async (i) => touchArtifact(`dl-${i}.png`); +} + +describe("fulfillImageCall", () => { + test("valid args → ok:true with file", async () => { + reset(); + const r = await fulfillImageCall( + { id: "c1", name: "image_gen", arguments: JSON.stringify({ prompt: "a cat", n: 2 }) }, + plan, { spent: 0 }, + ); + expect(r.ok).toBe(true); + expect(r.files.length).toBe(1); + }); + + test("plan.timeoutMs is forwarded to callXaiImages", async () => { + reset(); + const timedPlan = { ...plan, timeoutMs: 12_345 } as ImageBridgePlan; + await fulfillImageCall( + { id: "c1", name: "image_gen", arguments: JSON.stringify({ prompt: "a cat" }) }, + timedPlan, { spent: 0 }, + ); + expect(capturedTimeoutMs).toBe(12_345); + }); + + test("missing prompt → ok:false 'missing prompt'", async () => { + reset(); + const r = await fulfillImageCall({ id: "c1", name: "image_gen", arguments: "{}" }, plan, { spent: 0 }); + expect(r.ok).toBe(false); + expect(r.error).toBe("missing prompt"); + }); + + test("invalid JSON args → ok:false 'invalid arguments JSON'", async () => { + reset(); + const r = await fulfillImageCall({ id: "c1", name: "image_gen", arguments: "{bad" }, plan, { spent: 0 }); + expect(r.ok).toBe(false); + expect(r.error).toBe("invalid arguments JSON"); + }); + + test("xAI throws → ok:false with error message", async () => { + reset(); + xaiError = new Error("xAI images API returned 500"); + const r = await fulfillImageCall( + { id: "c1", name: "image_gen", arguments: JSON.stringify({ prompt: "x" }) }, plan, { spent: 0 }, + ); + expect(r.ok).toBe(false); + expect(r.error).toContain("500"); + }); + + test("b64_json result → materialized via materializeInlineImage", async () => { + reset(); + xaiResult = { images: [{ b64_json: "dGVzdA==" }] }; + await fulfillImageCall({ id: "c1", name: "image_gen", arguments: `{"prompt":"x"}` }, plan, { spent: 0 }); + expect(matIdx).toBe(1); + expect(dlIdx).toBe(0); + }); + + test("URL result → materialized via downloadImageToArtifact", async () => { + reset(); + xaiResult = { images: [{ url: "https://cdn.example.com/i.png" }] }; + await fulfillImageCall({ id: "c1", name: "image_gen", arguments: `{"prompt":"x"}` }, plan, { spent: 0 }); + expect(dlIdx).toBe(1); + expect(matIdx).toBe(0); + }); + + test("all images fail → ok:false", async () => { + reset(); + materializeFn = async () => { throw new Error("disk full"); }; + const r = await fulfillImageCall({ id: "c1", name: "image_gen", arguments: `{"prompt":"x"}` }, plan, { spent: 0 }); + expect(r.ok).toBe(false); + expect(r.error).toContain("no usable images"); + }); + + test("one of two images fails → ok:true with 1 file", async () => { + reset(); + xaiResult = { images: [{ b64_json: "AAA=" }, { b64_json: "QkI=" }] }; + materializeFn = async (i) => { if (i === 1) throw new Error("partial fail"); return touchArtifact(`img-${i}.png`); }; + const r = await fulfillImageCall({ id: "c1", name: "image_gen", arguments: `{"prompt":"x"}` }, plan, { spent: 0 }); + expect(r.ok).toBe(true); + expect(r.files.length).toBe(1); + }); + + test("prunes once after the full batch and omits deleted paths", async () => { + reset(); + const { unlinkSync } = await import("node:fs"); + xaiResult = { images: [{ b64_json: "AAA=" }, { b64_json: "QkI=" }] }; + const written: string[] = []; + materializeFn = async (i) => { + const path = touchArtifact(`batch-${i}.png`); + written.push(path); + return path; + }; + pruneImpl = () => { + pruneCalls++; + unlinkSync(written[0]!); + }; + const r = await fulfillImageCall( + { id: "c1", name: "image_gen", arguments: `{"prompt":"x"}` }, + { ...plan, artifactsKeepCount: 1 } as ImageBridgePlan, + { spent: 0 }, + ); + expect(pruneCalls).toBe(1); + expect(r.ok).toBe(true); + expect(r.files).toEqual([written[1]]); + expect(r.path).toBe(written[1]); + }); + + test("forwards prompt, model, and n to callXaiImages", async () => { + reset(); + await fulfillImageCall( + { id: "c1", name: "image_gen", arguments: JSON.stringify({ prompt: "a cat", n: 2 }) }, + plan, { spent: 0 }, + ); + expect(xaiCalls.length).toBe(1); + expect(xaiCalls[0]!.prompt).toBe("a cat"); + expect(xaiCalls[0]!.model).toBe(plan.model); + expect(xaiCalls[0]!.n).toBe(2); + }); + + test("clamps n > 4 down to 4", async () => { + reset(); + await fulfillImageCall( + { id: "c1", name: "image_gen", arguments: JSON.stringify({ prompt: "x", n: 100 }) }, + plan, { spent: 0 }, + ); + expect(xaiCalls[0]!.n).toBe(4); + }); + + test("forwards imageUrl from image_url arg", async () => { + reset(); + await fulfillImageCall( + { id: "c1", name: "image_gen", arguments: JSON.stringify({ prompt: "x", image_url: "https://example.com/i.png" }) }, + plan, { spent: 0 }, + ); + expect(xaiCalls[0]!.imageUrl).toBe("https://example.com/i.png"); + }); + + test("plan.defaultSize and defaultQuality fill omitted args", async () => { + reset(); + const sizedPlan = { + ...plan, + defaultSize: "1024x1024", + defaultQuality: "hd", + } as ImageBridgePlan; + await fulfillImageCall( + { id: "c1", name: "image_gen", arguments: JSON.stringify({ prompt: "a cat" }) }, + sizedPlan, { spent: 0 }, + ); + expect(xaiCalls[0]!.size).toBe("1024x1024"); + expect(xaiCalls[0]!.quality).toBe("hd"); + }); + + test("explicit size/quality override plan defaults", async () => { + reset(); + const sizedPlan = { + ...plan, + defaultSize: "1024x1024", + defaultQuality: "hd", + } as ImageBridgePlan; + await fulfillImageCall( + { id: "c1", name: "image_gen", arguments: JSON.stringify({ prompt: "a cat", size: "512x512", quality: "standard" }) }, + sizedPlan, { spent: 0 }, + ); + expect(xaiCalls[0]!.size).toBe("512x512"); + expect(xaiCalls[0]!.quality).toBe("standard"); + }); + + test("markdown uses a file: URI for Windows-safe destinations", async () => { + reset(); + const r = await fulfillImageCall( + { id: "c1", name: "image_gen", arguments: JSON.stringify({ prompt: "a cat" }) }, + plan, { spent: 0 }, + ); + expect(r.ok).toBe(true); + expect(r.path).toBeDefined(); + expect(r.markdown).toMatch(/^!\[image\]\(file:\/\//); + expect(r.files[0]).toBe(r.path); + expect(r.markdown).not.toContain("\\"); + }); +}); diff --git a/tests/images/z-handler-activation.test.ts b/tests/images/z-handler-activation.test.ts new file mode 100644 index 000000000..324b87732 --- /dev/null +++ b/tests/images/z-handler-activation.test.ts @@ -0,0 +1,214 @@ +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import type { ProviderAdapter } from "../../src/adapters/base"; + +/** + * Dispatch-priority regression test for the image bridge (PR #424). + * + * The image bridge and the web-search sidecar are both opt-in dispatch paths in + * handleResponses(). The design contract is "image defers to web-search": when a + * request is eligible for BOTH, the web-search sidecar wins and the image bridge + * must NOT activate. This was previously broken because planImageBridge ran and + * returned before planWebSearch was ever consulted. + * + * These tests drive handleResponses() end-to-end (real parser + real routing + + * real planImageBridge) with only the adapter, the runners, and the web-search + * planner stubbed, so they exercise the actual dispatch ordering in + * src/server/responses/core.ts. + * + * NOTE: Full server-level integration testing of every adapter path is out of + * scope here — the focus is the dispatch priority ordering at the planImageBridge + * / planWebSearch fork (core.ts ~L1516). + */ + +const PREV_HOME = process.env.OPENCODEX_HOME; + +// --- Activation spies, flipped by the stubbed runners --- +let imageBridgeRun = false; +let webSearchRun = false; +/** Whether the stubbed adapter should expose runTurn (simulates Cursor-style adapters). */ +let useRunTurnAdapter = false; +/** Spy: flipped when the stubbed runTurn is actually invoked. */ +let runTurnCalled = false; +/** Controlled return value for the stubbed planWebSearch (truthy ⇒ web-search plan active). */ +let mockWsPlan: unknown = undefined; + +let handleResponses: typeof import("../../src/server/responses")["handleResponses"]; + +beforeAll(async () => { + process.env.OPENCODEX_HOME = join(tmpdir(), "ocx-test-" + randomUUID()); + + const actualResolver = await import("../../src/server/adapter-resolve"); + mock.module("../../src/server/adapter-resolve", () => ({ + ...actualResolver, + resolveAdapter(provider: OcxProviderConfig) { + const base = { + name: "test", + buildRequest: async () => ({ url: provider.baseUrl, method: "POST", headers: {}, body: "" }), + async fetchResponse() { + return new Response("data: {\"type\":\"done\"}\n\n", { + status: 200, headers: { "content-type": "text/event-stream" }, + }); + }, + async *parseStream() { yield { type: "done" as const }; }, + }; + if (useRunTurnAdapter) { + return { + ...base, + async runTurn(_parsed: unknown, _incoming: unknown, emit: (event: { type: string }) => void) { + runTurnCalled = true; + emit({ type: "done" }); + }, + } as ProviderAdapter; + } + return base as ProviderAdapter; + }, + })); + + const actualLoop = await import("../../src/images/loop"); + mock.module("../../src/images/loop", () => ({ + ...actualLoop, + runWithImageBridge: async () => { + imageBridgeRun = true; + return new Response("data: {\"type\":\"done\"}\n\n", { + status: 200, headers: { "content-type": "text/event-stream" }, + }); + }, + })); + + mock.module("../../src/web-search/index", () => ({ + buildWebSearchTool: () => ({ name: "web_search", parameters: { type: "object", properties: {} } }), + WEB_SEARCH_TOOL_NAME: "web_search", + extractHostedWebSearch: (tools: unknown[]) => { + if (!Array.isArray(tools)) return undefined; + for (const t of tools) { + if (t && typeof t === "object" && (t as Record).type === "web_search") { + return { search_context_size: "medium" }; + } + } + return undefined; + }, + runWithWebSearch: async () => { + webSearchRun = true; + return new Response("data: {\"type\":\"done\"}\n\n", { + status: 200, headers: { "content-type": "text/event-stream" }, + }); + }, + planWebSearch: () => mockWsPlan, + shouldResolveOpenAiWebSearchSidecar: () => false, + })); + + ({ handleResponses } = await import("../../src/server/responses")); +}); + +afterAll(() => { + if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = PREV_HOME; + mock.restore(); +}); + +/** Routed (non-OpenAI) keyed provider + an xAI provider with an API key so the real planImageBridge returns a plan. */ +function makeConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { adapter: "openai-chat", baseUrl: "https://fixture.test/v1", authMode: "key", apiKey: "fixture-key" }, + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "xai-test-token" }, + }, + images: { bridgeEnabled: true }, + } as OcxConfig; +} + +function post(stream: boolean, tools: unknown[]): Promise { + return handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", input: "hello", stream, tools }), + }), + makeConfig(), + { model: "", provider: "" } as never, + {}, + ); +} + +describe("image bridge dispatch priority (handler activation)", () => { + test("stream=true + image_generation tool → image bridge activates and returns SSE", async () => { + imageBridgeRun = false; webSearchRun = false; mockWsPlan = undefined; + const res = await post(true, [{ type: "image_generation" }]); + expect(imageBridgeRun).toBe(true); + expect(res.headers.get("content-type")).toBe("text/event-stream"); + }); + + test("stream=false + image_generation tool → 400 (bridge requires stream=true)", async () => { + imageBridgeRun = false; webSearchRun = false; mockWsPlan = undefined; + const res = await post(false, [{ type: "image_generation" }]); + expect(res.status).toBe(400); + expect(imageBridgeRun).toBe(false); + expect((await res.text())).toContain("image bridge requires stream=true"); + }); + + test("dual-tool (image_generation + web_search), both eligible → web-search wins, image bridge deferred", async () => { + imageBridgeRun = false; webSearchRun = false; + mockWsPlan = { backend: "openai" }; + const res = await post(true, [{ type: "web_search" }, { type: "image_generation" }]); + expect(webSearchRun).toBe(true); + expect(imageBridgeRun).toBe(false); + expect(res.headers.get("content-type")).toBe("text/event-stream"); + }); + + test("routed compaction with image_generation tool → image bridge does NOT hijack compaction (#424)", async () => { + imageBridgeRun = false; webSearchRun = false; mockWsPlan = undefined; + const res = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/model", + input: [{ type: "compaction_trigger" }], + stream: true, + tools: [{ type: "image_generation" }], + }), + }), + makeConfig(), + { model: "", provider: "" } as never, + {}, + ); + expect(imageBridgeRun).toBe(false); + expect(res.headers.get("content-type")).toBe("text/event-stream"); + }); + + test("dual-tool on a runTurn adapter → image bridge wins (web-search loop has no runTurn support)", async () => { + imageBridgeRun = false; webSearchRun = false; runTurnCalled = false; + useRunTurnAdapter = true; + mockWsPlan = { backend: "openai" }; + try { + const res = await post(true, [{ type: "web_search" }, { type: "image_generation" }]); + expect(webSearchRun).toBe(false); + expect(imageBridgeRun).toBe(true); + expect(runTurnCalled).toBe(false); + expect(res.headers.get("content-type")).toBe("text/event-stream"); + } finally { + useRunTurnAdapter = false; + } + }); + + test("image-only on a runTurn adapter → image bridge activates before runTurn early-return", async () => { + imageBridgeRun = false; webSearchRun = false; runTurnCalled = false; + useRunTurnAdapter = true; + mockWsPlan = undefined; + try { + const res = await post(true, [{ type: "image_generation" }]); + expect(imageBridgeRun).toBe(true); + expect(webSearchRun).toBe(false); + expect(runTurnCalled).toBe(false); + expect(res.headers.get("content-type")).toBe("text/event-stream"); + } finally { + useRunTurnAdapter = false; + } + }); +}); diff --git a/tests/kiro-review-regressions.test.ts b/tests/kiro-review-regressions.test.ts index a3d83bd76..9f11edb1b 100644 --- a/tests/kiro-review-regressions.test.ts +++ b/tests/kiro-review-regressions.test.ts @@ -250,7 +250,7 @@ describe("Kiro review regressions", () => { expect(calls).toEqual([]); expect(readFileSync(kiroCliDbPath()).length).toBeGreaterThan(0); expect(inspectKiroCliSessionSnapshot()).toMatchObject({ blocked: true, snapshot: null }); - }); + }, { timeout: 20_000 }); test("forced login refuses when the primary CLI store exists but only a later fallback is readable", async () => { mkdirSync(join(kiroCliDbPath(), ".."), { recursive: true }); diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index fce5c005e..ce36a410c 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -81,6 +81,18 @@ describe("Responses parser", () => { expect(parsed.options.toolChoice).toEqual({ allowedTools: ["web_search"], mode: "required" }); }); + test("maps type-only hosted image_generation tool_choice to required image_gen", () => { + const parsed = parseRequest({ + model: "claude-opus-4-6", + input: "draw a cat", + tools: [{ type: "image_generation" }], + tool_choice: { type: "image_generation" }, + }); + + expect(parsed._imageGeneration?.toolNames.has("image_generation")).toBe(true); + expect(parsed.options.toolChoice).toEqual({ name: "image_gen" }); + }); + test("preserves requested service_tier for request logging", () => { const parsed = parseRequest({ model: "gpt-5.5", @@ -283,6 +295,20 @@ describe("codex-rs compat surface (260707)", () => { expect(parsed.options.reasoning).toBeUndefined(); }); + test("detects image_generation hosted tool arriving via additional_tools (responses_lite WS shape)", () => { + // Codex Desktop responses_websockets lite path: NO body.tools; the hosted tool spec rides + // inside an input item {type:"additional_tools", tools:[...]}. extractHostedImageGeneration + // must still see it so the image bridge activates. + const parsed = parseRequest({ + model: "p/m", + input: [ + { type: "additional_tools", tools: [{ type: "image_generation" }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "draw a cat" }] }, + ], + }); + expect(parsed._imageGeneration?.toolNames.has("image_generation")).toBe(true); + }); + test("current parser ignores null empty and unknown string efforts", () => { expect(parseRequest({ model: "p/m", input: "hi", reasoning: null }).options.reasoning).toBeUndefined(); expect(parseRequest({ model: "p/m", input: "hi", reasoning: { effort: "" } }).options.reasoning).toBeUndefined(); diff --git a/tests/storage-cleanup.test.ts b/tests/storage-cleanup.test.ts index dd1f8bb3f..8c89765d2 100644 --- a/tests/storage-cleanup.test.ts +++ b/tests/storage-cleanup.test.ts @@ -666,8 +666,10 @@ describe("executeArchivedCleanup", () => { expect(Buffer.compare(beforeGoals, readFileSync(join(home, "goals_1.sqlite")))).toBe(0); expect(Buffer.compare(beforeMemories, readFileSync(join(home, "memories_1.sqlite")))).toBe(0); expect(Buffer.compare(beforeState, readFileSync(join(home, "state_5.sqlite")))).toBe(0); - }); + }, { timeout: 20_000 }); + // Windows CI: injected satellite rollback paths (especially goals) can measure 6–13s + // there and trip bun's default 5s harness timeout. test.each([ ["failAfterLogsMutation", { failAfterLogsMutation: true }], ["failAfterMemoriesMutation", { failAfterMemoriesMutation: true }], From da212d3a6c4054eaf60571bb4347eee3e47e92a8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:40:40 +0200 Subject: [PATCH 021/129] feat(anthropic): opt-in Claude OAuth account pool (#294) (#578) * feat(anthropic): opt-in Claude OAuth account pool (#294) Add experimental, default-off routing across stored Anthropic OAuth accounts: sticky session affinity, 429 cooldown failover, and new-session lowest 5h-usage pick. Includes GUI toggle with an explicit not-battle-tested warning, management API, docs, and focused regressions. * fix(anthropic): address pool review feedback for #294 Return 429 when all accounts are cooling, bound per-request failover, skip unsafe local-cli refresh, and keep affinity/GUI/docs aligned with the reliability contract. --- .../src/content/docs/guides/claude-code.md | 24 ++ .../content/docs/reference/configuration.md | 28 ++ .../AnthropicAccountPoolSettings.tsx | 164 +++++++ .../provider-workspace/ProviderAuthPanel.tsx | 4 + gui/src/i18n/de.ts | 13 + gui/src/i18n/en.ts | 15 + gui/src/i18n/ja.ts | 13 + gui/src/i18n/ko.ts | 13 + gui/src/i18n/ru.ts | 13 + gui/src/i18n/zh.ts | 13 + src/oauth/anthropic-routing.ts | 404 ++++++++++++++++++ src/oauth/health.ts | 6 + src/providers/quota.ts | 23 + src/server/claude-messages.ts | 1 + src/server/management/oauth-account-routes.ts | 63 +++ src/server/responses/core.ts | 145 ++++++- src/types.ts | 10 + src/usage/log.ts | 2 + tests/anthropic-account-pool.test.ts | 156 +++++++ 19 files changed, 1097 insertions(+), 13 deletions(-) create mode 100644 gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx create mode 100644 src/oauth/anthropic-routing.ts create mode 100644 tests/anthropic-account-pool.test.ts diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index fb814832e..cc6d35645 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -7,6 +7,30 @@ opencodex serves `POST /v1/messages` (plus `count_tokens`) alongside `/v1/respon Code can use every routed provider — OAuth logins, account pools, key failover and sidecars included — with zero extra auth work. +## Claude OAuth account pool (experimental) + +You can log in multiple Claude accounts via the Providers dashboard (`ocx login anthropic` / +add-account). By default every request uses the **active** account only. + +An **experimental, opt-in** Claude account pool (`anthropicAccountPool.enabled`) adds sticky +session affinity and 429 cooldown failover across those OAuth accounts, with optional +new-session lowest-usage pick from the 5-hour quota bars. It is **off by default**, shows a +GUI warning, and is not battle-tested — Anthropic may restrict accounts that look like +automated rotation. + +Operational contract when enabled: + +- Upstream **429** cools that account using `Retry-After` when present (else a default backoff), + clears its affinities, and may rotate to another eligible account within the same request + (bounded). +- Affinity is **process-local** (lost on proxy restart). +- **401/403** credential failures quarantine the account (`needsReauth`) so it is excluded from + selection until re-authenticated. +- If every eligible account is cooling, the proxy returns **429** (not 401) with `Retry-After` + when known. + +See [Configuration](/reference/configuration/#anthropicaccountpool-experimental). + ## Quickstart ```bash diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index c25f59525..9aa34230b 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -103,6 +103,34 @@ credential store. Existing thread ids keep account affinity, while new sessions on quota, cooldown, and health. ::: +### anthropicAccountPool (experimental) + +Opt-in routing across **multiple Anthropic OAuth accounts** already stored in `auth.json` +(issue [#294](https://github.com/lidge-jun/opencodex/issues/294)). **Default off.** This is +experimental and not battle-tested — enable only if you accept the risk that Anthropic may +restrict accounts that look like automated multi-account rotation. Accounts under the same +organization can share quota; pooling those will not help. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `anthropicAccountPool.enabled?` | `boolean` | `false` | When true, sticky session affinity + 429 cooldown failover across eligible Anthropic OAuth accounts. | +| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For **new** sessions only: if the active account's **known** cached 5-hour usage is at/above this percent, pick the lowest-usage eligible account. Unknown usage does not force a switch. `0` disables quota-based picking (affinity + active only). | + +Reliability contract when enabled: + +- A provider **429** records cooldown from `Retry-After` (capped) or a default backoff, clears + that account's affinities, and may rotate within the request (bounded attempts). +- Affinity maps are **process-local** (lost on restart) and size-bounded. +- Credential **401/403** failures mark `needsReauth` and exclude the account until login is fixed. +- When all eligible accounts are cooling, clients receive **429** with `Retry-After` when known — + not an authentication error. + +Toggle and warning also appear on **Providers → anthropic → Accounts** in the GUI. +:::caution[Experimental] +Leave this disabled unless you understand Anthropic account policy risk. Prefer manual +`ocx account use anthropic ` switching when unsure. +::: + ### claudeCode (OcxClaudeCodeConfig) Claude Code inbound settings consumed by the `/v1/messages` surface, the `ocx claude` diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx new file mode 100644 index 000000000..257e566f8 --- /dev/null +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -0,0 +1,164 @@ +/** + * Opt-in Anthropic OAuth account pool controls (#294). + * Experimental — shows a strong warning because the feature is not battle-tested. + */ +import { useCallback, useEffect, useState } from "react"; +import { useT } from "../../i18n/shared"; + +type PoolState = { + enabled: boolean; + threshold: number; +}; + +export default function AnthropicAccountPoolSettings({ + apiBase, + accountCount, +}: { + apiBase: string; + accountCount: number; +}) { + const t = useT(); + const [state, setState] = useState(null); + const [draft, setDraft] = useState("80"); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(false); + + useEffect(() => { + let cancelled = false; + const ac = new AbortController(); + void (async () => { + try { + const res = await fetch(`${apiBase}/api/oauth/accounts/pool?provider=anthropic`, { + signal: ac.signal, + }); + if (!res.ok) throw new Error("load"); + const json = await res.json() as { enabled?: boolean; autoSwitchThreshold?: number }; + if (cancelled) return; + const nextEnabled = json.enabled === true; + const nextThreshold = typeof json.autoSwitchThreshold === "number" ? json.autoSwitchThreshold : 80; + setState({ enabled: nextEnabled, threshold: nextThreshold }); + setDraft(String(nextThreshold)); + setLoadError(false); + } catch { + if (cancelled || ac.signal.aborted) return; + setLoadError(true); + } + })(); + return () => { + cancelled = true; + ac.abort(); + }; + }, [apiBase]); + + const save = useCallback(async (nextEnabled: boolean, nextThreshold: number) => { + setSaving(true); + setError(null); + try { + const res = await fetch(`${apiBase}/api/oauth/accounts/pool`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + enabled: nextEnabled, + autoSwitchThreshold: nextThreshold, + }), + }); + if (!res.ok) throw new Error("save"); + setState({ enabled: nextEnabled, threshold: nextThreshold }); + setDraft(String(nextThreshold)); + } catch { + setError(t("anthropicPool.saveFailed")); + } finally { + setSaving(false); + } + }, [apiBase, t]); + + const enabled = state?.enabled === true; + const threshold = state?.threshold ?? 80; + const loading = state === null && !loadError; + // Always allow turning the pool off; only block enabling when fewer than 2 accounts. + const toggleDisabled = loading || saving || loadError || (!enabled && accountCount < 2); + + return ( +
    +
    +
    + {t("anthropicPool.title")} +
    + {loadError + ? t("anthropicPool.loadFailed") + : loading + ? t("common.loading") + : enabled + ? t("anthropicPool.enabledDesc", { threshold }) + : t("anthropicPool.disabledDesc")} +
    +
    + +
    + +
    + {t("anthropicPool.experimentalWarning")} +
    + + {accountCount < 2 && ( +
    {t("anthropicPool.needTwoAccounts")}
    + )} + + {enabled && ( + + )} + + {error && ( +
    + {error} +
    + )} +
    + ); +} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 45049d16e..efa7bed39 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -19,6 +19,7 @@ import { oauthHealthShowsReauth, } from "../../oauth-health-display"; import CodexAccountPool from "../CodexAccountPool"; +import AnthropicAccountPoolSettings from "./AnthropicAccountPoolSettings"; import { LoginUrlBlock } from "../login-url-block"; import QuotaBars from "../QuotaBars"; import { useCopyFeedback } from "../use-copy-feedback"; @@ -105,6 +106,9 @@ export default function ProviderAuthPanel({
    {isOauth && ( <> + {item.name === "anthropic" && ( + + )}