From 28c13d0c0991647c817357a32fba50bc5946431c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:55:59 +0900 Subject: [PATCH 01/53] fix(usage): retain readable totals with incomplete-history notices Carries #4111 at 2f07acb58b3e73f48cea38334f301b430a8634cd. Preserve positive omission evidence through retained aggregates and consumer caches; refuse most-used ordering from an incomplete snapshot. Replace fixed-delay GUI test completion with an observed predicate. Local suites NOT RUN; hosted verification follows. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- devlog/_plan/260912_operations/030_totals.md | 8 +- .../content/docs/fr/guides/web-dashboard.md | 2 + .../content/docs/fr/reference/cli/agents.md | 2 + .../docs/fr/reference/management-api.md | 2 + .../src/content/docs/guides/web-dashboard.md | 5 + .../content/docs/ja/guides/web-dashboard.md | 2 + .../content/docs/ja/reference/cli/agents.md | 2 + .../docs/ja/reference/management-api.md | 2 + .../content/docs/ko/guides/web-dashboard.md | 2 + .../content/docs/ko/reference/cli/agents.md | 2 + .../docs/ko/reference/management-api.md | 2 + .../src/content/docs/reference/cli/agents.md | 5 + .../content/docs/reference/management-api.md | 12 +- .../content/docs/ru/guides/web-dashboard.md | 2 + .../content/docs/ru/reference/cli/agents.md | 2 + .../docs/ru/reference/management-api.md | 2 + .../content/docs/tr/guides/web-dashboard.md | 2 + .../content/docs/tr/reference/cli/agents.md | 2 + .../docs/tr/reference/management-api.md | 2 + .../docs/zh-cn/guides/web-dashboard.md | 2 + .../docs/zh-cn/reference/cli/agents.md | 2 + .../docs/zh-cn/reference/management-api.md | 2 + .../docs/zh-tw/guides/web-dashboard.md | 2 + .../docs/zh-tw/reference/cli/agents.md | 2 + .../docs/zh-tw/reference/management-api.md | 2 + gui/src/components/AddProviderModal.tsx | 6 +- .../apikeys-workspace/ApiKeysListPanel.tsx | 7 +- .../apikeys-workspace/ApiKeysWorkspace.tsx | 12 +- .../ProviderWorkspaceShell.tsx | 11 +- .../components/usage-incomplete-notice.tsx | 10 ++ gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/ApiKeys.tsx | 7 +- gui/src/pages/Models.tsx | 3 +- gui/src/pages/Usage.tsx | 5 +- gui/src/pages/dashboard-overview-head.tsx | 2 + gui/src/pages/dashboard-shared.ts | 2 +- gui/src/usage-summary-resource.ts | 15 +++ gui/tests/apikeys-workspace.test.tsx | 20 +++ gui/tests/model-picker-order-editor.test.tsx | 36 ++++++ gui/tests/usage-custom-range.test.tsx | 19 +++ gui/tests/usage-incomplete-consumers.test.tsx | 120 ++++++++++++++++++ src/cli/usage-report.ts | 9 +- src/server/management/api-key-usage.ts | 9 +- src/server/management/logs-usage-routes.ts | 2 + src/server/management/oauth-account-routes.ts | 3 +- .../management/usage-aggregate-cache.ts | 29 ++--- src/server/management/usage-summary-cache.ts | 2 + structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/design-methodology.md | 2 + structure/gui-and-management-api.md | 17 ++- structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + tests/cli/cli-usage-report.test.ts | 29 +++++ tests/server/api-key-attribution.test.ts | 16 ++- tests/server/api-usage.test.ts | 19 ++- tests/usage/usage-aggregate-cache.test.ts | 39 +++++- 76 files changed, 528 insertions(+), 52 deletions(-) create mode 100644 gui/src/components/usage-incomplete-notice.tsx create mode 100644 gui/tests/usage-incomplete-consumers.test.tsx diff --git a/devlog/_plan/260912_operations/030_totals.md b/devlog/_plan/260912_operations/030_totals.md index 71d2615772..e3baa47109 100644 --- a/devlog/_plan/260912_operations/030_totals.md +++ b/devlog/_plan/260912_operations/030_totals.md @@ -2,10 +2,14 @@ Class C3; dependency roadmap. Adopt public #4111 final diff (2f07acb58b3e73f48cea38334f301b430a8634cd) after current-base and latest-review audit; preserve luvs01 credit. Source diff and metadata are in ignored .tmp/operations/pr-4111.diff/json, fetched directly from GitHub. -MODIFY src/server/management/usage-aggregate-cache.ts: replace four oversizedRows throws with retained usageIncomplete boolean, set on full scan, OR on append, preserve in resultFrom; cache API-key snapshots with diagnostic. MODIFY api-key-usage.ts: keep readable accumulator output and attach usageIncomplete:true / usageIncompleteReason:oversized_rows instead of throwing. MODIFY logs-usage-routes.ts: serialize diagnostics on filtered and unfiltered summaries. MODIFY usage-summary-cache.ts CachedUsageSummary and oauth-account-routes.ts GET /api/keys to retain/serialize flags. Other IO/mutation errors still fail. +MODIFY src/server/management/usage-aggregate-cache.ts: replace four oversizedRows throws with retained usageIncomplete boolean, set on full scan, OR on append, preserve in resultFrom; cache API-key snapshots with diagnostic. MODIFY api-key-usage.ts: keep readable accumulator output and attach usageIncomplete:true / usageIncompleteReason:oversized_rows instead of throwing. MODIFY logs-usage-routes.ts: serialize diagnostics on filtered and unfiltered summaries. MODIFY usage-summary-cache.ts CachedUsageSummary and oauth-account-routes.ts GET /api/keys to retain/serialize flags. Preserve existing non-oversized failure behavior: API-key rollups still return their existing zero fallback on IO failure, and /api/usage retains error:read_failed. MODIFY src/cli/usage-report.ts: warnings precede totals/no-match branch; incomplete no-match says skipped records may match. MODIFY gui/src/usage-summary-resource.ts shared optional diagnostic type; NEW components/usage-incomplete-notice.tsx; extend consumers Usage, dashboard overview, Models, AddProviderModal, ProviderWorkspaceShell, ApiKeysWorkspace/ListPanel and ApiKeys. Incomplete keys do not claim inactivity; warnings survive consumer caching. Add all locale keys. Full field chain: scanner oversizedRows -> retained aggregate boolean/API key snapshot -> route JSON/cache -> shared GUI/CLI input types -> every totals/ranking/key activity consumer. -MODIFY existing tests/cli/cli-usage-report.test.ts, tests/server/api-usage.test.ts, tests/server/api-key-attribution.test.ts, tests/usage/usage-aggregate-cache.test.ts and GUI usage/custom-range/model-picker/key-workspace tests; NEW gui/tests/usage-incomplete-consumers.test.tsx. Activation: good + oversized + good rows yields readable sums and warning; append oversized sticky flag, full clean rewrite clears it, missing filter matches stays uncertain, loading/stale consumers retain warning. Do not turn IO errors into zero totals. +MODIFY existing tests/cli/cli-usage-report.test.ts, tests/server/api-usage.test.ts, tests/server/api-key-attribution.test.ts, tests/usage/usage-aggregate-cache.test.ts and GUI usage/custom-range/model-picker/key-workspace tests; NEW gui/tests/usage-incomplete-consumers.test.tsx. Activation: good + oversized + good rows yields readable sums and warning; append oversized sticky flag, full clean rewrite clears it, missing filter matches stays uncertain, loading/stale consumers retain warning. Do not introduce any new IO-error fallback. MODIFY structure/gui-and-management-api.md and relevant mapped contract pointers; public management API, CLI agents and web-dashboard guides in all existing translated paths from original diff. Hosted full CI and dashboard evidence certify final tip; local suites/build/typecheck NOT RUN. This does not implement hub client-scoped CLI usage (#4205). + +Design reflection OPS-TOTALS01..05 accepted: positive-only flags do not prove completeness when absent; invalidRows is not sticky. Models rejects most-used ordering before PUT when usage is incomplete and keeps other modes available. Include dashboard-shared.ts as type carrier, keys-first/usage-first seeding, unfinished suffix without duplicates, empty/no-attribution and incomplete-to-clean recovery cases. Reuse existing Notice warn presentation and all locale modules, without new visual tokens or motion (ops dashboard, variance2/motion1, existing density). Remote build preview supplies rendered evidence; local product build/tests NOT RUN. + +A amendment: replace the newly carried fixed25ms wait in gui/tests/usage-incomplete-consumers.test.tsx with bounded condition-driven completion (act and event-loop turns, asserting rendered expected condition before return). No timer delay is accepted as proof of rendering. diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 2e333e39d8..2d4d753bb5 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -57,6 +57,8 @@ gestionnaire de mots de passe. | **Stockage** | Consultez en lecture seule la répartition du disque de CODEX_HOME — sessions, archives, bases de données et pièces jointes. Pour le nettoyage facultatif des archives, prévisualisez les N % les plus anciennes, puis placez-les en quarantaine dans `CODEX_HOME/.trash` (par défaut) ou supprimez-les définitivement après avoir coché une case explicite. **La stratégie de nettoyage automatique** est facultative et **désactivée par défaut** (`storageCleanupPolicy.enabled`) ; configurez son seuil, sa cible, sa planification et son mode sur la page **Stockage**, ou lancez **Exécuter maintenant**. Les entrées mises en quarantaine peuvent être restaurées depuis cette page (JSONL et fils). Les sessions actives restent en lecture seule. Le nettoyage et la restauration sont refusés tant que Codex verrouille le fichier `state_*.sqlite` le plus récent ou actif. | | **Arrêter** | Arrêtez proprement le proxy et le service d'arrière-plan installé, restaurez Codex natif et quittez (`POST /api/stop`). Sur Windows avec le backend Planificateur de tâches, le tableau de bord refuse et vous demande d'exécuter `ocx stop` : le wrapper peut relancer le proxy après la fin de la tâche, et seul un stop exécuté hors du proxy peut vérifier cette fenêtre de redémarrage avant de restaurer votre configuration client. Rien n'est modifié en cas de refus. | +Les vues Utilisation, Tableau de bord, Fournisseurs, Catalogue des fournisseurs et Clés API signalent les enregistrements exclus, même sans résultat lisible. Les décomptes, les dates et les classements reposent uniquement sur les lignes lisibles. L’enregistrement de l’ordre des modèles par utilisation est refusé si l’historique est incomplet : choisissez un autre ordre ou réparez l’historique avant de réessayer. + ### Filtrer les requêtes Les filtres combinent interface, requêtes interceptées, fournisseur, modèle exact, statut, période, vitesse et identifiant de conversation dans le journal chargé. Les choix incluent les tentatives de repli ; les modèles ignorent la casse et les espaces externes, sans correspondance partielle. Un choix disparu revient à Tous. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 0d781042d9..69d4ac39d0 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -98,6 +98,8 @@ Inspectez les requêtes de proxy, l’utilisation, le stockage, la mémoire et l ocx observe usage --range 30d --json ``` +Si certains enregistrements ne peuvent pas être inclus, la sortie lisible affiche un avertissement, même sans ligne lisible. Les totaux affichés ne reflètent que les enregistrements lisibles. Si un filtre ne trouve aucune correspondance lisible, la sortie affiche l'avertissement et des indications au lieu des lignes de totaux ; les enregistrements ignorés peuvent contenir des correspondances. `--json` préserve le diagnostic `usageIncomplete` et sa raison. + ### `ocx debug ` Lisez ou modifiez les remplacements de débogage d'exécution via la gestion du proxy en cours d'exécution API. diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index d50b11a50c..e7da3b7bfb 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -155,6 +155,8 @@ Voir [Combos](/fr/guides/combos/) pour les stratégies cibles, les temps de rech | `POST /api/storage/cleanup-policy/run` | Démarrer une exécution manuelle de la politique de nettoyage | 409 `already_running` ; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Point d'ancrage du flux de stratégie réservé aux tests | 404 `not_found` en cas d'indisponibilité | +Si une ligne dépasse la limite de taille du parseur, `GET /api/usage` et `GET /api/keys` conservent les agrégats lisibles et ajoutent `usageIncomplete: true` avec `usageIncompleteReason: "oversized_rows"` au niveau de la réponse. Ce diagnostic reste présent dans le cache et après les ajouts incrémentaux, même sans résultat ni correspondance de filtre ; une reconstruction le recalcule. Les identifiants de fournisseur, de modèle et de clé API ne sont pas raccourcis. L’absence du champ ne prouve pas la validité de toutes les lignes. Ce signal est distinct de `historyTruncated`, `entriesTruncated` et de la couverture de mesure des tokens. + Pour `GET /api/usage?range=30d&surface=codex`, `accounts` contient une ligne par libellé de pool Codex observé. Chaque ligne indique `accountLogLabel`, le total de jetons, `usageCoverageRatio` et une valeur facultative `estimatedCostUsd` calculée selon les tarifs d'affichage actuellement configurés. Les substitutions `modelCosts` actives de l'utilisateur diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 7e86901233..2161f81358 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -96,6 +96,11 @@ badge or the version value to read the full value. | **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`). On Windows with the Task Scheduler backend the dashboard refuses and asks you to run `ocx stop` instead: that wrapper can respawn the proxy after the task ends, and only a stop running outside this process can verify the restart window before restoring your client config. Nothing is changed when it refuses. | +If some usage records cannot be included, the Usage page, Dashboard, provider workspace, provider +catalog, and API key views show a warning even when no readable records remain. Counts, dates, and +usage rankings reflect readable records only. **Models → Most used snapshot → Apply order** refuses +to save an incomplete snapshot; choose another order or repair the history before retrying. + ### Account selection Account selection is shared with request routing. Selecting an OAuth account takes effect on the 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 310baa4a5a..304c9646b8 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -48,6 +48,8 @@ bun run dev:gui | **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。**自動クリーンアップ方針**はオプトインで**既定 OFF**(`storageCleanupPolicy.enabled`)。Storage ページでしきい値/目標/スケジュール/モードを設定するか **今すぐ実行**。隔離エントリは Storage ページから復元可能(JSONL + スレッド)。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中はクリーンアップと復元を拒否。 | | **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。ただし Windows のタスク スケジューラ バックエンドではダッシュボードが拒否し、`ocx stop` の実行を促します。タスク終了後もラッパーがプロキシを再起動しうるため、クライアント設定を戻す前にその再起動区間を確認できるのはプロキシの外で動く stop だけです。拒否されたときは何も変更されません。 | +使用量、ダッシュボード、プロバイダー画面、プロバイダーカタログ、API キー画面は、読み取れる記録がなくても除外された使用履歴の警告を表示します。回数、日付、使用順位は読み取れる記録のみを反映します。履歴が不完全な場合はモデルの使用回数順の保存を拒否します。別の順序を選ぶか、履歴を修復してから再試行してください。 + ### リクエストログの絞り込み Logsではサーフェス、インターセプトされたリクエスト、プロバイダー、完全なモデル名、ステータス、時間、速度、会話IDを組み合わせて、読み込み済みログを絞り込みます。選択肢にはフォールバック試行も含まれます。モデル名は大文字小文字と前後の空白を無視しますが、部分一致ではありません。ログから消えた選択肢は全件に戻ります。 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index a74f20a817..e2431ff41f 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -69,6 +69,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +一部の使用履歴を集計できない場合、人向けの出力は読み取れる行がない場合も警告を表示します。表示される合計値は読み取れる記録のみを反映します。フィルターに一致する読み取れる記録がない場合は、合計欄の代わりに警告と案内を表示します。除外した記録には一致するものが含まれる可能性があります。`--json` は応答の `usageIncomplete` 診断と理由をそのまま保持します。 + ### `ocx debug ` 実行中のプロキシの管理 API を通じて、ランタイム デバッグ オーバーライドを読み取りまたは変更します。 diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 35ebec9131..4398ff8118 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -134,6 +134,8 @@ Authorization: Bearer | `POST /api/storage/cleanup-policy/run` |手動クリーンアップ ポリシーの実行を開始します。 409 `already_running`; 500`cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` |テスト専用ポリシー ストリーム フック | 404 `not_found` 利用できない場合 | +行が既存のパーサーのサイズ上限を超えた場合、`GET /api/usage` と `GET /api/keys` は読み取れる行の集計を維持し、応答全体に `usageIncomplete: true` と `usageIncompleteReason: "oversized_rows"` を追加します。この診断はキャッシュや増分追記後も維持され、結果が空または一致なしでも返されます。再構築時には再計算されます。プロバイダー、モデル、API キーの識別子は短縮しません。フラグがないことは全行が有効だった証明にはなりません。`historyTruncated`、`entriesTruncated`、トークン測定カバレッジとは別の情報です。 + `models`、`providers`、および `days[].models` の各行にも `cacheHitRate` が含まれます。これは、プロバイダーのプロンプト キャッシュから供給された入力トークンの割合で、`[0, 1]` の範囲に制限されます。プロバイダーがキャッシュ テレメトリを報告しなかった場合、または行に入力トークンがない場合は、`0` ではなく `null` になります。「キャッシュ データなし」と「実際のヒット率 0%」は異なる事実であり、それらを同じように描画するチャートは誤解を招くためです。 :::caution 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 efdd80179f..783a5a19f0 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -48,6 +48,8 @@ bun run dev:gui | **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. **자동 정리 정책**은 opt-in이며 **기본 OFF**(`storageCleanupPolicy.enabled`)입니다. Storage 페이지에서 임계값/목표/일정/모드를 설정하거나 **지금 실행**하세요. Storage 페이지에서 격리 항목을 복원할 수 있습니다(JSONL + 스레드). 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 정리와 복원을 거절합니다. | | **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). 단, Windows 작업 스케줄러로 관리되는 경우에는 대시보드가 거절하고 `ocx stop`을 안내합니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 클라이언트 설정을 되돌리기 전에 그 재시작 구간을 확인할 수 있는 건 프록시 바깥에서 도는 stop뿐입니다. 거절될 때는 아무것도 바뀌지 않습니다. | +Usage, Dashboard, 공급자 작업 화면·카탈로그, API 키 화면은 읽을 수 있는 기록이 없어도 일부 기록 제외 경고를 표시합니다. 횟수·날짜·사용 순위는 읽을 수 있는 기록만 반영합니다. 이력이 불완전하면 모델의 ‘많이 사용한 순서’ 저장을 거절합니다. 다른 순서를 선택하거나 이력을 복구한 뒤 다시 시도하세요. + ### 요청 로그 필터 Logs에서는 클라이언트 종류, 가로챈 요청, 공급자, 정확한 모델명, 상태, 시간, diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 82f776b782..12afadd2e9 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -94,6 +94,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +일부 사용량 기록을 집계하지 못하면 일반 출력은 읽을 수 있는 행이 없어도 경고합니다. 표시되는 합계는 읽을 수 있는 기록만 반영합니다. 필터에 일치하는 읽을 수 있는 기록이 없으면 합계 항목 대신 경고와 안내를 표시하며, 제외된 기록에는 일치하는 항목이 있을 수 있습니다. `--json`은 응답의 `usageIncomplete` 진단과 사유를 그대로 유지합니다. + ### `ocx debug ` 실행 중인 프록시의 관리 API를 통해 런타임 디버그 override를 읽거나 변경합니다. diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index c0056af61d..b806e10d17 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -138,6 +138,8 @@ Authorization: Bearer | `POST /api/storage/cleanup-policy/run` | 수동 cleanup-policy 실행을 시작합니다 | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | 테스트 전용 policy stream 훅입니다 | 사용할 수 없으면 404 `not_found` | +행이 기존 파서의 크기 제한을 넘으면 `GET /api/usage`와 `GET /api/keys`는 읽을 수 있는 행의 집계를 유지하고 응답 전체에 `usageIncomplete: true`, `usageIncompleteReason: "oversized_rows"`를 추가합니다. 이 진단은 캐시와 증분 추가에서도 유지되며, 빈 결과나 필터 일치 결과가 없는 경우에도 반환됩니다. 재구축 시에는 다시 계산합니다. 행을 맞추기 위해 공급자·모델·API 키 식별자를 줄이지 않습니다. 플래그가 없다고 모든 기록이 유효했다는 뜻은 아닙니다. `historyTruncated`, `entriesTruncated`, 토큰 측정 커버리지와는 별개입니다. + `models`, `providers`, `days[].models`의 행에도 `cacheHitRate`가 포함됩니다. 이 값은 공급자의 프롬프트 캐시에서 제공된 입력 토큰의 비율이며 `[0, 1]` 범위로 제한됩니다. 공급자가 캐시 텔레메트리를 보고하지 않았거나 행에 입력 토큰이 없으면 `0`이 아니라 항상 `null`입니다. "캐시 데이터 없음"과 "실제 적중률 0%"는 서로 다른 사실이며, diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 32399c59df..3909ef90c5 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -162,6 +162,11 @@ separately, and requests with no matching price row are counted as ocx usage --range today --provider xai ``` +When some usage records cannot be included, human output warns, including when there are zero readable rows. +Any displayed totals reflect readable records only. If a filter has no readable matches, the output shows +the warning and guidance instead of total lines; skipped records may contain matches. +`--json` preserves the response-level `usageIncomplete` diagnostic and reason. + ### `ocx debug ` Read or change runtime debug overrides through the running proxy's management API. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index cd8b2450c2..87a54b749b 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -191,7 +191,7 @@ by the current window size. | `GET /api/debug/usage-logs` | Read bounded usage-debug entries | — | | `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — | | `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — | -| `GET /api/usage` | Stream the complete usage ledger into compact aggregates, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read | +| `GET /api/usage` | Scan the usage ledger into compact aggregates of readable rows, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read | | `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure | | `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` | | `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure | @@ -202,6 +202,14 @@ by the current window size. | `POST /api/storage/cleanup-policy/run` | Start a manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Test-only policy stream hook | 404 `not_found` when unavailable | +If a scanned row exceeds the existing parser size limit, `GET /api/usage` and `GET /api/keys` +keep the readable-row aggregates and add `usageIncomplete: true` with +`usageIncompleteReason: "oversized_rows"` at response level. This diagnostic survives cached +responses and incremental appends, including empty or unmatched results; a rebuild recalculates it. +No provider, model, or API-key identifier is shortened to make a row fit. An absent flag is not proof +that every ledger record was valid. This is separate from `historyTruncated`, `entriesTruncated`, +and token measurement coverage. + New xAI attempts in `usage.jsonl` include a request-time `credentialSource`: `grok-oauth` for the resolved Grok CLI OAuth transport, or `xai-api-key` for the public xAI API key transport. This fixed label contains no credential or account identifier. It belongs to @@ -214,7 +222,7 @@ The log reports usage, not subscription invoice amounts. snapshot on a cold start. It processes fixed 1 MiB chunks and retains compact aggregate state rather than every normalized request row. Later refreshes validate the previous line boundary and fold only newly appended complete rows. Concurrent callers share the same refresh. Range and surface predicates -are applied to the complete aggregate, so the former read-byte window and parsed-row cap cannot omit +are applied to the readable-row aggregate, so the former read-byte window and parsed-row cap cannot omit an earlier file prefix from 7-day, 30-day, or all-history totals. `managementUsageMaxReadBytes` remains accepted for compatibility with bounded legacy readers, but changing it no longer expands or reduces the history summarized by this endpoint. 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 4600b51ace..a40ab67ec4 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -48,6 +48,8 @@ bun run dev:gui | **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. **Политика автоочистки** — opt-in и **по умолчанию ВЫКЛ** (`storageCleanupPolicy.enabled`); порог/цель/расписание/режим на странице Storage или **Запустить сейчас**. Записи карантина можно восстановить со страницы Storage (JSONL + threads). Активные сессии только для чтения. Очистка и восстановление отклоняются, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. | | **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). На Windows с бэкендом планировщика заданий дашборд отказывает и просит выполнить `ocx stop`: обёртка может перезапустить прокси после завершения задачи, и проверить это окно перезапуска до восстановления клиентской конфигурации способен только stop, работающий вне прокси. При отказе ничего не изменяется. | +Страницы использования, дашборда, провайдеров, каталога провайдеров и API-ключей предупреждают об исключённых записях, даже если читаемых строк нет. Счётчики, даты и рейтинги основаны только на читаемых записях. Сохранение порядка моделей по частоте использования отклоняется при неполной истории: выберите другой порядок или восстановите историю перед повтором. + ### Фильтрация запросов Фильтры объединяют источник, перехваченные запросы, провайдера, точную модель, статус, время, скорость и ID диалога в загруженном журнале. Варианты включают резервные попытки; модель сравнивается без учёта регистра и крайних пробелов, но не по подстроке. Исчезнувший вариант сбрасывается на все записи. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index eadeb03d61..3cb310a90a 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -79,6 +79,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +Если часть записей нельзя учесть, человекочитаемый вывод показывает предупреждение, даже если нет читаемых строк. Отображаемые итоги учитывают только читаемые записи. Если фильтр не находит читаемых совпадений, вместо строк итогов выводятся предупреждение и подсказки; пропущенные записи могут содержать совпадения. `--json` сохраняет диагностику `usageIncomplete` и её причину из ответа. + ### `ocx debug ` Прочитать или изменить runtime debug-override'ы через management API работающего прокси. diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index e91a12222a..978744bbb0 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -156,6 +156,8 @@ GUI-сессия в стиле loopback не выпускается. | `POST /api/storage/cleanup-policy/run` | Запустить manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Тестовый policy-stream hook | 404 `not_found`, когда недоступен | +Если строка превышает существующий лимит размера парсера, `GET /api/usage` и `GET /api/keys` сохраняют агрегаты читаемых строк и добавляют в ответ `usageIncomplete: true` и `usageIncompleteReason: "oversized_rows"`. Диагностика сохраняется в кеше и при инкрементальных добавлениях, в том числе для пустых результатов и отсутствующих совпадений; при перестроении она вычисляется заново. Идентификаторы провайдеров, моделей и API-ключей не сокращаются. Отсутствие флага не доказывает корректность всех строк. Это отдельный сигнал от `historyTruncated`, `entriesTruncated` и покрытия измерений токенов. + Строки в `models`, `providers` и `days[].models` также содержат `cacheHitRate` — долю входных токенов, полученных из кэша промптов провайдера и ограниченную диапазоном `[0, 1]`. Значение равно `null`, а не `0`, если провайдер не передал телеметрию кэша или в строке нет входных токенов: отсутствие diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index ebdf946ffd..cfc04ca1df 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -60,6 +60,8 @@ kararıdır. | **Depolama** | Salt okunur CODEX_HOME disk dökümü (oturumlar, arşivler, DB'ler, ekler). İsteğe bağlı arşivlenmiş temizleme: en eski %N'yi önizleyin, ardından `CODEX_HOME/.trash` konumuna karantinaya alın (varsayılan) veya açık bir onay kutusu arkasında kalıcı olarak silin. **Otomatik temizleme politikası** isteğe bağlıdır ve **varsayılan olarak KAPALIDIR** (`storageCleanupPolicy.enabled`); Depolama sayfasında eşik/hedef/zamanlama/mod yapılandırın veya **Şimdi çalıştır (Run now)**'ı tetikleyin. Karantinaya alınan girdiler Depolama sayfasından geri yüklenebilir (JSONL + iş parçacıkları). Aktif oturumlar salt okunur kalır. Codex en yeni/aktif `state_*.sqlite` dosyasını kilitli tuttuğu sürece temizleme ve geri yükleme reddedilir. | | **Durdur** | Proxy'yi ve kurulu arka plan servisini zarif bir şekilde durdurun, yerel Codex'i geri yükleyin ve çıkın (`POST /api/stop`). Windows'ta Görev Zamanlayıcı arka ucunda panel reddeder ve `ocx stop` çalıştırmanızı ister: görev bittikten sonra sarmalayıcı proxy'yi yeniden başlatabilir ve bu yeniden başlatma penceresini istemci yapılandırmanız geri yüklenmeden önce yalnızca proxy dışında çalışan bir stop doğrulayabilir. Reddedildiğinde hiçbir şey değiştirilmez. | +Kullanım, panel, sağlayıcı çalışma alanı, sağlayıcı kataloğu ve API anahtarı görünümleri, okunabilir kayıt kalmasa bile dışlanan kayıtlar için uyarı gösterir. Sayılar, tarihler ve kullanım sıralamaları yalnızca okunabilir kayıtlara dayanır. Geçmiş eksikse en çok kullanılan model sırası kaydedilmez; başka bir sıra seçin veya yeniden denemeden önce geçmişi onarın. + ### İstek günlüklerini filtreleme Filtreler yüklü günlükte yüzey, yakalanan istekler, sağlayıcı, tam model adı, durum, zaman, hız ve konuşma kimliğini birleştirir. Seçenekler yedek denemeleri de içerir; model eşleşmesi büyük/küçük harfi ve dış boşlukları yok sayar, kısmi adları eşleştirmez. Kaybolan seçenek tüm kayıtlara döner. diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 9f38ae6495..f4564d3bba 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -111,6 +111,8 @@ verilerini inceleyin. Doğrudan takma adlar şunlardır: ocx observe usage --range 30d --json ``` +Bazı kullanım kayıtları dahil edilemiyorsa okunabilir çıktı, okunabilir satır olmadığında da uyarı gösterir. Gösterilen toplamlar yalnızca okunabilir kayıtları yansıtır. Filtreyle eşleşen okunabilir kayıt yoksa toplam satırları yerine uyarı ve yönlendirme gösterilir; atlanan kayıtlar eşleşme içerebilir. `--json`, yanıttaki `usageIncomplete` tanısını ve nedenini korur. + ### `ocx debug ` Çalışan proxy'nin yönetim API'si aracılığıyla çalışma zamanı hata ayıklama diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index bfdd83dcd9..354310f01e 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -163,6 +163,8 @@ Hedef stratejileri, soğuma süreleri, takma adlar ve yönlendirme hataları iç | `POST /api/storage/cleanup-policy/run` | Manuel bir temizleme politikası çalıştırması başlatın | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Yalnızca test amaçlı politika akış kancası | Kullanılamadığında 404 `not_found` | +Bir satır mevcut ayrıştırıcı boyut sınırını aşarsa `GET /api/usage` ve `GET /api/keys` okunabilir satır toplamlarını korur ve yanıt düzeyinde `usageIncomplete: true` ile `usageIncompleteReason: "oversized_rows"` ekler. Bu tanı, boş veya eşleşmeyen sonuçlar dahil önbellekte ve artımlı eklemelerde korunur; yeniden oluşturma sırasında tekrar hesaplanır. Sağlayıcı, model ve API anahtarı kimlikleri kısaltılmaz. Bayrağın bulunmaması tüm kayıtların geçerli olduğunu kanıtlamaz. Bu bilgi `historyTruncated`, `entriesTruncated` ve token ölçüm kapsamından ayrıdır. + `GET /api/usage?range=30d&surface=codex` için `accounts`, gözlemlenen her Codex havuz etiketi için bir satır içerir. Her satır `accountLogLabel`, belirteç toplamları, `usageCoverageRatio` ve geçerli olarak yapılandırılmış görüntüleme 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 dbb6383120..6f99e516c1 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 @@ -47,6 +47,8 @@ bun run dev:gui | **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。**自动清理策略**为可选且**默认关闭**(`storageCleanupPolicy.enabled`);可在 Storage 页配置阈值/目标/计划/模式,或点「立即运行」。可在 Storage 页从隔离区恢复(JSONL + 线程)。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理与恢复。 | | **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。在使用任务计划程序后端的 Windows 上,仪表板会拒绝并提示改用 `ocx stop`:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口。被拒绝时不会做任何更改。 | +用量、仪表板、供应商工作区、供应商目录和 API 密钥页面会提示部分记录被排除,即使没有可读取的记录。次数、日期和使用排名仅反映可读取的记录。历史不完整时,无法保存模型的最常用排序;请选择其他排序或修复历史后重试。 + ### 筛选请求日志 Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、时间、速度和会话 ID,筛选当前已加载的日志。选项包含回退尝试;模型匹配忽略大小写及首尾空格,但不做部分匹配。日志中消失的选项恢复为全部。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index f141dee649..2153f895c5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -75,6 +75,8 @@ API key,且绝不会回退到 native alias。启用这组兼容选项前,请 ocx observe usage --range 30d --json ``` +部分用量记录无法计入时,人类可读输出会显示警告,即使没有可读取的记录也是如此。显示的总数仅反映可读取的记录。如果筛选条件没有匹配到可读取的记录,输出将显示警告和提示,而不显示总数行;被跳过的记录可能包含匹配项。`--json` 原样保留响应中的 `usageIncomplete` 诊断及原因。 + ### `ocx debug ` 通过正在运行的代理的管理 API 读取或更改运行时调试覆盖项。 diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 84afe751a3..480accdf33 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -138,6 +138,8 @@ Authorization: Bearer | `POST /api/storage/cleanup-policy/run` | 启动一次手动清理策略运行 | 409 `already_running`;500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | 仅测试用的策略流钩子 | 不可用时返回 404 `not_found` | +如果某行超过现有解析器的大小限制,`GET /api/usage` 和 `GET /api/keys` 会保留可读取行的汇总,并在响应级别添加 `usageIncomplete: true` 和 `usageIncompleteReason: "oversized_rows"`。缓存和增量追加会保留该诊断,即使结果为空或没有筛选匹配;重建时会重新计算。不会缩短供应商、模型或 API 密钥标识来容纳该行。没有此标记不代表所有记录均有效。它与 `historyTruncated`、`entriesTruncated` 及 token 测量覆盖率相互独立。 + `models`、`providers` 和 `days[].models` 中的记录也带有 `cacheHitRate`:它表示由提供方提示缓存提供的输入 token 比例,并限制在 `[0, 1]` 范围内。当提供方未报告缓存遥测数据或该记录没有输入 token 时,其值为 `null`,绝不会是 `0`,因为“没有缓存数据”与“实际命中率为 0%”是不同的事实,将两者显示为相同结果的图表会产生误导。 :::caution diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 358624f2f9..29b324830f 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -51,6 +51,8 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 | **Usage / Debug** | 檢視 token usage 覆蓋率與趨勢,或啟用可選的 provider transport 和 usage 提取診斷。 | | **Stop** | 優雅地停止代理和已安裝的後臺服務,恢復原生 Codex 並退出(`POST /api/stop`)。在使用工作排程器後端的 Windows 上,儀表板會拒絕並提示改用 `ocx stop`:工作結束後包裝程序仍可能重新啟動 Proxy,只有執行在 Proxy 之外的 stop 才能在還原用戶端設定前確認這個重啟視窗。被拒絕時不會做任何變更。 | +用量、儀表板、供應商工作區、供應商目錄和 API 金鑰頁面會提示部分記錄被排除,即使沒有可讀取的記錄。次數、日期和使用排名僅反映可讀取的記錄。歷史不完整時,無法儲存模型的最常用排序;請選擇其他排序或修復歷史後重試。 + ### 篩選請求日誌 Logs 可組合介面、被攔截請求、供應商、完整模型名稱、狀態、時間、速度和對話 ID,篩選目前已載入的日誌。選項包含回退嘗試;模型比對忽略大小寫及頭尾空白,但不做部分比對。日誌中消失的選項恢復為全部。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index d9585b2520..6a724b7d16 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -70,6 +70,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +部分用量記錄無法納入時,人類可讀輸出會顯示警告,即使沒有可讀取的記錄也是如此。顯示的總數僅反映可讀取的記錄。如果篩選條件沒有符合的可讀取記錄,輸出將顯示警告和提示,而不顯示總數列;被略過的記錄可能包含符合項目。`--json` 原樣保留回應中的 `usageIncomplete` 診斷及原因。 + ### `ocx debug ` 透過執行中代理的管理 API 讀取或變更執行階段除錯覆寫。 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 8ea6cd79dd..4342a698e0 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -134,6 +134,8 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `POST /api/storage/cleanup-policy/run` | 啟動手動清理政策執行 | 409 `already_running`;500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | 僅測試的政策串流 hook | 不可用時 404 `not_found` | +如果某行超過現有解析器的大小限制,`GET /api/usage` 和 `GET /api/keys` 會保留可讀取行的彙總,並在回應層級加入 `usageIncomplete: true` 和 `usageIncompleteReason: "oversized_rows"`。快取和增量附加會保留此診斷,即使結果為空或沒有篩選符合項目;重建時會重新計算。不會縮短供應商、模型或 API 金鑰識別碼來容納該行。沒有此標記不代表所有記錄均有效。它與 `historyTruncated`、`entriesTruncated` 及 token 測量覆蓋率相互獨立。 + `models`、`providers` 及 `days[].models` 中的列也帶有 `cacheHitRate`:表示由供應商提示快取提供的輸入權杖比例,並限制在 `[0, 1]`。當供應商未回報快取遙測資料,或該列沒有輸入權杖時,其值為 `null`,絕不會是 `0`;因為「沒有快取資料」與「確實為 0% 的命中率」是不同事實,若圖表將兩者呈現為相同狀態,便會造成誤導。 :::caution diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 09f4fcb1d6..3ff9c631ca 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -1,4 +1,5 @@ -import { usageSummary30dResourceKey } from "../usage-summary-resource"; +import { usageSummary30dResourceKey, type UsageReadMetadata } from "../usage-summary-resource"; +import { UsageIncompleteNotice } from "./usage-incomplete-notice"; import { useEffect, useMemo, useReducer, useRef } from "react"; import { IconX } from "../icons"; import { useT } from "../i18n/shared"; @@ -89,7 +90,7 @@ export default function AddProviderModal({ async (signal) => { const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); if (!res.ok) throw new Error(String(res.status)); - return await res.json() as { providers?: Array<{ provider: string; requests: number }> }; + return await res.json() as UsageReadMetadata & { providers?: Array<{ provider: string; requests: number }> }; }, { deadlineMs: 60_000 }, // shared usage-summary key: all four subscribers raise the deadline together ); @@ -250,6 +251,7 @@ export default function AddProviderModal({ + {!preset && } {!preset ? ( + {keysLoading ? (
) : keys.length === 0 ? ( @@ -83,7 +88,7 @@ export default function ApiKeysListPanel({ ? "—" : k.usage.lastUsedAt ? formatCreatedDate(k.usage.lastUsedAt, localeTag) - : t("api.attribution.neverUsed")} + : t(usageMetadata?.usageIncomplete ? "api.attribution.noRecordedUse" : "api.attribution.neverUsed")} ))} diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx index dba0e23591..5b670428e3 100644 --- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -24,6 +24,8 @@ import { } from "../../pages/api-keys-panels"; import ClientConfigPanel from "./ClientConfigPanel"; import ApiKeysListPanel from "./ApiKeysListPanel"; +import type { UsageReadMetadata } from "../../usage-summary-resource"; +import { UsageIncompleteNotice } from "../usage-incomplete-notice"; export interface ApiKeysWorkspaceProps { keys: ApiKeyEntry[]; @@ -33,6 +35,7 @@ export interface ApiKeysWorkspaceProps { * statement from a key whose counters read zero. */ attributionSince?: string; historyTruncated?: boolean; + usageMetadata?: UsageReadMetadata; authMatrix: ApiAuthMatrixRow[]; keysLoading: boolean; keysLoadFailed: boolean; @@ -80,6 +83,7 @@ export default function ApiKeysWorkspace({ apiBase, attributionSince, historyTruncated, + usageMetadata, authMatrix, keysLoading, keysLoadFailed, @@ -397,6 +401,7 @@ export default function ApiKeysWorkspace({

{t("api.attribution.title")}

+ {/* Branch on the DATASET field, not on `usage`: a key with zero requests under a live dataset really was used zero times, which is not the same as having nothing to attribute. */} @@ -411,17 +416,17 @@ export default function ApiKeysWorkspace({
{selected.usage.requests7d.toLocaleString(localeTag)}
-
{historyTruncated ? t("api.attribution.totalRequestsAvailable") : t("api.attribution.totalRequests")}
+
{historyTruncated || usageMetadata?.usageIncomplete ? t("api.attribution.totalRequestsAvailable") : t("api.attribution.totalRequests")}
{selected.usage.totalRequests.toLocaleString(localeTag)}
{t("api.attribution.lastUsed")}
{selected.usage.lastUsedAt ? formatCreatedDate(selected.usage.lastUsedAt, localeTag) - : t("api.attribution.neverUsed")}
+ : t(usageMetadata?.usageIncomplete ? "api.attribution.noRecordedUse" : "api.attribution.neverUsed")}
-
{historyTruncated ? t("api.attribution.sinceAvailable") : t("api.attribution.since")}
+
{historyTruncated || usageMetadata?.usageIncomplete ? t("api.attribution.sinceAvailable") : t("api.attribution.since")}
{formatCreatedDate(attributionSince, localeTag)}
@@ -465,6 +470,7 @@ export default function ApiKeysWorkspace({ keysLoading={keysLoading} keysLoadFailed={keysLoadFailed} attributionSince={attributionSince} + usageMetadata={usageMetadata} localeTag={localeTag} busy={mutationPending} onSelect={id => { diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 73f0aad616..e478656baa 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -7,7 +7,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useKeyedClientResource } from "../../client-resource"; import { createBoundedFetch } from "../../bounded-fetch"; -import { usageSummary30dResourceKey } from "../../usage-summary-resource"; +import { readUsageMetadata, usageSummary30dResourceKey, type UsageReadMetadata } from "../../usage-summary-resource"; +import { UsageIncompleteNotice } from "../usage-incomplete-notice"; import { useT } from "../../i18n/shared"; import { IconFilter, IconSearch, IconBoxes, IconGlobe, IconLock, IconKey, IconTrash } from "../../icons"; import { @@ -150,6 +151,9 @@ export default function ProviderWorkspaceShell({ const [modelsLoadFailed, setModelsLoadFailed] = useState(false); const quotasCacheKey = `ocx.providers.quotas.v1:${apiBase}`; const usageCacheKey = `ocx.providers.usage.v2:${apiBase}`; + const [usageMetadata, setUsageMetadata] = useState(() => ( + readUsageMetadata(readSessionListCache(usageCacheKey)) + )); const [usageTotals, setUsageTotals] = useState>(() => ( readSessionListCache<{ totals: Record }>(usageCacheKey)?.totals ?? {} )); @@ -235,7 +239,9 @@ export default function ProviderWorkspaceShell({ setUsageTotals(byProvider); const byProviderModels = buildProviderModelUsage(data.models ?? [], byProvider); setUsageModels(byProviderModels); - writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels }); + const metadata = readUsageMetadata(data); + setUsageMetadata(metadata); + writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels, ...metadata }); setUsageLoading(false); }, 0); return () => { cancelled = true; window.clearTimeout(timeout); }; @@ -561,6 +567,7 @@ export default function ProviderWorkspaceShell({
+ {!jsonEditor?.open && } {jsonEditor?.open ? ( {t("usage.incomplete")} + : null; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 868e2a2855..ea0e2d3a8c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,6 +5,9 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "usage.incomplete": "Einige Nutzungsdatensätze konnten nicht berücksichtigt werden. Anzahlen, Datumsangaben und Ranglisten beruhen nur auf lesbaren Datensätzen.", + "models.pickerOrder.usageIncomplete": "Die Reihenfolge nach Nutzung kann wegen unvollständiger Nutzungsdaten nicht gespeichert werden. Wählen Sie eine andere Reihenfolge oder reparieren Sie zuerst den Verlauf.", + "api.attribution.noRecordedUse": "Keine Nutzung in lesbaren Datensätzen", "models.pickerOrder.label": "Modellreihenfolge", "models.pickerOrder.default": "Standard", "models.pickerOrder.alphabetical": "A–Z nach Modell", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index dfa9ad90e9..ce9003e4c5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,6 +6,9 @@ * `{var}` are plain interpolations. */ export const en = { + "usage.incomplete": "Some usage records could not be included. Counts, dates, and rankings reflect readable records only.", + "models.pickerOrder.usageIncomplete": "Cannot save most-used order because usage history is incomplete. Choose another order or repair the history first.", + "api.attribution.noRecordedUse": "No use in readable records", "models.pickerOrder.label": "Picker order", "models.pickerOrder.default": "Default", "models.pickerOrder.alphabetical": "A–Z by model", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2cf33a7a96..bec627f086 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "usage.incomplete": "Certains enregistrements d’utilisation n’ont pas pu être inclus. Les totaux, dates et classements reposent uniquement sur les enregistrements lisibles.", + "models.pickerOrder.usageIncomplete": "Impossible d’enregistrer l’ordre par utilisation : l’historique est incomplet. Choisissez un autre ordre ou réparez d’abord l’historique.", + "api.attribution.noRecordedUse": "Aucune utilisation dans les enregistrements lisibles", "models.pickerOrder.label": "Ordre des modèles", "models.pickerOrder.default": "Par défaut", "models.pickerOrder.alphabetical": "A–Z par modèle", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0f51bbb2d9..169579a211 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "usage.incomplete": "一部の使用履歴を集計できませんでした。回数、日付、順位は読み取れる記録のみを反映しています。", + "models.pickerOrder.usageIncomplete": "使用履歴が不完全なため、使用回数順を保存できません。別の順序を選ぶか、履歴を修復してください。", + "api.attribution.noRecordedUse": "読み取れる記録に使用履歴なし", "models.pickerOrder.label": "モデル選択順", "models.pickerOrder.default": "デフォルト", "models.pickerOrder.alphabetical": "モデル名のA–Z順", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1db3ad6a32..68fb26b955 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "usage.incomplete": "일부 사용량 기록을 집계하지 못했습니다. 횟수, 날짜, 순위는 읽을 수 있는 기록만 반영합니다.", + "models.pickerOrder.usageIncomplete": "사용량 이력이 불완전해 많이 사용한 순서를 저장할 수 없습니다. 다른 순서를 선택하거나 이력을 복구하세요.", + "api.attribution.noRecordedUse": "읽을 수 있는 기록에 사용 내역 없음", "models.pickerOrder.label": "모델 선택 순서", "models.pickerOrder.default": "기본값", "models.pickerOrder.alphabetical": "모델 이름순", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index fc6f61c152..a182aee2e3 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "usage.incomplete": "Часть записей об использовании не удалось учесть. Счётчики, даты и рейтинги основаны только на читаемых записях.", + "models.pickerOrder.usageIncomplete": "Нельзя сохранить порядок по частоте использования: история неполная. Выберите другой порядок или сначала восстановите историю.", + "api.attribution.noRecordedUse": "В читаемых записях использование не найдено", "models.pickerOrder.label": "Порядок моделей", "models.pickerOrder.default": "По умолчанию", "models.pickerOrder.alphabetical": "По имени A–Z", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ee6ae93adf..efe35aa483 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,6 +5,9 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "usage.incomplete": "Bazı kullanım kayıtları dahil edilemedi. Sayılar, tarihler ve sıralamalar yalnızca okunabilir kayıtlara dayanır.", + "models.pickerOrder.usageIncomplete": "Kullanım geçmişi eksik olduğundan en çok kullanılan sıralaması kaydedilemiyor. Başka bir sıralama seçin veya önce geçmişi onarın.", + "api.attribution.noRecordedUse": "Okunabilir kayıtlarda kullanım yok", "models.pickerOrder.label": "Model sırası", "models.pickerOrder.default": "Varsayılan", "models.pickerOrder.alphabetical": "Model adına göre A–Z", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 8f38f5c0f4..1457455cc4 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,6 +2,9 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "usage.incomplete": "部分用量記錄無法納入。次數、日期和排名僅反映可讀取的記錄。", + "models.pickerOrder.usageIncomplete": "用量歷史不完整,無法儲存最常用排序。請選擇其他排序或先修復歷史記錄。", + "api.attribution.noRecordedUse": "可讀取的記錄中沒有使用記錄", "models.pickerOrder.label": "模型選擇順序", "models.pickerOrder.default": "預設", "models.pickerOrder.alphabetical": "依模型名稱 A–Z", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 1cfd82623c..c88aff94f1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "usage.incomplete": "部分用量记录无法计入。次数、日期和排名仅反映可读取的记录。", + "models.pickerOrder.usageIncomplete": "用量历史不完整,无法保存最常用排序。请选择其他排序或先修复历史记录。", + "api.attribution.noRecordedUse": "可读取的记录中没有使用记录", "models.pickerOrder.label": "模型选择顺序", "models.pickerOrder.default": "默认", "models.pickerOrder.alphabetical": "按模型名 A–Z", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index d10ff3c33d..ccfedc11ce 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { Notice } from "../ui"; +import { readUsageMetadata, type UsageReadMetadata } from "../usage-summary-resource"; import { useI18n, LOCALES } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; @@ -27,7 +28,7 @@ import { type ModelTests, } from "./api-keys-utils"; -interface KeysResponse { +interface KeysResponse extends UsageReadMetadata { // `usage` is optional on the wire only so a malformed payload lands in // fetchKeys' validator rather than at the type boundary. A row without it is // rejected, not defaulted: zeroes would assert "never used" about data we @@ -53,7 +54,7 @@ interface StartRotationResponse extends CreateKeyResponse { rotationId?: unknown; } -type CachedKeysShape = { +type CachedKeysShape = UsageReadMetadata & { keys: ApiKeyEntry[]; endpoints: ApiEndpointInfo; claudeCodeEnabled: boolean; @@ -159,6 +160,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a claudeCodeEnabled: data.claudeCodeEnabled !== false, ...(data.attributionSince ? { attributionSince: data.attributionSince } : {}), ...(data.historyTruncated === true ? { historyTruncated: true } : {}), + ...readUsageMetadata(data), authMatrix: data.authMatrix, }; // Prefixes only — never the secret key material. @@ -500,6 +502,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a apiBase={apiBase} attributionSince={attributionSince} historyTruncated={historyTruncated} + usageMetadata={readUsageMetadata(keysData)} authMatrix={authMatrix} keysLoading={false} keysLoadFailed={keysState.showError} diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 0150cd29b0..3a4fb03db6 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1920,8 +1920,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; if (mode === "most-used") { const response = await fetch(`${apiBase}/api/usage?range=all&surface=all`, { signal: bounded.signal }); if (!current()) return; - const payload = await readJsonOrThrow<{ models?: unknown }>(response, t("models.pickerOrder.usageFailed")); + const payload = await readJsonOrThrow<{ models?: unknown; usageIncomplete?: unknown }>(response, t("models.pickerOrder.usageFailed")); if (!current()) return; + if (payload?.usageIncomplete === true) throw new Error(t("models.pickerOrder.usageIncomplete")); if (!isModelPickerUsage(payload?.models)) throw new Error(t("models.pickerOrder.usageFailed")); usage = payload.models; } diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 96f0f1db0c..900ab6eac0 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; +import type { UsageReadMetadata } from "../usage-summary-resource"; +import { UsageIncompleteNotice } from "../components/usage-incomplete-notice"; import { formatProviderDisplayName } from "../provider-icons"; import { formatTokens } from "../format-tokens"; import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters"; @@ -79,7 +81,7 @@ interface UsageProvider { class UsageWindowMismatchError extends Error {} -interface UsageResponse { +interface UsageResponse extends UsageReadMetadata { range: Range; surface: UsageSurface; since: number | null; @@ -989,6 +991,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas ) : ( <> {state.showError && {t(connected ? "usage.hubOffline" : "usage.loadError")}} + {data?.historyTruncated && ( // Naming the loaded window is the point: without it, `30d` and "Available history" // look identical on a busy installation even though both may cover far less than diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index 44b3f8ac3c..c956ca1f4b 100644 --- a/gui/src/pages/dashboard-overview-head.tsx +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -4,6 +4,7 @@ import { formatTokens } from "../format-tokens"; import { formatUptime } from "../formatUptime"; import { navigateHash } from "../hash-routing"; import type { useDashboardData } from "./use-dashboard-data"; +import { UsageIncompleteNotice } from "../components/usage-incomplete-notice"; type Dash = ReturnType; @@ -113,6 +114,7 @@ export function DashboardOverviewHead({ + {projectConfigWarnings.length > 0 && (
diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 029e39b2da..ae24f861a2 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -123,7 +123,7 @@ export interface SidecarPatch { }; } export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } -export interface UsageSummary30d { summary: { requests: number; totalTokens: number; coverageRatio: number } } +export type UsageSummary30d = import("../usage-summary-resource").UsageReadMetadata & { summary: { requests: number; totalTokens: number; coverageRatio: number } }; export type UpdateChannel = "latest" | "preview"; export type Installer = "npm" | "bun" | "source"; export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed"; diff --git a/gui/src/usage-summary-resource.ts b/gui/src/usage-summary-resource.ts index 568860230c..e427258194 100644 --- a/gui/src/usage-summary-resource.ts +++ b/gui/src/usage-summary-resource.ts @@ -1,3 +1,18 @@ +/** Positive diagnostics only: an older response without the flag proves no completeness. */ +export interface UsageReadMetadata { + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; +} + +export function readUsageMetadata(value: unknown): UsageReadMetadata { + if (!value || typeof value !== "object" || !("usageIncomplete" in value) || value.usageIncomplete !== true) return {}; + return { + usageIncomplete: true, + ...("usageIncompleteReason" in value && value.usageIncompleteReason === "oversized_rows" + ? { usageIncompleteReason: "oversized_rows" as const } : {}), + }; +} + export function usageSummary30dResourceKey(apiBase: string, surface: "all" | "codex" = "all"): string { return surface === "codex" ? ["usage-summary-30d", apiBase, "codex"].join(":") diff --git a/gui/tests/apikeys-workspace.test.tsx b/gui/tests/apikeys-workspace.test.tsx index 56ffeacaf1..4695e09430 100644 --- a/gui/tests/apikeys-workspace.test.tsx +++ b/gui/tests/apikeys-workspace.test.tsx @@ -135,6 +135,26 @@ function keyButton(container: HTMLElement, name: string): HTMLButtonElement { .find(el => el.textContent === name)!; } +test("incomplete usage qualifies key list and detail without asserting never used", async () => { + const { root, container, rerender } = await mountWorkspace({ + usageMetadata: { usageIncomplete: true, usageIncompleteReason: "oversized_rows" }, + }); + try { + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).toContain("No use in readable records"); + await act(async () => { keyButton(container, "beta").click(); }); + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).toContain("Requests in available history"); + expect(container.textContent).toContain("No use in readable records"); + await rerender({ attributionSince: undefined }); + expect(container.textContent).toContain("Some usage records could not be included"); + await rerender({ keys: [] }); + expect(container.textContent).toContain("Some usage records could not be included"); + await rerender({ usageMetadata: {} }); + expect(container.textContent).not.toContain("Some usage records could not be included"); + } finally { await act(async () => { root.unmount(); }); } +}); + test("workspace overview navigation preserves pending secret and resets delete confirm", async () => { const { root, container } = await mountWorkspace({ newKey: FULL_SECRET, diff --git a/gui/tests/model-picker-order-editor.test.tsx b/gui/tests/model-picker-order-editor.test.tsx index ea9f808cb7..0789264470 100644 --- a/gui/tests/model-picker-order-editor.test.tsx +++ b/gui/tests/model-picker-order-editor.test.tsx @@ -388,3 +388,39 @@ test("Models pins cache-inferred Custom across late parent GET publication, then await act(async () => { root!.render(); }); expect(host.querySelector(".picker-order-editor")).toBeNull(); }); + +test("Models refuses an incomplete most-used snapshot before PUT and accepts a later readable snapshot", async () => { + const modelRows = ids.map(row => ({ ...row, disabled: false })); + const catalog = { models: modelRows, providers: [{ name: "p" }], selectedModels: {}, disabled: [], contextCaps: {}, contextCapValue: 350_000 }; + const settings = { ...initial(), pickerOrderMode: "most-used" }; + win.sessionStorage.setItem("ocx.models.catalog.v1:/a", JSON.stringify(catalog)); + win.sessionStorage.setItem("ocx.models.catalog.v1:/a:picker-order", JSON.stringify(settings)); + const deferredFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.includes("/api/usage?") || init?.method === "PUT") return deferredFetch(input, init); + const payload = path.endsWith("/api/subagent-models") ? settings + : path.endsWith("/api/models") ? modelRows + : path.endsWith("/api/providers") ? catalog.providers + : path.endsWith("/api/provider-context-caps") ? { caps: {} } + : path.endsWith("/api/selected-models") ? { selected: {} } + : path.endsWith("/api/aliases") ? { providers: {}, models: {}, defaults: { global: false, providers: {} } } + : undefined; + return Promise.resolve(payload === undefined ? new Response(null, { status: 404 }) : Response.json(payload)); + } }); + const { createRoot } = await import("react-dom/client"); + await act(async () => { root = createRoot(host); root.render(); }); + await click("Apply order"); + expect(requests[0]?.url).toBe("/a/api/usage?range=all&surface=all"); + const models = [{ provider: "p", model: "b", requests: 3 }]; + await reply(0, { models, usageIncomplete: true, usageIncompleteReason: "oversized_rows" }); + expect(host.textContent).toContain("Cannot save most-used order because usage history is incomplete"); + expect(requests.map(r => r.method)).toEqual(["GET"]); + expect(button("Apply order").disabled).toBe(false); + await click("Apply order"); + await reply(1, { models }); + expect(requests[2]?.url).toBe("/a/api/subagent-models"); + expect(requests[2]?.method).toBe("PUT"); + expect(requests[2]?.body).toEqual({ pickerOrder: ["p/b", "p/a", "p/c", "p/f"], pickerOrderMode: "most-used" }); + await reply(2, { ok: true, pickerOrder: ["p/b", "p/a", "p/c", "p/f"], pickerOrderMode: "most-used" }); +}); diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index ea4e98e51c..78a3e9b165 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -85,6 +85,25 @@ async function respond(index: number, marker: string, date?: string) { await act(async () => { requests[index].resolve(Response.json(report(requests[index], marker, date))); }); } +test("incomplete usage notice survives held cache and remains visible with no readable rows", async () => { + await mount(); + const partial = { ...report(requests[0], "readable-model"), usageIncomplete: true, usageIncompleteReason: "oversized_rows" }; + await act(async () => { requests[0].resolve(Response.json(partial)); }); + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).toContain("readable-model"); + expect(sessionEntries().some(([, value]) => value?.includes('"usageIncomplete":true'))).toBe(true); + await act(async () => { root!.unmount(); }); + root = undefined; + clearClientResourceStoresForTests(); + await mount(); + expect(container.textContent).toContain("Some usage records could not be included"); + await act(async () => { requests[1].resolve(Response.json({ ...partial, + summary: { ...partial.summary, requests: 0, totalTokens: 0 }, days: [], models: [], + })); }); + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).not.toContain("readable-model"); +}); + const toggle = () => container.querySelector(".usage-range-toggle")!; const form = () => container.querySelector('form[aria-label="Custom date range"]')!; const startInput = () => form().querySelectorAll('input[type="datetime-local"]')[0]; diff --git a/gui/tests/usage-incomplete-consumers.test.tsx b/gui/tests/usage-incomplete-consumers.test.tsx new file mode 100644 index 0000000000..5fdb4b01ae --- /dev/null +++ b/gui/tests/usage-incomplete-consumers.test.tsx @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, type ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { readSessionListCache } from "../src/session-list-cache"; +import { readUsageMetadata } from "../src/usage-summary-resource"; +import { DashboardOverviewHead } from "../src/pages/dashboard-overview-head"; +import ProviderWorkspaceShell from "../src/components/provider-workspace/ProviderWorkspaceShell"; +import AddProviderModal from "../src/components/AddProviderModal"; +import ApiKeys from "../src/pages/ApiKeys"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Map; +let win: Window, host: HTMLElement, root: Root | null; +let usageBody: Record, keysBody: Record; +let hold = false; +const partial = { usageIncomplete: true, usageIncompleteReason: "oversized_rows" }; +const warning = "Some usage records could not be included"; + +beforeEach(() => { + clearClientResourceStoresForTests(); + previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + win.localStorage.setItem("ocx-lang", "en"); + const values = { document: win.document, window: win, navigator: win.navigator, + localStorage: win.localStorage, sessionStorage: win.sessionStorage, IS_REACT_ACT_ENVIRONMENT: true }; + for (const [key, value] of Object.entries(values)) Object.defineProperty(globalThis, key, { configurable: true, value }); + root = null; hold = false; + usageBody = { ...partial, providers: [], models: [] }; + keysBody = { ...partial, keys: [], authMatrix: [{ endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }] }; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { + if (hold) return new Promise((_resolve, reject) => { + if (init?.signal?.aborted) reject(new Error("aborted")); + else init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + const path = String(input); + const body = path.includes("/api/usage?") ? usageBody + : path.endsWith("/api/keys") ? keysBody + : path.endsWith("/api/models") ? [] + : path.endsWith("/v1/models") ? { data: [] } + : path.endsWith("/api/selected-models") ? { selected: {}, available: {}, liveModelCounts: {} } + : path.includes("/api/provider-quotas") ? { reports: [] } + : path.endsWith("/api/oauth/providers") ? { providers: [] } + : path.endsWith("/api/provider-presets") ? { providers: [{ id: "test", label: "Test", adapter: "openai-chat", baseUrl: "https://example.test", auth: "key" }] } + : {}; + return Response.json(body); + } }); + host = document.createElement("div"); document.body.append(host); +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + clearClientResourceStoresForTests(); + win.close(); + for (const key of globals) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +async function mount(node: ReactNode) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { root ??= createRoot(host); root.render({node}); }); + const deadline = Date.now() + 5_000; + while (!host.textContent?.includes(warning) && Date.now() < deadline) { + await act(async () => { await new Promise(resolve => setImmediate(resolve)); }); + } + expect(host.textContent, "expected usage notice must finish rendering").toContain(warning); +} +async function remountFromCache(node: ReactNode) { + await act(async () => { root!.unmount(); }); root = null; + clearClientResourceStoresForTests(); hold = true; + await mount(node); +} + +test("metadata reader preserves positive diagnostics without inferring completeness or copying fields", () => { + for (const value of [null, {}, { usageIncomplete: false }, { usageIncomplete: "true" }]) expect(readUsageMetadata(value)).toEqual({}); + expect(readUsageMetadata({ ...partial, models: [1], token: "private" })).toEqual(partial); + expect(readUsageMetadata({ usageIncomplete: true, usageIncompleteReason: "future_reason" })).toEqual({ usageIncomplete: true }); +}); + +test("Dashboard warns even when no readable requests remain", async () => { + await mount( {}} switchMaMode={async () => {}} maError={null} />); + expect(host.textContent).toContain(warning); +}); + +test("provider usage projection retains incomplete metadata through a cache-only revisit", async () => { + usageBody = { ...partial, providers: [{ provider: "test", requests: 7, totalTokens: 123 }], models: [] }; + const node = {}} onAddProvider={() => {}} />; + await mount(node); + expect(host.textContent).toContain(warning); + const cached = readSessionListCache>("ocx.providers.usage.v2:/provider"); + expect(cached).toMatchObject({ ...partial, totals: { test: { requests: 7, totalTokens: 123 } } }); + await remountFromCache(node); + expect(host.textContent).toContain(warning); +}); + +test("provider catalog explains that its usage ranking can be incomplete without any readable rows", async () => { + await mount( {}} onAdded={() => {}} />); + expect(host.textContent).toContain(warning); +}); + +test("API key fetch and session cache retain incomplete metadata even without attribution or keys", async () => { + const node = ; + await mount(node); + expect(host.textContent).toContain(warning); + const cached = readSessionListCache>("ocx.apikeys.list.v2:/keys"); + expect(cached).toMatchObject({ ...partial, keys: [] }); + expect(cached).not.toHaveProperty("attributionSince"); + await remountFromCache(node); + expect(host.textContent).toContain(warning); +}); diff --git a/src/cli/usage-report.ts b/src/cli/usage-report.ts index 3311a781a1..d9c6652e23 100644 --- a/src/cli/usage-report.ts +++ b/src/cli/usage-report.ts @@ -21,6 +21,8 @@ interface CostRow { } interface UsageReportInput { + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; range?: string; surface?: string; since?: number | null; @@ -109,11 +111,16 @@ function describeScope(data: UsageReportInput): string { export function formatUsageReport(data: UsageReportInput): string[] { const summary = data.summary ?? {}; const lines: string[] = [describeScope(data), ""]; + if (data.usageIncomplete === true) { + lines.push("WARNING: Usage is incomplete; some records could not be included. Totals and rankings reflect readable records only.", ""); + } if (data.filter && !data.filter.matched) { const what = [data.filter.provider && `provider "${data.filter.provider}"`, data.filter.model && `model "${data.filter.model}"`] .filter(Boolean).join(" and "); - lines.push(`No usage recorded for ${terminalText(what)} in this range.`); + lines.push(data.usageIncomplete === true + ? `No matching readable usage records for ${terminalText(what)} in this range; skipped records may contain matches.` + : `No usage recorded for ${terminalText(what)} in this range.`); lines.push("Check the spelling against `ocx usage --json`, or widen --range."); return lines.map(terminalText); } diff --git a/src/server/management/api-key-usage.ts b/src/server/management/api-key-usage.ts index 6a8664dee2..7314ac2a20 100644 --- a/src/server/management/api-key-usage.ts +++ b/src/server/management/api-key-usage.ts @@ -19,6 +19,9 @@ export type ApiKeyUsage = export interface ApiKeyUsageSnapshot { rollup: Map; historyTruncated?: true; + /** Positive evidence of skipped oversized rows; absence is not a completeness guarantee. */ + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; /** * Earliest row carrying a recognized `admissionKind`. A property of the DATA * SET, not of a key, so it is singular and lives beside the map: it is what @@ -223,9 +226,11 @@ export async function readApiKeyUsageRollup(configuredIds: string[], maxReadByte const flight = (async (): Promise => { const accumulator = createApiKeyUsageAccumulator(configuredIds, now); const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); - if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); return cacheApiKeyUsageFromRollup( - accumulator.snapshot(), + { + ...accumulator.snapshot(), + ...(scan.oversizedRows > 0 ? { usageIncomplete: true as const, usageIncompleteReason: "oversized_rows" as const } : {}), + }, configuredIds, usageLogIdentityKey(scan.revision), scan.revision?.size ?? 0, diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 72a019d0a2..cd1e3ed3b0 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -225,6 +225,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise k.id), config.managementUsageMaxReadBytes); + const { rollup, attributionSince, historyTruncated, usageIncomplete, usageIncompleteReason } = await readApiKeyUsageRollup(keys.map(k => k.id), config.managementUsageMaxReadBytes); return jsonResponse({ // 8 random hex past the fixed `ocx_data_` literal: enough to tell two keys // apart in a list, with 128 bits of the tail still unrevealed. Masking only @@ -853,6 +853,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // Dataset-level and singular: it describes the usage log, not any one key. ...(attributionSince ? { attributionSince } : {}), ...(historyTruncated ? { historyTruncated: true } : {}), + ...(usageIncomplete ? { usageIncomplete: true, usageIncompleteReason } : {}), authMatrix: AUTH_MATRIX, ...endpoints, }, 200, req, config); diff --git a/src/server/management/usage-aggregate-cache.ts b/src/server/management/usage-aggregate-cache.ts index 51e02781f1..1cabf9b85b 100644 --- a/src/server/management/usage-aggregate-cache.ts +++ b/src/server/management/usage-aggregate-cache.ts @@ -23,6 +23,7 @@ import { interface RetainedUsageAggregate { accumulator: UsageSummaryAccumulator; + usageIncomplete: boolean; revision: UsageLogRevision | null; identityKey: string; revisionKey: string; @@ -35,6 +36,7 @@ interface RetainedUsageAggregate { export interface UsageAggregateResult { accumulator: UsageSummaryAccumulator; + usageIncomplete: boolean; revision: UsageLogRevision | null; processedThroughBytes: number; overlayVersion: number; @@ -75,6 +77,7 @@ function resultFrom( ): UsageAggregateResult { return { accumulator: state.accumulator, + usageIncomplete: state.usageIncomplete, revision: state.revision, processedThroughBytes: state.processedThroughBytes, overlayVersion: state.overlayVersion, @@ -99,6 +102,7 @@ function makeRetainedAggregate( ): RetainedUsageAggregate { return { accumulator, + usageIncomplete: scan.oversizedRows > 0, revision: scan.revision, identityKey: usageLogIdentityKey(scan.revision), revisionKey: usageLogRevisionKey(scan.revision), @@ -126,9 +130,6 @@ async function rebuildAggregate(options: UsageAggregateOptions): Promise 0) { - throw new Error("usage ledger contains an oversized row"); - } if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) { lastError = new Error("usage aggregation inputs changed during rebuild"); continue; @@ -138,7 +139,10 @@ async function rebuildAggregate(options: UsageAggregateOptions): Promise candidate.add(entry), }); - if (scan.oversizedRows > 0) { - if (retainedAggregate === state) retainedAggregate = null; - throw new Error("usage ledger contains an oversized row"); - } if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) { if (retainedAggregate === state) retainedAggregate = null; rebuildAfterUnpin = true; @@ -200,6 +200,9 @@ async function appendAggregate( const next: RetainedUsageAggregate = { ...state, accumulator: candidate, + // A partial unterminated row can be scanned again on the next append. + // Preserve a boolean diagnostic rather than double-counting omissions. + usageIncomplete: state.usageIncomplete || scan.oversizedRows > 0, revision: scan.revision, identityKey: usageLogIdentityKey(scan.revision), revisionKey: usageLogRevisionKey(scan.revision), @@ -329,7 +332,6 @@ async function rebuildFilteredAggregate( const accumulator = createUsageSummaryAccumulator({ filter, mode: "row-unique", window }); try { const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); - if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) { lastError = new Error("usage aggregation inputs changed during filtered scan"); continue; @@ -362,10 +364,6 @@ async function appendFilteredAggregate( expectedProcessedThroughDigest: state.processedThroughDigest, onEntry: entry => candidate.add(entry), }); - if (scan.oversizedRows > 0) { - if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); - throw new Error("usage ledger contains an oversized row"); - } if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) { if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); rebuildAfterUnpin = true; @@ -373,6 +371,7 @@ async function appendFilteredAggregate( const next: RetainedUsageAggregate = { ...state, accumulator: candidate, + usageIncomplete: state.usageIncomplete || scan.oversizedRows > 0, revision: scan.revision, identityKey: usageLogIdentityKey(scan.revision), revisionKey: usageLogRevisionKey(scan.revision), diff --git a/src/server/management/usage-summary-cache.ts b/src/server/management/usage-summary-cache.ts index 1e6815c0a2..80f35398a3 100644 --- a/src/server/management/usage-summary-cache.ts +++ b/src/server/management/usage-summary-cache.ts @@ -2,6 +2,8 @@ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../../l import type { UsageSummary } from "../../usage/summary"; export type CachedUsageSummary = UsageSummary & { + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; historyTruncated: boolean; truncatedPrefixBytes: number; entriesTruncated: boolean; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index dd98190184..019de2f49d 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -52,3 +52,5 @@ request when a node carries both. Codex's own deferred tool catalog emits exactl so the schema is not something a user can fix from configuration (issue #2673). > Decision record: [ADR-0093](../decisions/ADR-0093-moonshot-ref-with-siblings-normalization.md) + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/catalog.md b/structure/catalog.md index 174e506470..e83c54be42 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -264,3 +264,5 @@ provider wire mapping; unpinned native requests retain their existing pass-throu Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f8e3691f38..9aa9059ed9 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -75,3 +75,5 @@ away from. Resolution stays a pure function of (env, platform, home) so the Wind testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/config.md b/structure/config.md index 48a29a7817..59ef399738 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 270e6c38de..515486a912 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -69,3 +69,5 @@ injects summary generation into a request, and config validation rejects a deliv conflicts with `modelSupportsReasoningSummaries: false` for the same model. > Decision record: [ADR-0045](../decisions/ADR-0045-standalone-images.md) + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 7c90c2f74d..5f809107f3 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -89,3 +89,5 @@ copies an authoritative catalog context window into `limit.context` and a nonemp reasoning ladder into `thinking.effortOptions`. Missing capabilities stay absent instead of falling back to OpenCodex guesses, and the integration does not write the removed `thinking.effort` / `defaultEffort` fields because MCode owns the active effort per session. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/design-methodology.md b/structure/design-methodology.md index bcaf940d72..8e5df2250d 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -36,3 +36,5 @@ surfaces, run through all 3 stages in order. - Design methodology: Product-Personality-Selection (dev-uiux-design §1) - 6 design dials: mood, lightness, density, shape, typography, motion - 7 axes total: design → domain → feature/data/security/ops/cost (derived) + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 40e55b1f1f..6b419367ee 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -122,7 +122,7 @@ this document owns is which module holds which area and what invariant that area | 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`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. 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 the complete `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | +| Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | @@ -408,7 +408,7 @@ An opt-in shadow-call rewrite persists the bounded, redacted original helper mod request content or inferring a helper subtype from timing. `src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts. -The management route streams the complete ledger from its beginning in fixed 1 MiB chunks on a +The management route scans the ledger from its beginning in fixed 1 MiB chunks on a cold rebuild, then retains compact numeric aggregate state and resumes at the last verified LF for ordinary appends. It does not retain the full input or a normalized object for every request, and neither the old byte window nor the parsed-entry cap can discard an earlier prefix before range and @@ -445,6 +445,19 @@ large existing log. The first read is proportional to ledger size; steady-state proportional to newly appended bytes. The Dashboard polls its 30-day usage summary independently once per minute, so usage work cannot delay health/provider/settings state or run every five seconds. +An oversized row is skipped within the existing scanner bound, without shortening provider, +model, or API-key identities. Base and filtered accumulators retain normal rows and a positive +`usageIncomplete` diagnostic. Append publication ORs the previous flag with the new scan; a rebuild +recalculates it. Summary-cache hits and direct or aggregate-seeded API-key rollups preserve the +response-level `usageIncomplete: true` / `usageIncompleteReason: "oversized_rows"` metadata, even +when no normal rows or attributed keys remain. Invalid-row counters are not a sticky diagnostic: +they also include temporarily torn suffixes. Absence of the flag is not a completeness guarantee. +The GUI preserves the metadata in held/session caches and warns in Usage, Dashboard, provider +workspace/catalog, and key list/detail views. Human CLI output warns before no-match early returns; +JSON remains unchanged. Saving a most-used model-order snapshot refuses an incomplete response. +No warning is attached to separate provider quota data. Legacy truncation fields and measurement +coverage keep their existing meanings; file-read/mutation failures still fail closed. + `usage.jsonl` is an append-only runtime ledger. A manual in-place edit earlier than the trailing 64 KiB checkpoint followed by file growth is intentionally outside the incremental detector's contract: validating arbitrary historical rewrites on every refresh would require rereading the diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..18e10554d5 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index dd5f58e345..174dabe7c6 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -132,3 +132,5 @@ Binary detection decodes only the supplied buffer view; malformed UTF-8 can itse so the flag does not identify the peer responsible for corruption. Existing diagnostic files are not rewritten. Audio devices, WebRTC media negotiation, captions and spoken handoff delivery remain client responsibilities. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..c9f672d6dc 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index d9155185fc..fdd7d56e47 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -49,3 +49,5 @@ malformed, gapped, oversized, contradictory, failed, or incomplete streams stay - **Authentication:** `Authorization: Bearer ` + `X-XAI-Token-Auth: xai-grok-cli`. No cookies required. - **Safety & Idempotency:** Managed via `src/grok/reset-coupon-ledger.ts` using UUIDv4 operation tracking before upstream dispatch to prevent duplicate consumption during network flakes. - **Surfaces:** `ocx account grok-reset-coupons` in the terminal, and the dashboard at Providers > xAI Grok > Accounts, where each OAuth row carries a ticket badge with its remaining count and opens a redemption dialog (`gui/src/hooks/useGrokResetCoupons.ts`, `gui/src/components/provider-workspace/GrokResetCoupons.tsx`). The dashboard reads one `GET /api/grok/reset-coupons` per account with at most three in flight, always sends an explicit `tokenId` and a client-minted `operationId`, and treats redemption truth as the settled `code` rather than HTTP 200 — a replayed *failure* returns 200 with `replayed: true`. After a request times out it issues no further consume call, because a redemption whose ledger record is still `open` re-executes. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/runtime.md b/structure/runtime.md index 49a5fb6483..0cb162ee97 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -188,3 +188,5 @@ not an authentication or entitlement decision. Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/subagents.md b/structure/subagents.md index d9e6eaf1d2..6c8f68e48a 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -198,3 +198,5 @@ Native Codex advertisements still follow display priority; private guidance rank Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5acafbf63b..fa6af1b5fc 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -57,3 +57,5 @@ does not cover ordinary requests, streaming, retries, or per-hop redirect review Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..111355e033 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -507,3 +507,5 @@ deprecated, sunset, decommissioned, or no longer available). An unrelated applic not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 42d3442e99..c3cd77c524 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -189,3 +189,5 @@ WebSocket clients observe the same canonical lifecycle. `ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket frame rather than always emitting `response.completed`. If the response status is `failed`, a `response.failed` frame is sent; otherwise `response.completed` carries through the original status. + +Usage consumers preserve positive incomplete-history metadata as specified in [usage accounting](../gui-and-management-api.md#usage-accounting); readable totals are not represented as a complete ledger. diff --git a/tests/cli/cli-usage-report.test.ts b/tests/cli/cli-usage-report.test.ts index 5399137a57..0de6bc7950 100644 --- a/tests/cli/cli-usage-report.test.ts +++ b/tests/cli/cli-usage-report.test.ts @@ -84,6 +84,24 @@ describe("formatUsageReport", () => { expect(JSON.parse(out)).toEqual(malformed); }); + test("incomplete usage retains readable totals and warns even with no data or no match", () => { + const partial = { usageIncomplete: true, usageIncompleteReason: "oversized_rows" }; + const out = formatUsageReport(payload(partial) as never).join("\n"); + expect(out).toContain("WARNING: Usage is incomplete"); + expect(out).toContain("Requests 1,447"); + expect(out).toContain("grok-4.6"); + expect(out.indexOf("WARNING:")).toBeLessThan(out.indexOf("Requests")); + const empty = payload({ ...partial, summary: { requests: 0, totalTokens: 0 }, providers: [], models: [], days: [] }); + expect(formatUsageReport(empty as never).join("\n")).toContain("WARNING: Usage is incomplete"); + const noMatch = formatUsageReport({ ...empty, + filter: { provider: "nope", model: null, matched: false, comboOverlap: false }, + } as never).join("\n"); + expect(noMatch).toContain("WARNING: Usage is incomplete"); + expect(noMatch).toContain("skipped records may contain matches"); + expect(noMatch).not.toContain("No usage recorded"); + expect(formatUsageReport(payload() as never).join("\n")).not.toContain("WARNING: Usage is incomplete"); + }); + test("prints per-provider and per-model cost, not an item count", () => { const out = formatUsageReport(payload() as never).join("\n"); expect(out).toContain("~$12.3456"); @@ -168,6 +186,17 @@ describe("formatUsageReport", () => { }); describe("ocx usage command", () => { + test("incomplete usage succeeds with human warning and unchanged JSON metadata", async () => { + const body = payload({ usageIncomplete: true, usageIncompleteReason: "oversized_rows" }); + const human = await run(["usage"], body); + expect(human.code).toBe(0); + expect(human.out).toContain("WARNING: Usage is incomplete"); + expect(human.out).toContain("grok-4.6"); + const json = await run(["usage", "--json"], body); + expect(json.code).toBe(0); + expect(JSON.parse(json.out)).toEqual(body); + }); + test("duplicate, inline and stray custom-bound arguments do not echo credential-shaped values", async () => { const secret = "sk-" + "a".repeat(40); const errors: string[] = []; diff --git a/tests/server/api-key-attribution.test.ts b/tests/server/api-key-attribution.test.ts index 6be92c4652..469d98f485 100644 --- a/tests/server/api-key-attribution.test.ts +++ b/tests/server/api-key-attribution.test.ts @@ -399,7 +399,7 @@ describe("attribution reaches usage.jsonl", () => { } }); - test("an oversized usage row cannot seed a partial key rollup", async () => { + test.each(["keys-first", "usage-first"])("an oversized usage row preserves an explicitly incomplete key rollup: %s", async order => { saveConfig(remoteConfig()); const now = Date.now(); const oversized = { @@ -428,11 +428,21 @@ describe("attribution reaches usage.jsonl", () => { writeFileSync(usageLogPath(), `${JSON.stringify(oversized)}\n${JSON.stringify(valid)}\n`); const server = startServer(0); try { + if (order === "usage-first") { + const usage = await fetch(new URL("/api/usage?range=all", server.url), { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }).then(res => res.json()); + expect(usage).toMatchObject({ usageIncomplete: true }); + } const payload = await keysGet(server); const keys = payload.keys as Array>; expect((keys.find(key => key.id === "key-one")!.usage as Record).totalRequests).toBe(0); - expect((keys.find(key => key.id === "key-two")!.usage as Record).totalRequests).toBe(0); - expect(payload.attributionSince).toBeUndefined(); + expect((keys.find(key => key.id === "key-two")!.usage as Record).totalRequests).toBe(1); + expect(payload.attributionSince).toBe(new Date(now).toISOString()); + expect(payload).toMatchObject({ usageIncomplete: true, usageIncompleteReason: "oversized_rows" }); + expect(await keysGet(server)).toMatchObject({ + usageIncomplete: true, usageIncompleteReason: "oversized_rows", attributionSince: payload.attributionSince, + }); } finally { await server.stop(true); } diff --git a/tests/server/api-usage.test.ts b/tests/server/api-usage.test.ts index fa5c0ee2e2..9615c622ae 100644 --- a/tests/server/api-usage.test.ts +++ b/tests/server/api-usage.test.ts @@ -868,7 +868,7 @@ describe("GET /api/usage", () => { } }); - test("an oversized row fails closed instead of caching a partial aggregate", async () => { + test("an oversized row preserves normal usage with explicit incomplete cached and filtered results", async () => { const now = Date.now(); const oversized = { requestId: "ocx-oversized", @@ -896,11 +896,18 @@ describe("GET /api/usage", () => { writeFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify(oversized)}\n${JSON.stringify(valid)}\n`); const server = startServer(0); try { - const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(body.error).toBe("read_failed"); - expect(body.summary.requests).toBe(0); - expect(body.historyTruncated).toBe(false); - expect(getUsageSummaryCacheEntry("all:all")).toBeUndefined(); + for (const query of ["range=all", "range=all", "range=7d", "range=all&model=gpt-5.5"]) { + const response = await fetch(new URL(`/api/usage?${query}`, server.url)); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.error).toBeUndefined(); + expect(body.summary.requests).toBe(1); + expect(body.summary.totalTokens).toBe(2); + expect(body).toMatchObject({ + historyTruncated: false, usageIncomplete: true, usageIncompleteReason: "oversized_rows", + }); + } + expect(getUsageSummaryCacheEntry("all:all")?.summary).toMatchObject({ usageIncomplete: true }); } finally { await server.stop(true); } diff --git a/tests/usage/usage-aggregate-cache.test.ts b/tests/usage/usage-aggregate-cache.test.ts index cdaea2c23b..c2a539bf94 100644 --- a/tests/usage/usage-aggregate-cache.test.ts +++ b/tests/usage/usage-aggregate-cache.test.ts @@ -129,6 +129,28 @@ describe("retained usage aggregate cache", () => { expect(report.summary.unmeteredRequests).toBe(1); }); + test.each(["base", "filtered"])("an oversized unfinished suffix stays incomplete without duplicating rows: %s", async scope => { + const path = join(testDir, "usage.jsonl"); + const read = () => scope === "filtered" ? getFilteredUsageAggregate({ provider: "openai" }) : getUsageAggregate({ now: NOW }); + writeFileSync(path, line("one")); + expect(requests(await read())).toBe(1); + appendFileSync(path, JSON.stringify({ + ...entry("oversized"), padding: "x".repeat(usageLedgerScannerModule.USAGE_LEDGER_MAX_LINE_BYTES), + })); + const unfinished = await read(); + expect(unfinished).toMatchObject({ usageIncomplete: true }); + expect(requests(unfinished)).toBe(1); + appendFileSync(path, "\n" + line("two")); + const completed = await read(); + expect(completed).toMatchObject({ usageIncomplete: true }); + expect(requests(completed)).toBe(2); + expect(await read()).toMatchObject({ update: "unchanged", usageIncomplete: true }); + writeFileSync(path, line("replacement")); + const rebuilt = await read(); + expect(rebuilt).toMatchObject({ update: "rebuild", usageIncomplete: false }); + expect(requests(rebuilt)).toBe(1); + }); + test("custom cache keys isolate both endpoints and never poison preset aggregates", async () => { const path = join(testDir, "usage.jsonl"); const rows = [NOW - 2_000, NOW - 1_000, NOW].map((timestamp, index) => ({ ...entry(String(index)), timestamp })); @@ -392,7 +414,7 @@ describe("retained usage aggregate cache", () => { } }); - test("an oversized append result never publishes its partially-fed candidate", async () => { + test("an oversized append retains normal rows and its incomplete marker until rebuild", async () => { writeFileSync(join(testDir, "usage.jsonl"), line("one")); const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; let forceOversizedAppend = false; @@ -412,14 +434,21 @@ describe("retained usage aggregate cache", () => { appendFileSync(join(testDir, "usage.jsonl"), line("two")); forceOversizedAppend = true; - await expect(getUsageAggregate({ now: NOW })).rejects.toThrow("oversized row"); + const partial = await getUsageAggregate({ now: NOW }); + expect(partial).toMatchObject({ update: "append", usageIncomplete: true }); + expect(requests(partial)).toBe(2); expect(requests(original)).toBe(1); - expect(usageAggregateRetainedStats().count).toBe(0); + expect(original).toMatchObject({ usageIncomplete: false }); + expect(usageAggregateRetainedStats().count).toBe(1); forceOversizedAppend = false; + const unchanged = await getUsageAggregate({ now: NOW }); + expect(unchanged).toMatchObject({ update: "unchanged", usageIncomplete: true }); + expect(requests(unchanged)).toBe(2); + writeFileSync(join(testDir, "usage.jsonl"), line("replaced")); const rebuilt = await getUsageAggregate({ now: NOW }); - expect(rebuilt.update).toBe("rebuild"); - expect(requests(rebuilt)).toBe(2); + expect(rebuilt).toMatchObject({ update: "rebuild", usageIncomplete: false }); + expect(requests(rebuilt)).toBe(1); expect(scanStarts).toHaveLength(3); expect(scanStarts[0]).toBe(0); expect(scanStarts[1]).toBeGreaterThan(0); From 57a013155897f4cfd317c8417eaac58f1705590f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:57:42 +0900 Subject: [PATCH 02/53] fix(codex): enforce and explain automatic plan exclusions --- .../_plan/260912_accounts/020_eligibility.md | 21 ++++++++++++++++++ .../021_eligibility_delivery.md | 9 ++++++++ .../docs/fr/guides/codex-integration.md | 3 +-- .../content/docs/guides/codex-integration.md | 3 +-- .../docs/ja/guides/codex-integration.md | 3 +-- .../docs/ko/guides/codex-integration.md | 3 +-- .../docs/ru/guides/codex-integration.md | 3 +-- .../docs/tr/guides/codex-integration.md | 3 +-- .../docs/zh-cn/guides/codex-integration.md | 3 +-- .../docs/zh-tw/guides/codex-integration.md | 3 +-- .../components/codex-account-pool-cards.tsx | 10 +++++++-- gui/src/hooks/useCodexAccountPool.ts | 2 ++ gui/src/i18n/de.ts | 2 ++ gui/src/i18n/en.ts | 2 ++ gui/src/i18n/fr.ts | 2 ++ gui/src/i18n/ja.ts | 2 ++ gui/src/i18n/ko.ts | 2 ++ gui/src/i18n/ru.ts | 2 ++ gui/src/i18n/tr.ts | 2 ++ gui/src/i18n/zh-TW.ts | 2 ++ gui/src/i18n/zh.ts | 2 ++ .../codex-account-pool-pinned-badge.test.tsx | 19 ++++++++++++++++ src/cli/account-api.ts | 8 +++++++ src/cli/account.ts | 3 +++ src/codex/auth-api.ts | 11 ++++++++++ src/codex/routing.ts | 4 +++- structure/catalog.md | 2 ++ structure/clients/claude-desktop.md | 2 ++ structure/codex-home.md | 2 ++ structure/config.md | 2 ++ structure/design-methodology.md | 2 ++ structure/gui-and-management-api.md | 2 ++ structure/ops/docs-and-release.md | 2 ++ structure/overview.md | 2 ++ structure/providers/openai-tiers.md | 6 +++++ structure/runtime.md | 2 ++ structure/subagents.md | 2 ++ tests/cli/cli-account.test.ts | 12 ++++++++++ .../codex-integration/codex-auth-api.test.ts | 16 ++++++++++++++ .../codex-auth-context.test.ts | 22 +++++++++++++++++++ .../codex-pool-plan-exclusion.test.ts | 18 +++++++++++---- 41 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 devlog/_plan/260912_accounts/020_eligibility.md create mode 100644 devlog/_plan/260912_accounts/021_eligibility_delivery.md diff --git a/devlog/_plan/260912_accounts/020_eligibility.md b/devlog/_plan/260912_accounts/020_eligibility.md new file mode 100644 index 0000000000..734bc42d89 --- /dev/null +++ b/devlog/_plan/260912_accounts/020_eligibility.md @@ -0,0 +1,21 @@ +# Finish automatic plan policy and visible exclusion reasons + +Cycle eligibility; C3 selection policy. Depends only on roadmap, independent dev PR. #4238 already added excludedPlans; do not reimplement its selector. Source: routing.ts:1044-1090 and 1326; explicit fixedAccountId path auth-context.ts:826/915. + +MODIFY `src/codex/routing.ts`: export the existing normalized policy predicate (or move the pure plan calculation into `src/codex/plan.ts` and reuse it). Add the predicate to BOTH configured-account fallback guards at preview :2152 and detailed resolve :2391. Before, an all-excluded pool returns its excluded active row; after, ordinary selection returns null/none. Explicit fixed routes retain existing auth, pause, entitlement checks. Native __main__ remains exempt, avoiding physical auth reads on selection-only paths. + +```diff +- && !isCodexAccountPaused(config, active) ++ && !isCodexAccountPaused(config, active) ++ && !isCodexAccountPlanExcluded(config, active) +``` + +MODIFY `src/codex/auth-api.ts`: poolAccountDto adds optional `selectionExcludedReason: "plan_excluded"`, derived from the SAME predicate and config, never from credential health; include current plan already in DTO. MODIFY `src/cli/account-api.ts` AccountRow/CodexAccountDto mapping and `src/cli/account.ts` statusText to show `not-auto-selected(plan=)`. MODIFY `gui/src/components/codex-account-pool-types.ts`, pool-card badge in `codex-account-pool-cards.tsx`, and all locale catalogs: separate localized reason; do not mutate paused/needsReauth and do not disable explicit routing. Unknown plan and empty policy remain eligible; reauth renewal clears the reason dynamically. + +Field chain: existing excludedPlans config create/save/load → same normalized predicate → account DTO JSON → CLI/GUI optional union → status and badge. No new config field or minimumPlan ordering. Enforcing tier: runtime automatic selection only; explicit fixed account intentionally bypasses this selection rule, not auth; residual unknown-plan and native-main exemptions documented, no hard account-block claim. + +MODIFY existing `tests/codex-integration/codex-pool-plan-exclusion.test.ts`: replace last-account soft fallback test with none/preview none; test normalized plan update and explicit fixed route. Extend account API/CLI and card tests for reason and renewal clearing. Sync ownership docs and providers configuration pages that describe the old soft exception. Retain source attribution of #4238; no recarry of already-landed commits. Local tests/build/typecheck NOT RUN. Hosted CI plus rendered artifact from final tip supplies execution proof. + +Exclusion reason derives from the routing config plan, not a display-only freshly observed plan if persistence failed. This preserves truth between selection and explanation. + +P revalidation on dev d6fb87197a: keep exported existing predicate in routing.ts; pass runtimeConfig into both poolAccountDto calls. Alongside closed selectionExcludedReason include selectionExcludedPlan from the same routing config when excluded, so a display-only fresh WHAM tier cannot mislabel the reason. CLI/card render this policy plan. Exact GUI type owner is hooks/useCodexAccountPool.ts; component type file re-exports it. Docs source is guides/codex-integration.md in every locale; revise all-excluded fallback paragraphs there. Callback D delivered PR4352 and left hosted acceptance open; this cycle is independent from current dev. diff --git a/devlog/_plan/260912_accounts/021_eligibility_delivery.md b/devlog/_plan/260912_accounts/021_eligibility_delivery.md new file mode 100644 index 0000000000..ed4405ac17 --- /dev/null +++ b/devlog/_plan/260912_accounts/021_eligibility_delivery.md @@ -0,0 +1,9 @@ +# Plan exclusion completion + +Built on already-landed #4238, independently from current dev d6fb87197a. Existing normalized predicate is shared with the account DTO; both preview and real automatic fallback reject excluded plans when no eligible account remains. Explicit account-qualified routes retain normal auth, pause and entitlement checks. Native main remains exempt. + +CLI and dashboard display the policy's routing-plan reason separately from credential health and a possibly newer display-only plan. The automatic Set-as-next action is suppressed for excluded rows because pinning does not bypass this policy; explicit account-qualified routes remain available. All nine UI locale catalogs and eight affected integration guides are synchronized. Source ownership docs link the canonical plan-exclusion contract. + +Regression sources cover all-excluded preview/resolve, renewal, explicit route with pause/reauth, API reasons, CLI normalization and card display/renewal. No new test file or dependency. Local suites/build/typecheck/install: NOT RUN. Hosted CI and rendered preview remain pending. Source searches: isCodexAccountPlanExcluded, getPoolAccountPlan, poolAccountDto, CodexAccountEntry, selection guards and excludedPlans docs; reused the existing predicate rather than a parallel policy. + +Prior callback cycle delivered PR4352 and remains pending hosted verification. This is an independent dev PR, with no callback code and no manual chain dependency. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index fa2457395d..44c98858bd 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -399,8 +399,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' C'est une politique de sélection, pas un blocage. Un compte écarté conserve ses identifiants, son historique de quota et son affinité de thread, reste visible dans la liste des comptes et demeure joignable par sélection explicite comme `work/gpt-5.5`. Seule la rotation automatique cesse de le choisir, y compris lorsqu'il est déjà le compte actif ou déjà lié à un thread — l'état exact que laisse un abonnement expiré. -Deux limites volontaires. Le compte Codex principal n'est jamais écarté par forfait, car le routage en mode sélection seule ne lit pas son forfait dans les identifiants natifs protégés ; une règle le couvrant se contredirait. Et lorsqu'il ne reste aucun compte non écarté, le compte écarté répond quand même au lieu d'échouer : mettre tous les comptes en pause reste le moyen d'arrêter complètement le service. Il n'existe pas de `minimumPlan`, car classer les forfaits ChatGPT entre eux exige un ordre total qui n'existe pas ici. - +Le compte Codex principal reste exempt de l’exclusion par forfait : le routage en mode sélection seule ne lit pas ses identifiants natifs protégés. Si tous les comptes éligibles du pool sont exclus, la sélection automatique ne renvoie aucun compte. Les routes désignant explicitement un compte restent disponibles, avec les contrôles de pause, d’authentification et de droits du modèle. La carte et le CLI affichent le forfait exclu séparément de l’état des identifiants. Il n’existe pas de réglage `minimumPlan`, faute d’ordre total des forfaits. ## Restauration de Codex natif `ocx stop` arrête le proxy et le service d'arrière-plan installé, puis tente de restaurer Codex natif. OpenCodex retire les éléments de routage dont il peut vérifier la propriété et signale une restauration incomplète si les fichiers de configuration ne peuvent pas être récupérés en toute sécurité. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index ffc383ac8c..6a52f5b584 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -730,8 +730,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' This is a selection policy, not a block. An excluded account keeps its credential, quota history, and thread affinity, stays visible on the account surface, and is still reachable by explicit account selection such as `work/gpt-5.5`. What changes is that automatic rotation stops choosing it, including when it is already the active account or already bound to a thread — which is the state a lapsed subscription leaves behind. -Two deliberate limits. The main Codex account is never excluded by plan, because selection-only routing withholds its plan rather than reading the fenced native credential, so a rule covering it would disagree with itself. And when no unexcluded account remains, the excluded one still answers rather than failing closed; pausing every account is still the way to stop serving entirely. There is no `minimumPlan` counterpart, because ranking ChatGPT plans against each other needs a total ordering that does not exist here. - +The main Codex account remains exempt from plan exclusion; selection-only routing does not read its fenced native credential. If every eligible pool account is excluded, automatic selection returns no account. Explicit account-qualified routes remain available and still enforce pause, authentication and model entitlement. The account card and CLI show the excluded routing plan separately from credential health. There is no `minimumPlan` setting because the plan names do not define a total order. ## Restoring native Codex `ocx stop` stops the proxy and any installed background service, then attempts to restore native Codex. OpenCodex removes verified routing artifacts and reports an incomplete restore when it cannot safely recover configuration files. 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 b1a499dcbc..31a90c96ff 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -265,8 +265,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' これはブロックではなく選択ポリシーです。除外されたアカウントも資格情報・使用量履歴・スレッドアフィニティを保持し、アカウント一覧に表示され、`work/gpt-5.5` のような明示的な指定では引き続き利用できます。変わるのは自動ローテーションが選ばなくなる点で、すでにアクティブなアカウントやスレッドに紐づいている場合も含みます。サブスクリプションが失効した直後は、まさにその状態です。 -意図的な制限が2つあります。メインの Codex アカウントはプランによって除外されません。選択のみのルーティングは保護されたネイティブ資格情報を読まずにプランを伏せるため、メインを対象にすると挙動が食い違うからです。また、除外されていないアカウントが1つも残らない場合は、失敗させずに除外済みのアカウントが応答します。完全に停止したい場合は従来どおり全アカウントを一時停止してください。`minimumPlan` に相当する設定はありません。ChatGPT のプランを順位付けするには、ここに存在しない全順序が必要になるためです。 - +メイン Codex アカウントはプラン除外の対象外です。選択のみのルーティングは保護されたネイティブ資格情報を読みません。利用可能なプールアカウントがすべて除外されると、自動選択はアカウントを返しません。アカウントを明示したルートは引き続き利用でき、一時停止・認証・モデル権限の検査は維持されます。カードと CLI は資格情報の状態とは別に、除外されたルーティングプランを表示します。プランに全順序がないため `minimumPlan` 設定はありません。 ## ネイティブ Codexの復元 `ocx stop` はプロキシとインストール済みのバックグラウンドサービスを停止し、ネイティブ Codex の復元を試みます。OpenCodex は所有を確認できるルーティング設定を削除し、設定ファイルを安全に復元できない場合は未完了として報告します。 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 0dc6021bd6..604a6c1276 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -276,8 +276,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 차단이 아니라 선택 정책입니다. 제외된 계정도 자격 증명과 사용량 기록, 스레드 어피니티를 그대로 유지하고 계정 목록에도 계속 보이며 `work/gpt-5.5` 같은 명시적 지정으로는 여전히 쓸 수 있습니다. 달라지는 것은 자동 로테이션이 그 계정을 고르지 않는다는 점이고, 이미 활성 계정이거나 스레드에 묶여 있는 경우도 포함합니다. 구독이 만료된 계정이 바로 그 상태입니다. -의도한 제한이 두 가지 있습니다. 메인 Codex 계정은 플랜으로 제외하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않고 플랜을 감추기 때문에, 메인까지 적용하면 상황에 따라 판정이 어긋납니다. 그리고 제외되지 않은 계정이 하나도 남지 않으면 실패시키지 않고 제외된 계정이 그대로 응답합니다. 완전히 멈추려면 지금처럼 모든 계정을 일시 중지하면 됩니다. `minimumPlan`에 해당하는 설정은 없습니다. ChatGPT 플랜에 순위를 매기려면 여기 존재하지 않는 전순서가 필요합니다. - +메인 Codex 계정에는 플랜 제외 정책을 적용하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않습니다. 풀의 모든 사용 가능한 계정이 제외되면 자동으로 계정을 선택하지 않습니다. 계정을 직접 지정한 경로는 계속 사용할 수 있으며 일시 중지·인증·모델 사용 권한 검사는 그대로 적용됩니다. 계정 카드와 CLI에는 자격 증명 상태와 별도로 제외된 플랜이 표시됩니다. 플랜 사이에 정해진 순위가 없으므로 `minimumPlan` 설정은 없습니다. ## 네이티브 Codex 복원 `ocx stop`은 proxy와 설치된 background service를 중지한 뒤 네이티브 Codex 복원을 시도합니다. OpenCodex 소유로 확인된 라우팅 항목을 제거하며, 설정 파일을 안전하게 복구할 수 없으면 미완료로 보고합니다. 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 4e5acb9ee6..8bed86f84c 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -392,8 +392,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' Это политика выбора, а не блокировка. Исключённый аккаунт сохраняет учётные данные, историю квот и привязку к треду, остаётся видимым в списке и по-прежнему доступен при явном выборе вроде `work/gpt-5.5`. Меняется только то, что автоматическая ротация перестаёт его выбирать — в том числе когда он уже активен или уже привязан к треду, а именно это состояние остаётся после истёкшей подписки. -Два намеренных ограничения. Основной аккаунт Codex никогда не исключается по тарифу: маршрутизация в режиме «только выбор» скрывает его тариф, чтобы не читать защищённые нативные учётные данные, и правило для него противоречило бы само себе. А если не осталось ни одного неисключённого аккаунта, исключённый всё равно отвечает вместо отказа; чтобы остановить обслуживание полностью, по-прежнему нужно поставить на паузу все аккаунты. Аналога `minimumPlan` нет: чтобы ранжировать тарифы ChatGPT, нужен полный порядок, которого здесь не существует. - +Основной аккаунт Codex не исключается по тарифу: маршрутизация только для выбора не читает защищённые нативные учётные данные. Если все доступные аккаунты пула исключены, автоматический выбор не возвращает аккаунт. Явные маршруты к аккаунту доступны, но проверки паузы, аутентификации и прав на модель сохраняются. Карточка и CLI показывают исключённый тариф отдельно от состояния учётных данных. Настройки `minimumPlan` нет, поскольку тарифы не имеют полного порядка. ## Восстановление нативного Codex `ocx stop` останавливает прокси и установленную фоновую службу, затем пытается восстановить нативный Codex. OpenCodex удаляет настройки маршрутизации, принадлежность которых может подтвердить, и сообщает о неполном восстановлении, если файлы конфигурации нельзя безопасно восстановить. diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index 09493150a4..e307dd002c 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -449,8 +449,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' Bu bir engelleme değil, seçim politikasıdır. Dışarıda bırakılan hesap kimlik bilgisini, kota geçmişini ve iş parçacığı bağını korur, hesap listesinde görünmeye devam eder ve `work/gpt-5.5` gibi açık bir seçimle hâlâ erişilebilir. Değişen tek şey, otomatik rotasyonun onu artık seçmemesidir; hesap zaten etkin olsa ya da bir iş parçacığına bağlı olsa bile. Süresi dolan bir abonelik tam olarak bu durumu bırakır. -İki kasıtlı sınır var. Ana Codex hesabı plana göre hiçbir zaman dışarıda bırakılmaz: yalnızca-seçim yönlendirmesi korunan yerel kimlik bilgisini okumamak için planını saklar, dolayısıyla ana hesabı kapsayan bir kural kendisiyle çelişirdi. Ayrıca dışarıda bırakılmamış hiçbir hesap kalmadığında, dışarıda bırakılan hesap başarısız olmak yerine yine yanıt verir; hizmeti tamamen durdurmak için hâlâ tüm hesapları duraklatmak gerekir. `minimumPlan` karşılığı yoktur, çünkü ChatGPT planlarını sıralamak burada bulunmayan bir tam sıralama gerektirir. - +Ana Codex hesabı plan hariç tutma politikasından muaftır; yalnızca seçim yapan yönlendirme korunan yerel kimlik bilgilerini okumaz. Kullanılabilir tüm havuz hesapları hariç tutulursa otomatik seçim hesap döndürmez. Açıkça hesap belirten yollar kullanılabilir; duraklatma, kimlik doğrulama ve model yetkisi denetimleri korunur. Hesap kartı ve CLI, hariç tutulan yönlendirme planını kimlik bilgisi durumundan ayrı gösterir. Planların tam sıralaması olmadığından `minimumPlan` ayarı yoktur. ## Yerel Codex'i geri yükleme `ocx stop`, proxy'yi ve kurulu arka plan servisini durdurur, ardından yerel Codex'i geri yüklemeyi dener. OpenCodex yalnızca sahipliğini doğrulayabildiği yönlendirme öğelerini kaldırır; yapılandırma dosyaları güvenle geri yüklenemiyorsa işlemin tamamlanmadığını bildirir. 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 1e4cea1d43..db90fd9de5 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 @@ -336,8 +336,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 这是选择策略,不是封禁。被排除的账号保留凭据、用量历史和线程亲和性,仍显示在账号列表中,也仍可通过 `work/gpt-5.5` 这类显式选择使用。改变的只是自动轮换不再选它,包括它已经是活跃账号或已绑定线程的情况——订阅到期后留下的正是这种状态。 -有两处刻意的限制。主 Codex 账号不会因套餐被排除:仅选择模式的路由不读取受保护的原生凭据而隐去其套餐,覆盖主账号的规则会自相矛盾。另外,当没有未被排除的账号时,被排除的账号仍会应答而不是失败;要彻底停止服务,仍然是暂停全部账号。没有对应的 `minimumPlan`,因为给 ChatGPT 套餐排序需要一个这里并不存在的全序。 - +主 Codex 账号不受套餐排除策略影响;仅选择模式不会读取受保护的原生凭据。如果所有可用的池账号都被排除,自动选择不返回账号。明确指定账号的路由仍可使用,并继续检查暂停、认证和模型权限。账号卡片与 CLI 将被排除的路由套餐与凭据健康状态分开显示。套餐没有全序关系,因此不提供 `minimumPlan` 设置。 ## 恢复原生 Codex `ocx stop` 会停止 proxy 和已安装的后台服务,然后尝试恢复原生 Codex。OpenCodex 只移除能够确认归属的路由配置;如果无法安全恢复配置文件,会报告恢复未完成。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index a17997b848..2582af8b10 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -343,8 +343,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}' 這是選擇策略,不是封鎖。被排除的帳號保留憑證、用量紀錄與執行緒親和性,仍顯示在帳號清單中,也仍可透過 `work/gpt-5.5` 這類明確選擇使用。改變的只是自動輪換不再挑它,包括它已經是使用中帳號或已綁定執行緒的情況——訂閱到期後留下的正是這種狀態。 -有兩處刻意的限制。主 Codex 帳號不會因方案被排除:僅選擇模式的路由不讀取受保護的原生憑證而隱去其方案,涵蓋主帳號的規則會自相矛盾。此外,當沒有未被排除的帳號時,被排除的帳號仍會回應而不是失敗;要完全停止服務,仍然是暫停所有帳號。沒有對應的 `minimumPlan`,因為為 ChatGPT 方案排序需要一個這裡並不存在的全序。 - +主 Codex 帳號不受方案排除策略影響;僅選擇模式不會讀取受保護的原生憑證。如果所有可用的池帳號都被排除,自動選取不會回傳帳號。明確指定帳號的路由仍可使用,並繼續檢查暫停、認證及模型權限。帳號卡片與 CLI 將被排除的路由方案與憑證健康狀態分開顯示。方案沒有全序關係,因此不提供 `minimumPlan` 設定。 ## 恢復原生 Codex `ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。 diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 619ec2ecf2..72da4b774c 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -78,6 +78,7 @@ export function CodexAccountPoolCards({ <> {pool.map(a => { const healthStatus = a.health?.status; + const planExcluded = a.selectionExcludedReason === "plan_excluded"; const showReauth = Boolean(a.needsReauth) || oauthHealthShowsReauth(healthStatus); const inCooldown = oauthHealthIsCooldown(healthStatus); const validationPending = a.health?.reason === "validation_pending"; @@ -90,6 +91,11 @@ export function CodexAccountPoolCards({ {a.alias ?? a.email} {a.plan && {a.plan}} + {planExcluded && ( + + {t("codexAuth.planExcluded")} + + )} {a.paused && ( {t("codexAuth.paused")} @@ -102,13 +108,13 @@ export function CodexAccountPoolCards({ {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {isNext(a) && !showReauth && !inCooldown && !validationPending && ( + {isNext(a) && !planExcluded && !showReauth && !inCooldown && !validationPending && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!a.paused && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && ( + {!a.paused && !planExcluded && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && ( diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 2d909b29bd..87aca93743 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -54,6 +54,8 @@ export interface CodexAccountEntry { }; mainAccountHardLock?: MainAccountHardLockStatus; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string }; healthLabel?: string; healthSummary?: string; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fdb18eb6ed..f48259c80a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1329,6 +1329,8 @@ export const de: Record = { "codexAuth.pause": "Pausieren", "codexAuth.resume": "Fortsetzen", "codexAuth.paused": "PAUSIERT", + "codexAuth.planExcluded": "Nicht automatisch gewählt", + "codexAuth.planExcludedHint": "Tarif {plan} ist von der automatischen Auswahl ausgeschlossen. Explizite Kontorouten bleiben verfügbar.", "codexAuth.pauseSucceeded": "{email} ist pausiert", "codexAuth.resumeSucceeded": "{email} ist wieder im Pool verfügbar", "codexAuth.pauseFailed": "{email} konnte nicht pausiert werden. Es wurde nichts geändert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1847a7af7e..88a47c7c66 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1905,6 +1905,8 @@ export const en = { "codexAuth.pause": "Pause", "codexAuth.resume": "Resume", "codexAuth.paused": "PAUSED", + "codexAuth.planExcluded": "Not auto-selected", + "codexAuth.planExcludedHint": "Plan {plan} is excluded from automatic selection. Explicit account routes remain available.", "codexAuth.pauseSucceeded": "{email} is paused", "codexAuth.resumeSucceeded": "{email} is available to the pool again", "codexAuth.pauseFailed": "Could not pause {email}. Nothing was changed.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e465adbb10..71d02a56db 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1837,6 +1837,8 @@ export const fr: Record = { "codexAuth.pause": "Suspendre", "codexAuth.resume": "Reprendre", "codexAuth.paused": "SUSPENDU", + "codexAuth.planExcluded": "Exclu du choix automatique", + "codexAuth.planExcludedHint": "Le forfait {plan} est exclu de la sélection automatique. Les routes explicites vers ce compte restent disponibles.", "codexAuth.pauseSucceeded": "{email} est suspendu", "codexAuth.resumeSucceeded": "{email} est de nouveau disponible dans le groupe", "codexAuth.pauseFailed": "Impossible de suspendre {email}. Aucune modification apportée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9a3d9f58b..022888c079 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1762,6 +1762,8 @@ export const ja: Record = { "codexAuth.pause": "一時停止", "codexAuth.resume": "再開", "codexAuth.paused": "一時停止中", + "codexAuth.planExcluded": "自動選択の対象外", + "codexAuth.planExcludedHint": "プラン {plan} は自動選択の対象外です。アカウントを明示的に指定すると利用できます。", "codexAuth.pauseSucceeded": "{email} を一時停止しました", "codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました", "codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 67ee251970..41fbeca7ee 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1365,6 +1365,8 @@ export const ko: Record = { "codexAuth.pause": "일시 중지", "codexAuth.resume": "재개", "codexAuth.paused": "일시 중지됨", + "codexAuth.planExcluded": "자동 선택 제외", + "codexAuth.planExcludedHint": "{plan} 플랜은 자동 선택에서 제외됩니다. 계정을 직접 지정하면 사용할 수 있습니다.", "codexAuth.pauseSucceeded": "{email} 계정을 일시 중지했습니다", "codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다", "codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 56d43fc301..5ca60bb485 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1832,6 +1832,8 @@ export const ru: Record = { "codexAuth.pause": "Приостановить", "codexAuth.resume": "Возобновить", "codexAuth.paused": "ПРИОСТАНОВЛЕН", + "codexAuth.planExcluded": "Не выбирается автоматически", + "codexAuth.planExcludedHint": "Тариф {plan} исключён из автоматического выбора. Явная маршрутизация на аккаунт доступна.", "codexAuth.pauseSucceeded": "Аккаунт {email} приостановлен", "codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле", "codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b627813bb9..98e50d5556 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1862,6 +1862,8 @@ export const tr: Record = { "codexAuth.pause": "Duraklat", "codexAuth.resume": "Devam Ettir", "codexAuth.paused": "DURAKLATILDI", + "codexAuth.planExcluded": "Otomatik seçilmez", + "codexAuth.planExcludedHint": "{plan} planı otomatik seçimden hariç tutulur. Açık hesap yönlendirmeleri kullanılabilir.", "codexAuth.pauseSucceeded": "{email} duraklatıldı", "codexAuth.resumeSucceeded": "{email} tekrar havuza alındı", "codexAuth.pauseFailed": "{email} duraklatılamadı.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ce06556fd4..a1b9ce40b4 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1453,6 +1453,8 @@ export const zhTW: Record = { "codexAuth.pause": "暫停", "codexAuth.resume": "恢復", "codexAuth.paused": "已暫停", + "codexAuth.planExcluded": "不自動選取", + "codexAuth.planExcludedHint": "方案 {plan} 已排除自動選取。仍可明確指定此帳號。", "codexAuth.pauseSucceeded": "已暫停 {email}", "codexAuth.resumeSucceeded": "{email} 已重新加入帳號池", "codexAuth.pauseFailed": "無法暫停 {email},未做任何變更。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ae2fdfec92..bb222d01ea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1346,6 +1346,8 @@ export const zh: Record = { "codexAuth.pause": "暂停", "codexAuth.resume": "恢复", "codexAuth.paused": "已暂停", + "codexAuth.planExcluded": "不自动选择", + "codexAuth.planExcludedHint": "套餐 {plan} 已从自动选择中排除。仍可明确指定此账号。", "codexAuth.pauseSucceeded": "已暂停 {email}", "codexAuth.resumeSucceeded": "{email} 已重新加入账号池", "codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。", diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx index 9702d7aff9..bb3e616491 100644 --- a/gui/tests/codex-account-pool-pinned-badge.test.tsx +++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx @@ -280,3 +280,22 @@ test("healthy account cards omit log-label and 30-day usage copy", async () => { expect(main.textContent).not.toContain("Log label: main"); expect(hasPinnedHint(main)).toBe(false); }); + + +test("plan exclusion is visible without presenting the account as the next automatic selection", async () => { + await mountPool(makeController({ + accounts: [mainAccount, { ...account, plan: "plus", selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free" }], + activeId: account.id, + })); + const card = cardFor(account.email); + const excluded = [...card.querySelectorAll(".badge")].find(el => el.textContent === en["codexAuth.planExcluded"]); + expect(excluded).toBeTruthy(); + expect(excluded!.getAttribute("title")).toContain("free"); + expect([...card.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(false); + expect(card.textContent).not.toContain(en["codexAuth.paused"]); + expect(switchAction(card)).toBeNull(); + await act(async () => { + root!.render(); + }); + expect(cardFor(account.email).textContent).not.toContain(en["codexAuth.planExcluded"]); +}); diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index e0e573e251..c734f7eb85 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -26,6 +26,8 @@ export interface AccountRow { masked?: string; active: boolean; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; /** Registered credential that is still excluded from routing until validation completes. */ validationPending?: boolean; /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */ @@ -243,6 +245,8 @@ interface CodexAccountDto { plan?: string; isMain?: boolean; needsReauth?: boolean; + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; health?: { reason?: string }; priority?: number; quota?: CodexQuotaDto | null; @@ -309,6 +313,10 @@ export async function fetchCodexRows( plan: a.plan, active: a.id === activeId, needsReauth: a.needsReauth, + ...(a.selectionExcludedReason === "plan_excluded" ? { + selectionExcludedReason: "plan_excluded" as const, + ...(typeof a.selectionExcludedPlan === "string" ? { selectionExcludedPlan: a.selectionExcludedPlan } : {}), + } : {}), ...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}), priority: typeof a.priority === "number" ? a.priority : 0, paused: a.paused === true, diff --git a/src/cli/account.ts b/src/cli/account.ts index 4a8c6a0427..04e7f52be2 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -101,6 +101,9 @@ function statusText(row: AccountRow): string { if (row.active) parts.push(row.type === "codex" ? "selected" : "active"); if (row.needsReauth) parts.push("needs-reauth"); if (row.validationPending) parts.push("validation-pending"); + if (row.selectionExcludedReason === "plan_excluded") { + parts.push(`not-auto-selected(plan=${row.selectionExcludedPlan ?? row.plan ?? "unknown"})`); + } return parts.join(" "); } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 09becf51ea..3b5fc83d1e 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -49,6 +49,7 @@ import { clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, + isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion, resetCodexRoutingForManualSelection, settleCodexQuotaRecoveryProbe, @@ -379,6 +380,7 @@ export type CodexAccountReauthReason = | "forbidden"; function poolAccountDto( + config: OcxConfig, account: CodexAccount, quotaResult: PoolQuotaResult, hasCredential: boolean, @@ -413,6 +415,10 @@ function poolAccountDto( quota: quota ? { ...quota } : null, needsReauth: needsReauth || health.status === "reauth_required", ...(reauthReason !== undefined ? { reauthReason } : {}), + ...(isCodexAccountPlanExcluded(config, account.id) ? { + selectionExcludedReason: "plan_excluded" as const, + selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan), + } : {}), hasCredential, ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}), ...oauthAccountHealthFields("codex", account.id, health), @@ -1192,6 +1198,9 @@ export interface CodexAuthAccountDto { * needs the operator; `/api/oauth/accounts` already carries the same field name. */ reauthReason?: CodexAccountReauthReason; + /** Automatic selection policy only; explicit routes retain their usual auth checks. */ + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; hasCredential: boolean; health: OAuthAccountHealth; healthLabel: OAuthHealthLabel; @@ -2006,6 +2015,7 @@ export async function listCodexAuthAccountsSnapshot( const currentCredential = getCodexAccountCredential(accountId); if (!currentCredential) { return [poolAccountDto( + runtimeConfig, currentAccount, { quota: null, needsReauth: true }, false, @@ -2026,6 +2036,7 @@ export async function listCodexAuthAccountsSnapshot( ? { ...currentAccount, plan: quotaResult.freshPlan } : currentAccount; return [poolAccountDto( + runtimeConfig, dtoAccount, effectiveQuotaResult, true, diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 04c5b8b1ab..83ade05b4b 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -1066,7 +1066,7 @@ function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet | und * selection-only drain so routing never reads the fenced native credential for it, so a rule that * covered main would disagree with itself between drain and ordinary routing. */ -function isCodexAccountPlanExcluded( +export function isCodexAccountPlanExcluded( config: OcxConfig, accountId: string, precomputed?: ReadonlySet, @@ -2155,6 +2155,7 @@ export function previewCodexAccountForRequest( else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) + && !isCodexAccountPlanExcluded(config, active) ) return active; else return null; } @@ -2391,6 +2392,7 @@ export function resolveCodexAccountForThreadDetailed( } else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) + && !isCodexAccountPlanExcluded(config, active) ) { return { status: "selected", accountId: active }; } else { diff --git a/structure/catalog.md b/structure/catalog.md index 91d7734848..3d430fa176 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -268,3 +268,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Account-qualified catalog routes bypass automatic plan exclusions while retaining credential and entitlement checks; see [automatic pool plan exclusions](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 4c86504a08..02a44005da 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -79,3 +79,5 @@ testable on any host: stubbing `process.platform` does not propagate to `os.plat Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Desktop requests routed to the Codex pool use the shared [automatic plan exclusion contract](../providers/openai-tiers.md#automatic-pool-plan-exclusions); explicit account-qualified targets retain their selection semantics. diff --git a/structure/codex-home.md b/structure/codex-home.md index 7c523dd20a..ed52e53a0c 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -226,3 +226,5 @@ a deliberate user choice: Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +Plan-based automatic exclusions leave native credential files untouched and preserve the native-main exemption in the [selection policy](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/config.md b/structure/config.md index 48a29a7817..3980d030c9 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +`codexPool.excludedPlans` is interpreted only by automatic selection; its all-excluded and explicit-route behavior follows the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/design-methodology.md b/structure/design-methodology.md index bcaf940d72..a8e9a822f3 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -36,3 +36,5 @@ surfaces, run through all 3 stages in order. - Design methodology: Product-Personality-Selection (dev-uiux-design §1) - 6 design dials: mood, lightness, density, shape, typography, motion - 7 axes total: design → domain → feature/data/security/ops/cost (derived) + +The Codex account card separates automatic plan-policy exclusion from credential health and suppresses an unavailable next-session action; see the [account selection contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a860a78fc2..ec5a815667 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -515,3 +515,5 @@ survives availability drift, while complete/native custom orders await explicit Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Codex account DTOs and cards expose the routing-plan exclusion separately from credential health; the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions) also governs CLI projection. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..0b0366a2ab 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The account CLI and translated Codex integration guides follow the [automatic plan exclusion contract](../providers/openai-tiers.md#automatic-pool-plan-exclusions), including all-excluded pools and explicit routes. diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..11ce44ed33 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +Codex plan exclusions constrain automatic pool selection without deleting credentials; [account-policy reasons](providers/openai-tiers.md#automatic-pool-plan-exclusions) remain distinct from health and pause. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index b8d1d56675..7c9dce0813 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -398,3 +398,9 @@ model settings, and noncanonical `openai` rows never receive that recovery path. `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a successful main usage refresh clears the runtime mark. + +## Automatic pool plan exclusions + +`src/codex/routing.ts` applies optional `codexPool.excludedPlans` to both candidate selection and existing active/affined accounts. An all-excluded pool returns no automatic candidate, including preview and configured-account fallback. Native main remains exempt and unknown plans remain eligible. Explicit account-qualified routes retain pause, credential and entitlement checks while bypassing only this automatic policy. + +`src/codex/auth-api.ts` projects `selectionExcludedReason: "plan_excluded"` and `selectionExcludedPlan` from the routing config, even when a newer display-only WHAM plan could not be persisted. The dashboard and account CLI show the policy reason separately from credential health; renewal clears the derived fields. The automatic next-session action and badge are omitted for excluded rows. diff --git a/structure/runtime.md b/structure/runtime.md index 495745051e..c9718b89df 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -192,3 +192,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Automatic Codex pool selection and account status share the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/structure/subagents.md b/structure/subagents.md index 3f00e96302..44d922bdcc 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -202,3 +202,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. + +Subagent automatic pool preview returns no candidate when all pool plans are excluded; explicit account-qualified models retain the [selection-policy distinction](providers/openai-tiers.md#automatic-pool-plan-exclusions). diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 214270e4be..fdfa2b6151 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -586,6 +586,18 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("plan exclusions survive the API projection and use the policy plan", async () => { + codexAccounts = [{ id: "policy", plan: "plus", selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free", paused: false }]; + const human = await run(["list", "openai"]); + expect(human.code).toBe(0); + expect(human.stdout).toContain("not-auto-selected(plan=free)"); + const machine = await run(["list", "openai", "--json"]); + expect(JSON.parse(machine.stdout).accounts[0]).toMatchObject({ selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free" }); + codexAccounts = [{ id: "policy", plan: "plus", selectionExcludedReason: "unrecognized", selectionExcludedPlan: "free" }]; + expect((await run(["list", "openai"])).stdout).not.toContain("not-auto-selected"); + expect(JSON.parse((await run(["list", "openai", "--json"])).stdout).accounts[0]).not.toHaveProperty("selectionExcludedReason"); + }); + test.each([100, 12])("pending validation stays visible at %s percent usage without exposing raw health details", async weeklyPercent => { codexAccounts = [{ id: "pending", email: "p***@example.test", quota: { weeklyPercent }, health: { status: "warning", reason: "validation_pending", message: RAW_SENTINEL } }]; diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 89813bb058..4ef25c381c 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1051,6 +1051,22 @@ describe("codex-auth API", () => { } }); + test("account DTO exposes the routing plan exclusion and clears it on renewal", async () => { + const cfg = makeConfig({ codexPool: { excludedPlans: ["free"] } }); + seedPoolAccount(cfg, { id: "plan-row", email: "plan@example.test", plan: "free" }); + const read = async () => { + const request = new Request("http://localhost/api/codex-auth/accounts"); + const response = await handleCodexAuthAPI(request, new URL(request.url), cfg); + const body = await response!.json() as { accounts: CodexAuthAccountDto[] }; + return body.accounts.find(account => account.id === "plan-row")!; + }; + expect(await read()).toMatchObject({ selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free", paused: false }); + cfg.codexAccounts![0].plan = "plus"; + const renewed = await read(); + expect(renewed).not.toHaveProperty("selectionExcludedReason"); + expect(renewed).not.toHaveProperty("selectionExcludedPlan"); + }); + test("GET /api/codex-auth/accounts returns array with main", async () => { const req = new Request("http://localhost/api/codex-auth/accounts", { method: "GET" }); const url = new URL(req.url); diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 580f6d15bd..5c50d021e0 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -1523,6 +1523,28 @@ describe("Codex auth context", () => { .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); + test("explicit account routing bypasses plan policy while retaining pause and reauth checks", async () => { + const cfg = config(); + cfg.codexAccounts!.find(account => account.id === "pool-a")!.plan = "free"; + cfg.codexPool = { excludedPlans: ["free"] }; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, chatgptAccountId: "pool_a_acc", + }); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); + cfg.pausedCodexAccountIds = ["pool-a"]; + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).rejects.toThrow("Selected Codex account is unavailable"); + cfg.pausedCodexAccountIds = []; + markAccountNeedsReauth("pool-a"); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + accountId: "pool-a", modelId: "gpt-5.5", + })).rejects.toThrow("Selected Codex account needs reauthentication"); + }); + test("exact selection reports reauthentication without falling back to the active Pool account", async () => { const cfg = config(); cfg.activeCodexAccountId = "pool-b"; diff --git a/tests/codex-integration/codex-pool-plan-exclusion.test.ts b/tests/codex-integration/codex-pool-plan-exclusion.test.ts index 52a76fb57a..36fe8aed2a 100644 --- a/tests/codex-integration/codex-pool-plan-exclusion.test.ts +++ b/tests/codex-integration/codex-pool-plan-exclusion.test.ts @@ -6,6 +6,7 @@ import { clearCodexUpstreamHealth, clearThreadAccountMap, pickLowestUsageCodexAccount, + isCodexAccountPlanExcluded, previewCodexAccountForRequest, resolveCodexAccountForThread, } from "../../src/codex/routing"; @@ -164,15 +165,24 @@ describe("codex pool plan exclusion", () => { expect(pickLowestUsageCodexAccount(config)).toBe("downgraded"); }); - test("the last remaining account still serves rather than stranding the operator", () => { - // Deliberately unlike pause. #4211 asks for a selection policy, not a hard block, so with no - // unexcluded candidate left the excluded account keeps answering instead of failing closed. + test("automatic routing refuses the last excluded account", () => { const config = makeConfig({ codexAccounts: [{ id: "downgraded", email: "downgraded@test", isMain: false, plan: "free" }], codexPool: { excludedPlans: ["free"] }, } as Partial); recordUsage("downgraded", 10); expect(pickLowestUsageCodexAccount(config)).toBeNull(); - expect(resolveCodexAccountForThread("last-account", config)).toBe("downgraded"); + expect(resolveCodexAccountForThread("last-account", config)).toBeNull(); + expect(previewCodexAccountForRequest("last-account", config)).toBeNull(); }); + test("renewal clears the policy reason without pausing or deleting the account", () => { + const config = makeConfig({ codexPool: { excludedPlans: ["free"] } }); + expect(isCodexAccountPlanExcluded(config, "downgraded")).toBe(true); + config.codexAccounts![0].plan = "plus"; + expect(isCodexAccountPlanExcluded(config, "downgraded")).toBe(false); + expect(isCodexAccountPlanExcluded(config, "__main__")).toBe(false); + expect(config.codexAccounts).toHaveLength(2); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + }); From 3bf0ae126a3d1bbb7d182d72dcaacb39614fc5b2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:59:31 +0900 Subject: [PATCH 03/53] feat(remote): carry bounded executor and hub runtime adapters Carry #3458 runtime foundations with explicit session grants, private state stores and fail-closed Windows command support. Keep server and dashboard activation for the dependent integration layer. Co-authored-by: Ingwannu --- .gitignore | 3 + .npmignore | 1 + .../020_executor_runtime.md | 22 + native/remote-workspace-helper/Cargo.lock | 130 +++ native/remote-workspace-helper/Cargo.toml | 24 + native/remote-workspace-helper/src/main.rs | 49 ++ .../remote-workspace-helper/src/protocol.rs | 246 ++++++ .../src/sandbox/macos.rs | 19 + .../src/sandbox/mod.rs | 77 ++ .../src/sandbox/windows.rs | 15 + .../tests/live_confinement.rs | 75 ++ package.json | 5 + scripts/test-layout/layout.json | 16 + src/cli/remote-workspace.ts | 154 ++++ src/lib/windows-atomic-replace.ts | 1 + src/remote-control/index.ts | 233 +++++- .../workspace-agent-connection.ts | 366 +++++++++ .../workspace-claude-runtime.ts | 243 ++++++ src/remote-control/workspace-codex-runtime.ts | 531 +++++++++++++ src/remote-control/workspace-codex-sandbox.ts | 115 +++ .../workspace-command-runner.ts | 749 ++++++++++++++++++ src/remote-control/workspace-coordinator.ts | 230 ++++++ src/remote-control/workspace-device.ts | 585 ++++++++++++++ src/remote-control/workspace-executable.ts | 43 + src/remote-control/workspace-executor.ts | 396 +++++++++ src/remote-control/workspace-hub.ts | 519 ++++++++++++ src/remote-control/workspace-pi-runtime.ts | 382 +++++++++ src/remote-control/workspace-process.ts | 129 +++ src/remote-control/workspace-rpc.ts | 304 +++++++ src/remote-control/workspace-runtime.ts | 60 ++ src/remote-control/workspace-secret-store.ts | 39 + src/remote-control/workspace-sessions.ts | 730 +++++++++++++++++ src/remote-control/workspace-tool-bridge.ts | 192 +++++ structure/clients/claude-desktop.md | 2 + structure/clients/integrations.md | 2 + structure/config.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/overview.md | 2 + structure/remote-workspace.md | 18 +- structure/runtime.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + .../remote-workspace-agent-wire.test.ts | 324 ++++++++ ...e-workspace-app-server.integration.test.ts | 426 ++++++++++ ...emote-workspace-claude.integration.test.ts | 166 ++++ .../remote-workspace-cli-runtimes.test.ts | 67 ++ tests/clients/remote-workspace-cli.test.ts | 105 +++ .../remote-workspace-codex-runtime.test.ts | 120 +++ .../remote-workspace-command-runner.test.ts | 328 ++++++++ tests/clients/remote-workspace-device.test.ts | 158 ++++ tests/clients/remote-workspace-hub.test.ts | 211 +++++ ...remote-workspace-linux-confinement.test.ts | 114 +++ .../clients/remote-workspace-platform.test.ts | 182 +++++ .../remote-workspace-secret-store.test.ts | 105 +++ .../remote-workspace-session-binding.test.ts | 75 ++ .../clients/remote-workspace-sessions.test.ts | 352 ++++++++ .../remote-workspace-tool-bridge.test.ts | 87 ++ tests/clients/remote-workspace.test.ts | 464 +++++++++++ tests/fake-codex-server.ts | 4 + tests/fixtures/fake-claude-stream.ts | 8 + tests/fixtures/test-layout-expected.json | 16 + 62 files changed, 9984 insertions(+), 47 deletions(-) create mode 100644 native/remote-workspace-helper/Cargo.lock create mode 100644 native/remote-workspace-helper/Cargo.toml create mode 100644 native/remote-workspace-helper/src/main.rs create mode 100644 native/remote-workspace-helper/src/protocol.rs create mode 100644 native/remote-workspace-helper/src/sandbox/macos.rs create mode 100644 native/remote-workspace-helper/src/sandbox/mod.rs create mode 100644 native/remote-workspace-helper/src/sandbox/windows.rs create mode 100644 native/remote-workspace-helper/tests/live_confinement.rs create mode 100644 src/cli/remote-workspace.ts create mode 100644 src/remote-control/workspace-agent-connection.ts create mode 100644 src/remote-control/workspace-claude-runtime.ts create mode 100644 src/remote-control/workspace-codex-runtime.ts create mode 100644 src/remote-control/workspace-codex-sandbox.ts create mode 100644 src/remote-control/workspace-command-runner.ts create mode 100644 src/remote-control/workspace-coordinator.ts create mode 100644 src/remote-control/workspace-device.ts create mode 100644 src/remote-control/workspace-executable.ts create mode 100644 src/remote-control/workspace-executor.ts create mode 100644 src/remote-control/workspace-hub.ts create mode 100644 src/remote-control/workspace-pi-runtime.ts create mode 100644 src/remote-control/workspace-process.ts create mode 100644 src/remote-control/workspace-rpc.ts create mode 100644 src/remote-control/workspace-runtime.ts create mode 100644 src/remote-control/workspace-secret-store.ts create mode 100644 src/remote-control/workspace-sessions.ts create mode 100644 src/remote-control/workspace-tool-bridge.ts create mode 100644 tests/clients/remote-workspace-agent-wire.test.ts create mode 100644 tests/clients/remote-workspace-app-server.integration.test.ts create mode 100644 tests/clients/remote-workspace-claude.integration.test.ts create mode 100644 tests/clients/remote-workspace-cli-runtimes.test.ts create mode 100644 tests/clients/remote-workspace-cli.test.ts create mode 100644 tests/clients/remote-workspace-codex-runtime.test.ts create mode 100644 tests/clients/remote-workspace-command-runner.test.ts create mode 100644 tests/clients/remote-workspace-device.test.ts create mode 100644 tests/clients/remote-workspace-hub.test.ts create mode 100644 tests/clients/remote-workspace-linux-confinement.test.ts create mode 100644 tests/clients/remote-workspace-platform.test.ts create mode 100644 tests/clients/remote-workspace-secret-store.test.ts create mode 100644 tests/clients/remote-workspace-session-binding.test.ts create mode 100644 tests/clients/remote-workspace-sessions.test.ts create mode 100644 tests/clients/remote-workspace-tool-bridge.test.ts create mode 100644 tests/clients/remote-workspace.test.ts create mode 100644 tests/fixtures/fake-claude-stream.ts diff --git a/.gitignore b/.gitignore index ce10233dcc..f32218aafd 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ tests/**/.tmp-* # `git add` three separate times and reached `dev` once — see # tests/ci-workflows/repo-hygiene.test.ts, which fails if any path here becomes tracked again. go/ + +# Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts. +native/**/target/ diff --git a/.npmignore b/.npmignore index acf3a0c4d0..cfbe1d3750 100644 --- a/.npmignore +++ b/.npmignore @@ -19,6 +19,7 @@ gui/eslint.config.* gui/bun.lock # misc +native/remote-workspace-helper/target/ *.test.ts *.map .DS_Store diff --git a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md index 550b2a5bb5..e20200c7c3 100644 --- a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md +++ b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md @@ -82,3 +82,25 @@ REMOTE-ARCH-003: Separate persisted enrollment capabilities from current connect REMOTE-ARCH-006: Use existing required private-file/Windows ACL primitives for new identity and bearer stores. Check permission setup failures and refuse loading/saving secrets when enforcement fails. Do not change global config-store behavior. Record exact selected existing helper in phase-2 P after reading the owner; no best-effort function is accepted as proof. REMOTE-ARCH-007: Codex real App Server tests depend on OCX_CODEX_BIN; Claude real integration on OCX_CLAUDE_BIN; Pi on OCX_PI_BIN. The Linux confinement case can return without execution unless OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT=1 or bwrap is available. Current generic CI alone does not prove those paths. Mock tests prove lifecycle and tool-routing contracts only; native Hub isolation and executor confinement stay explicit final acceptance gaps when not activated. For each adapter separately record denied local tools, inherited plugins/hooks/config, offline refusal and teardown; inspect source plus hosted mocks, no claims of live CLI confinement from flags alone. + +## Phase-2 revalidation and exact owner choices + +Previous D: wp1 inactive foundation source cycle complete at 726ddc7fc0; final hosted proof remains wp4. Continue in child branch codex/260912-60plus-remote-runtime. Existing public exports and added host-negative coverage are retained. + +REMOTE-ARCH-004: storage modules import atomicWriteFile directly from src/config/atomic-write.ts and getConfigDir from src/config/paths.ts, avoiding the broad config.ts barrel. Device CLI orchestration retains explicit runner construction because it computes actual availability after root approval; no import-time probe exists. This is intentional sequential coupling. Server seams in phase 3 use narrow structural connection/session interfaces rather than pulling concrete remote classes into shared request types. No remote module imports server surfaces. + +REMOTE-ARCH-006 exact helpers: NEW src/remote-control/workspace-secret-store.ts owns prepareWorkspaceSecretDirectory(directory) and hardenWorkspaceSecretFile(path). On POSIX use chmodSync with propagated failure and lstat directory/file identity/type checks. On Windows call existing src/lib/windows-secret-acl.ts hardenSecretDir/hardenSecretPath with required:true. Reject symlink state targets. All three stores use this before reads and before atomicWriteFile. Existing atomic-write.ts already creates an empty private descriptor, hardens before writing bytes, and scrubs failures; retain it. Tests: NEW tests/clients/remote-workspace-secret-store.test.ts covers owner-only POSIX file mode, unexpected path types/symlinks and failed reads; hosted Windows ACL owner tests remain applicable. No global config behavior changes. + +src/lib/windows-atomic-replace.ts change is the new ReplacePublisher literal remote-workspace (the function is already exported). Use existing counter serialization/consumers unchanged: creation at executor write, diagnostic key serialization, dynamic record readers; no closed switch to extend. + +NEW tests/clients/remote-workspace-session-binding.test.ts covers session/device/root/capability mismatches with zero execution and a valid positive control, using encrypted messages and independent fixtures. MODIFY agent-wire, hub, sessions and device tests to assert subset negotiation and presence intersection. Platform runner source retains existing fail-closed native paths; remove stale comment claiming supported macOS commands. + +### Audit amendment: store-level failure propagation + +Hub/Device/Session file-store constructors accept an optional narrow permissions dependency containing prepareDirectory and hardenFile, defaulting to the required production helper. Load returns null for absent files; existing files require directory and file checks before secret reads. Save prepares directory, hardens an existing target, then invokes the existing private atomic writer. For each store, injected directory/file hardening throws must propagate, preserve existing bytes and prevent secret IO. New-state first-run controls return null then save/load valid fixtures. Add all three store cases to remote-workspace-secret-store.test.ts; this injection observes caller ordering rather than relying on ACL-owner tests alone. + +### Native containment amendment + +Independent source review requires a protected Linux bubblewrap executable outside writable roots, with identity revalidation before use. Custom executable files and their parent chain must not be writable by group/other; canonical system symlinks are resolved before checking. Workspace roots cannot contain the executable; every invocation rechecks. Add source/runner regression fixtures without claiming a local run. + +Windows command availability remains disabled in this carry: nativeRemoteWorkspaceCommandRunnerAvailable returns false before invoking the helper, and the official Windows helper rejects public probe/run without allocating OS resources. The candidate Windows implementation remains in original PR history; do not retain callable unverified entrypoints. This matches the fail-closed macOS policy and preserves independently authorized file tools. Update native denial tests and docs; Windows working-command acceptance stays OPEN. A future lifecycle owner and hosted cancellation/cleanup evidence are required before re-enablement. This is a safety limitation, not completion of Windows commands. diff --git a/native/remote-workspace-helper/Cargo.lock b/native/remote-workspace-helper/Cargo.lock new file mode 100644 index 0000000000..8dba097e9d --- /dev/null +++ b/native/remote-workspace-helper/Cargo.lock @@ -0,0 +1,130 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "opencodex-remote-workspace-helper" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "windows-sys", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/remote-workspace-helper/Cargo.toml b/native/remote-workspace-helper/Cargo.toml new file mode 100644 index 0000000000..65bd1d0ba7 --- /dev/null +++ b/native/remote-workspace-helper/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "opencodex-remote-workspace-helper" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[dependencies] +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Isolation", + "Win32_Storage_FileSystem", + "Win32_System_JobObjects", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_Threading", +] } diff --git a/native/remote-workspace-helper/src/main.rs b/native/remote-workspace-helper/src/main.rs new file mode 100644 index 0000000000..8312186b8d --- /dev/null +++ b/native/remote-workspace-helper/src/main.rs @@ -0,0 +1,49 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +mod protocol; +mod sandbox; + +use std::io::{self, Read, Write}; + +use protocol::{HelperRequest, HelperResponse, MAX_REQUEST_BYTES, PROTOCOL_VERSION}; + +fn main() { + if std::env::args().nth(1).as_deref() == Some("__probe-child") { + std::process::exit(sandbox::run_probe_child()); + } + + let response = match read_request().and_then(handle_request) { + Ok(response) => response, + Err(error) => HelperResponse::error(error), + }; + let mut stdout = io::stdout().lock(); + if serde_json::to_writer(&mut stdout, &response).is_err() || stdout.write_all(b"\n").is_err() { + std::process::exit(2); + } +} + +fn read_request() -> Result { + let mut body = Vec::new(); + io::stdin() + .take((MAX_REQUEST_BYTES + 1) as u64) + .read_to_end(&mut body) + .map_err(|_| "could not read helper request".to_owned())?; + if body.len() > MAX_REQUEST_BYTES { + return Err("helper request exceeds its size limit".to_owned()); + } + let request: HelperRequest = + serde_json::from_slice(&body).map_err(|_| "helper request is invalid".to_owned())?; + request.validate()?; + Ok(request) +} + +fn handle_request(request: HelperRequest) -> Result { + if request.version != PROTOCOL_VERSION { + return Err("unsupported helper protocol version".to_owned()); + } + match request.operation.as_str() { + "probe" => sandbox::probe().map(|()| HelperResponse::probe_success()), + "run" => sandbox::run(&request).map(HelperResponse::command_success), + _ => Err("unsupported helper operation".to_owned()), + } +} diff --git a/native/remote-workspace-helper/src/protocol.rs b/native/remote-workspace-helper/src/protocol.rs new file mode 100644 index 0000000000..900f630e5c --- /dev/null +++ b/native/remote-workspace-helper/src/protocol.rs @@ -0,0 +1,246 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +#[cfg(target_os = "windows")] +use std::path::PathBuf; + +pub const PROTOCOL_VERSION: u8 = 1; +pub const MAX_REQUEST_BYTES: usize = 64 * 1024; +pub const MAX_OUTPUT_BYTES: usize = 256 * 1024; +const MAX_PATH_BYTES: usize = 4096; +const MAX_COMMAND_ARGUMENTS: usize = 64; +const MAX_COMMAND_ARGUMENT_BYTES: usize = 4096; +const MAX_COMMAND_BYTES: usize = 16 * 1024; +const MAX_TOOLCHAIN_ROOTS: usize = 16; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct HelperRequest { + pub version: u8, + pub operation: String, + #[serde(default)] + pub root: String, + #[serde(default)] + pub cwd: String, + #[serde(default)] + pub command: Vec, + #[serde(default)] + pub toolchain_roots: Vec, + #[serde(default)] + pub timeout_ms: u64, + #[serde(default)] + pub max_output_bytes: usize, + #[serde(default)] + pub network_access: bool, +} + +impl HelperRequest { + pub fn validate(&self) -> Result<(), String> { + if self.operation == "probe" { + if !self.root.is_empty() + || !self.cwd.is_empty() + || !self.command.is_empty() + || !self.toolchain_roots.is_empty() + || self.timeout_ms != 0 + || self.max_output_bytes != 0 + || self.network_access + { + return Err("probe request must not carry command authority".to_owned()); + } + return Ok(()); + } + if self.operation != "run" { + return Ok(()); + } + validate_path(&self.root, "workspace root")?; + validate_path(&self.cwd, "command cwd")?; + if !Path::new(&self.root).is_absolute() || !Path::new(&self.cwd).is_absolute() { + return Err("workspace root and cwd must be absolute".to_owned()); + } + if self.command.is_empty() || self.command.len() > MAX_COMMAND_ARGUMENTS { + return Err("invalid command vector".to_owned()); + } + let mut command_bytes = 0usize; + for value in &self.command { + if value.is_empty() || value.len() > MAX_COMMAND_ARGUMENT_BYTES || value.contains('\0') + { + return Err("invalid command vector".to_owned()); + } + command_bytes = command_bytes + .checked_add(value.len()) + .ok_or_else(|| "command vector is too large".to_owned())?; + } + if command_bytes > MAX_COMMAND_BYTES { + return Err("command vector is too large".to_owned()); + } + if self.toolchain_roots.len() > MAX_TOOLCHAIN_ROOTS { + return Err("too many toolchain roots".to_owned()); + } + for path in &self.toolchain_roots { + validate_path(path, "toolchain root")?; + if !Path::new(path).is_absolute() { + return Err("toolchain roots must be absolute".to_owned()); + } + } + if !(1..=60_000).contains(&self.timeout_ms) { + return Err("command timeout is outside its limit".to_owned()); + } + if !(1024..=MAX_OUTPUT_BYTES).contains(&self.max_output_bytes) { + return Err("command output limit is outside its limit".to_owned()); + } + Ok(()) + } + + #[cfg(target_os = "windows")] + pub fn canonical_paths(&self) -> Result { + let root = canonical_directory(&self.root, "workspace root")?; + let cwd = canonical_directory(&self.cwd, "command cwd")?; + if !cwd.starts_with(&root) { + return Err("command cwd escaped its workspace root".to_owned()); + } + let mut toolchain_roots = Vec::with_capacity(self.toolchain_roots.len()); + for value in &self.toolchain_roots { + let canonical = canonical_directory(value, "toolchain root")?; + if !toolchain_roots.contains(&canonical) { + toolchain_roots.push(canonical); + } + } + Ok(CanonicalPaths { + root, + cwd, + toolchain_roots, + }) + } +} + +fn validate_path(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() || value.len() > MAX_PATH_BYTES || value.contains('\0') { + return Err(format!("invalid {label}")); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn canonical_directory(value: &str, label: &str) -> Result { + let original = Path::new(value); + let metadata = + std::fs::symlink_metadata(original).map_err(|_| format!("{label} is unavailable"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("{label} must remain a real directory")); + } + original + .canonicalize() + .map_err(|_| format!("{label} is unavailable")) +} + +#[cfg(target_os = "windows")] +#[derive(Debug)] +pub struct CanonicalPaths { + pub root: PathBuf, + pub cwd: PathBuf, + pub toolchain_roots: Vec, +} + +#[derive(Debug)] +pub struct CommandOutcome { + pub exit_code: i32, + pub stdout: Vec, + pub stderr: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelperResponse { + version: u8, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + probe: Option, + #[serde(skip_serializing_if = "Option::is_none")] + exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stdout_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stderr_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl HelperResponse { + pub fn error(error: String) -> Self { + Self { + version: PROTOCOL_VERSION, + ok: false, + probe: None, + exit_code: None, + stdout_base64: None, + stderr_base64: None, + error: Some(limit_error(error)), + } + } + + pub fn probe_success() -> Self { + Self { + version: PROTOCOL_VERSION, + ok: true, + probe: Some(true), + exit_code: None, + stdout_base64: None, + stderr_base64: None, + error: None, + } + } + + pub fn command_success(outcome: CommandOutcome) -> Self { + Self { + version: PROTOCOL_VERSION, + ok: true, + probe: None, + exit_code: Some(outcome.exit_code), + stdout_base64: Some(STANDARD.encode(outcome.stdout)), + stderr_base64: Some(STANDARD.encode(outcome.stderr)), + error: None, + } + } +} + +fn limit_error(mut value: String) -> String { + const MAX_ERROR_CHARS: usize = 512; + if value.chars().count() <= MAX_ERROR_CHARS { + return value; + } + value = value.chars().take(MAX_ERROR_CHARS).collect(); + value.push('…'); + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_authority_smuggled_into_probe() { + let request: HelperRequest = + serde_json::from_str(r#"{"version":1,"operation":"probe","command":["whoami"]}"#) + .expect("valid JSON fixture"); + assert!(request.validate().is_err()); + } + + #[test] + fn rejects_unknown_wire_fields() { + assert!( + serde_json::from_str::( + r#"{"version":1,"operation":"probe","surprise":true}"#, + ) + .is_err() + ); + } + + #[test] + fn bounds_command_shape_before_platform_code() { + let request: HelperRequest = serde_json::from_str( + r#"{"version":1,"operation":"run","root":"/tmp/a","cwd":"/tmp/a","command":["x"],"timeoutMs":0,"maxOutputBytes":262144}"#, + ) + .expect("valid JSON fixture"); + assert!(request.validate().is_err()); + } +} diff --git a/native/remote-workspace-helper/src/sandbox/macos.rs b/native/remote-workspace-helper/src/sandbox/macos.rs new file mode 100644 index 0000000000..2052f82707 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/macos.rs @@ -0,0 +1,19 @@ +use crate::protocol::{CommandOutcome, HelperRequest}; + +const MACOS_CONFINEMENT_UNAVAILABLE: &str = + "macOS Remote Workspace command confinement is unavailable; file tools remain enabled"; + +/// macOS has no unprivileged Job Object or cgroup equivalent that can revoke every descendant's +/// workspace access. A Seatbelt profile can constrain a process, but allowing subprocesses lets a +/// descendant call `setsid()` and outlive cancellation. Importing broad system profiles merely to +/// make a single-process probe start would also widen unrelated host-service authority. Until a +/// native containment owner closes both boundaries, command execution must stay unavailable. +pub fn probe() -> Result<(), String> { + Err(MACOS_CONFINEMENT_UNAVAILABLE.to_owned()) +} + +/// Keep the helper itself fail-closed even if a caller bypasses OCX capability negotiation and +/// submits a `run` request directly. +pub fn run(_request: &HelperRequest) -> Result { + Err(MACOS_CONFINEMENT_UNAVAILABLE.to_owned()) +} diff --git a/native/remote-workspace-helper/src/sandbox/mod.rs b/native/remote-workspace-helper/src/sandbox/mod.rs new file mode 100644 index 0000000000..4b9bf551d4 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/mod.rs @@ -0,0 +1,77 @@ +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +use crate::protocol::{CommandOutcome, HelperRequest}; +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +#[cfg(target_os = "macos")] +pub use macos::{probe, run}; +#[cfg(target_os = "windows")] +pub use windows::{probe, run}; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub fn probe() -> Result<(), String> { + Err("native helper is supported only on macOS and Windows".to_owned()) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub fn run(_request: &HelperRequest) -> Result { + Err("native helper is supported only on macOS and Windows".to_owned()) +} + +pub fn run_probe_child() -> i32 { + let mut args = std::env::args().skip(2); + let Some(workspace) = args.next() else { + return 20; + }; + let Some(outside_file) = args.next() else { + return 21; + }; + let Some(outside_write) = args.next() else { + return 22; + }; + let Some(listener_address) = args.next() else { + return 23; + }; + let Some(existing_workspace_file) = args.next() else { + return 24; + }; + if args.next().is_some() { + return 24; + } + + let marker = std::path::Path::new(&workspace).join("probe-marker"); + if fs::write(&marker, b"sandboxed").is_err() { + return 25; + } + if !matches!(fs::read(&existing_workspace_file), Ok(value) if value == b"existing") + || fs::write(&existing_workspace_file, b"updated").is_err() + { + return 29; + } + let mut outside = Vec::new(); + if OpenOptions::new() + .read(true) + .open(&outside_file) + .and_then(|mut file| file.read_to_end(&mut outside)) + .is_ok() + { + return 26; + } + if fs::write(&outside_write, b"escaped").is_ok() { + return 27; + } + let Ok(listener_address) = listener_address.parse::() else { + return 23; + }; + if TcpStream::connect_timeout(&listener_address, Duration::from_millis(500)).is_ok() { + return 28; + } + 0 +} diff --git a/native/remote-workspace-helper/src/sandbox/windows.rs b/native/remote-workspace-helper/src/sandbox/windows.rs new file mode 100644 index 0000000000..2ecefef055 --- /dev/null +++ b/native/remote-workspace-helper/src/sandbox/windows.rs @@ -0,0 +1,15 @@ +use crate::protocol::{CommandOutcome, HelperRequest}; + +const WINDOWS_CONFINEMENT_UNAVAILABLE: &str = + "Windows Remote Workspace command confinement is unavailable; command execution is disabled"; + +// A command-capable implementation must retain cleanup ownership through helper cancellation +// and establish Job membership atomically. Until that owner is implemented and verified, +// direct helper requests and capability probes refuse before allocating OS resources. +pub fn probe() -> Result<(), String> { + Err(WINDOWS_CONFINEMENT_UNAVAILABLE.to_owned()) +} + +pub fn run(_request: &HelperRequest) -> Result { + Err(WINDOWS_CONFINEMENT_UNAVAILABLE.to_owned()) +} diff --git a/native/remote-workspace-helper/tests/live_confinement.rs b/native/remote-workspace-helper/tests/live_confinement.rs new file mode 100644 index 0000000000..e735029ac8 --- /dev/null +++ b/native/remote-workspace-helper/tests/live_confinement.rs @@ -0,0 +1,75 @@ +#![cfg(any(target_os = "macos", target_os = "windows"))] + +use serde_json::Value; +use std::io::Write; +use std::process::{Command, Stdio}; + +fn run_helper(request: &Value) -> Value { + let binary = env!("CARGO_BIN_EXE_opencodex-remote-workspace-helper"); + let mut child = Command::new(binary) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("native helper starts"); + child + .stdin + .take() + .expect("native helper stdin") + .write_all(&serde_json::to_vec(request).expect("helper request serializes")) + .expect("helper request is written"); + let output = child.wait_with_output().expect("native helper exits"); + assert!( + output.status.success(), + "helper stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("helper response is JSON") +} + +fn run_probe() -> Value { + run_helper(&serde_json::json!({ "version": 1, "operation": "probe" })) +} + +#[cfg(target_os = "windows")] +#[test] +fn native_helper_keeps_windows_command_execution_fail_closed() { + let unavailable = serde_json::json!({ + "version": 1, + "ok": false, + "error": "Windows Remote Workspace command confinement is unavailable; command execution is disabled" + }); + assert_eq!(run_probe(), unavailable); + let root = std::env::current_dir().expect("test cwd"); + assert_eq!(run_helper(&serde_json::json!({ + "version": 1, "operation": "run", "root": root, "cwd": root, + "command": ["cmd.exe", "/c", "exit"], "timeoutMs": 1000, "maxOutputBytes": 4096 + })), unavailable); +} + +#[cfg(target_os = "macos")] +#[test] +fn native_helper_keeps_macos_command_execution_fail_closed() { + let unavailable = serde_json::json!({ + "version": 1, + "ok": false, + "error": "macOS Remote Workspace command confinement is unavailable; file tools remain enabled" + }); + assert_eq!(run_probe(), unavailable); + + let root = std::env::current_dir().expect("test cwd"); + assert_eq!( + run_helper(&serde_json::json!({ + "version": 1, + "operation": "run", + "root": root, + "cwd": root, + "command": ["/usr/bin/true"], + "toolchainRoots": [], + "timeoutMs": 5_000, + "maxOutputBytes": 16 * 1024, + "networkAccess": false + })), + unavailable + ); +} diff --git a/package.json b/package.json index 6fae3e4d49..593ae79698 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "README.md", "SPONSORS.md", "AGENTS_INSTALL.md", + "native/remote-workspace-helper/Cargo.toml", + "native/remote-workspace-helper/Cargo.lock", + "native/remote-workspace-helper/src", "LICENSE" ], "engines": { @@ -52,6 +55,8 @@ "structure:check": "bun scripts/structure-ssot.ts", "generate:model-metadata": "bun scripts/generate-model-metadata.ts", "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package", + "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", + "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "prepare:package": "bun scripts/prepare-package.ts", "prepack": "bun run prepare:package", "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index fa89d735b8..55b8a9bf44 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1051,6 +1051,22 @@ "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", + "remote-workspace-secret-store.test.ts": "clients", + "remote-workspace-session-binding.test.ts": "clients", + "remote-workspace-agent-wire.test.ts": "clients", + "remote-workspace-app-server.integration.test.ts": "clients", + "remote-workspace-claude.integration.test.ts": "clients", + "remote-workspace-cli-runtimes.test.ts": "clients", + "remote-workspace-cli.test.ts": "clients", + "remote-workspace-codex-runtime.test.ts": "clients", + "remote-workspace-command-runner.test.ts": "clients", + "remote-workspace-device.test.ts": "clients", + "remote-workspace-hub.test.ts": "clients", + "remote-workspace-linux-confinement.test.ts": "clients", + "remote-workspace-platform.test.ts": "clients", + "remote-workspace-sessions.test.ts": "clients", + "remote-workspace-tool-bridge.test.ts": "clients", + "remote-workspace.test.ts": "clients", "remote-control-prototype.test.ts": "clients", "remote-workspace-protocol.test.ts": "clients", "remote-workspace-rpc-framing.test.ts": "clients", diff --git a/src/cli/remote-workspace.ts b/src/cli/remote-workspace.ts new file mode 100644 index 0000000000..5eb8a59c44 --- /dev/null +++ b/src/cli/remote-workspace.ts @@ -0,0 +1,154 @@ +import type { RemoteWorkspaceDeviceState } from "../remote-control/workspace-device"; +import { + RemoteWorkspaceDeviceFileStore, + pairRemoteWorkspaceDevice, + remoteWorkspaceCapabilitiesForCommandRunner, + runRemoteWorkspaceAgent, + type PairRemoteWorkspaceDeviceOptions, + type RemoteWorkspaceAgentRunStatus, + type RemoteWorkspaceDeviceStateStore, +} from "../remote-control/workspace-device"; +import { createPlatformRemoteWorkspaceCommandRunner } from "../remote-control/workspace-command-runner"; +import { + CliUsageError, + readSecretLine, + rejectArgs, + takeFlag, + takeJsonFlag, + takeOption, + type RuntimeApiDeps, +} from "./runtime-api"; + +export const REMOTE_WORKSPACE_USAGE = `Usage: + ocx remote-workspace pair --pairing-code-stdin --root [--root ...] [--toolchain-root ...] [--executor-helper ] [--name ] [--json] + ocx remote-workspace agent + ocx remote-workspace status [--json]`; + +export interface RemoteWorkspaceCliDeps extends RuntimeApiDeps { + store?: RemoteWorkspaceDeviceStateStore; + pair?: (options: PairRemoteWorkspaceDeviceOptions) => Promise; + runAgent?: typeof runRemoteWorkspaceAgent; + signal?: AbortSignal; + onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void; +} + +function takeRepeatedPathFlag(args: string[], flag: "--root" | "--toolchain-root"): string[] { + const roots: string[] = []; + for (;;) { + const index = args.indexOf(flag); + if (index < 0) break; + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new CliUsageError(`${flag} requires an absolute path`, REMOTE_WORKSPACE_USAGE); + roots.push(value); + args.splice(index, 2); + } + return roots; +} + +function publicStatus(state: RemoteWorkspaceDeviceState | null): Record { + if (!state) return { paired: false }; + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner( + createPlatformRemoteWorkspaceCommandRunner({ + linux: { + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + }, + ...(state.nativeHelper ? { native: { + helper: state.nativeHelper, + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + } } : {}), + }), + state.capabilities, + ); + return { + paired: true, + hubUrl: state.hubUrl, + deviceId: state.deviceId, + deviceName: state.deviceName, + devicePlatform: state.devicePlatform, + capabilities, + roots: state.roots.map(root => ({ id: root.id, label: root.label, path: root.path })), + toolchainRoots: state.toolchainRoots, + }; +} + +export async function runRemoteWorkspaceCommand(rawArgs: string[], deps: RemoteWorkspaceCliDeps = {}): Promise { + const args = [...rawArgs]; + const command = args.shift(); + const store = deps.store ?? new RemoteWorkspaceDeviceFileStore(); + if (command === "status") { + const wantsJson = takeJsonFlag(args); + rejectArgs(args, REMOTE_WORKSPACE_USAGE); + const status = publicStatus(store.load()); + if (wantsJson) console.log(JSON.stringify(status, null, 2)); + else if (!status.paired) console.log("Remote Workspace executor is not paired."); + else { + console.log(`Remote Workspace executor: ${status.deviceName}`); + console.log(`Hub: ${status.hubUrl}`); + console.log(`Capabilities: ${(status.capabilities as string[]).join(", ")}`); + console.log(`Workspace roots: ${(status.roots as unknown[]).length}`); + } + return 0; + } + if (command === "pair") { + const wantsJson = takeJsonFlag(args); + const readCode = takeFlag(args, "--pairing-code-stdin"); + const name = takeOption(args, "--name"); + const nativeHelperPath = takeOption(args, "--executor-helper"); + const roots = takeRepeatedPathFlag(args, "--root"); + const toolchainRoots = takeRepeatedPathFlag(args, "--toolchain-root"); + const hubUrl = args.shift(); + if (!hubUrl || !readCode || roots.length === 0) throw new CliUsageError( + "pair requires , --pairing-code-stdin, and at least one --root", + REMOTE_WORKSPACE_USAGE, + ); + rejectArgs(args, REMOTE_WORKSPACE_USAGE, { redactValues: true }); + const pairingCode = await readSecretLine(deps, "Remote Workspace pairing code"); + const state = await (deps.pair ?? pairRemoteWorkspaceDevice)({ + hubUrl, + pairingCode, + ...(name ? { name } : {}), + roots: roots.map(path => ({ path })), + toolchainRoots, + ...(nativeHelperPath ? { nativeHelperPath } : {}), + store, + }); + const status = publicStatus(state); + if (wantsJson) console.log(JSON.stringify(status, null, 2)); + else { + console.log(`Paired ${state.deviceName} with ${state.hubUrl}.`); + console.log("Run `ocx remote-workspace agent` to keep this executor online."); + } + return 0; + } + if (command === "agent") { + rejectArgs(args, REMOTE_WORKSPACE_USAGE); + const state = store.load(); + if (!state) throw new CliUsageError("Remote Workspace executor is not paired. Run the pair command first.", REMOTE_WORKSPACE_USAGE); + const controller = deps.signal ? null : new AbortController(); + const signal = deps.signal ?? controller!.signal; + const stop = () => controller?.abort(); + if (controller) { + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + } + try { + await (deps.runAgent ?? runRemoteWorkspaceAgent)({ + state, + signal, + onStatus: deps.onStatus ?? (status => { + if (status.state === "online") console.log(`Remote Workspace executor online: ${state.deviceName}`); + if (status.state === "reconnecting" && status.message) console.error(`Remote Workspace reconnecting: ${status.message}`); + }), + }); + } finally { + if (controller) { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + } + } + return 0; + } + throw new CliUsageError("choose pair, agent, or status", REMOTE_WORKSPACE_USAGE); +} diff --git a/src/lib/windows-atomic-replace.ts b/src/lib/windows-atomic-replace.ts index 0f3ba94552..a876c98bca 100644 --- a/src/lib/windows-atomic-replace.ts +++ b/src/lib/windows-atomic-replace.ts @@ -33,6 +33,7 @@ export type ReplacePublisher = | "claude-agents" | "lab-automation" | "lab-ledger" + | "remote-workspace" | "storage-cleanup" | "tray"; diff --git a/src/remote-control/index.ts b/src/remote-control/index.ts index 256832a324..352ff68043 100644 --- a/src/remote-control/index.ts +++ b/src/remote-control/index.ts @@ -1,3 +1,25 @@ +export { + parseRemoteControlClientHello, + parseRemoteControlHostHello, + serializeRemoteControlHello, + generateRemoteControlIdentityKeyPair, + RemoteControlCipher, + RemoteControlClientHandshake, + acceptRemoteControlClientHello, +} from "./crypto"; +export type { + RemoteControlIdentityKeyPair, + CreateRemoteControlClientHandshakeOptions, + AcceptRemoteControlClientHelloOptions, +} from "./crypto"; +export { + RemoteControlHost, +} from "./host"; +export type { + RemoteControlTerminal, + RemoteControlTerminalFactory, + RemoteControlHostOptions, +} from "./host"; export { REMOTE_CONTROL_PROTOCOL_VERSION, REMOTE_CONTROL_RELAY_HEADER_BYTES, @@ -24,28 +46,6 @@ export type { RemoteControlRelayFrame, RemoteControlApplicationFrame, } from "./protocol"; -export { - parseRemoteControlClientHello, - parseRemoteControlHostHello, - serializeRemoteControlHello, - generateRemoteControlIdentityKeyPair, - RemoteControlCipher, - RemoteControlClientHandshake, - acceptRemoteControlClientHello, -} from "./crypto"; -export type { - RemoteControlIdentityKeyPair, - CreateRemoteControlClientHandshakeOptions, - AcceptRemoteControlClientHelloOptions, -} from "./crypto"; -export { - RemoteControlHost, -} from "./host"; -export type { - RemoteControlTerminal, - RemoteControlTerminalFactory, - RemoteControlHostOptions, -} from "./host"; export { OpaqueRemoteControlRelay, } from "./relay"; @@ -53,6 +53,176 @@ export type { RemoteControlRelayPeer, OpaqueRemoteControlRelayOptions, } from "./relay"; +export { + RemoteWorkspaceHubAgentConnection, + RemoteWorkspaceExecutorAgentConnection, +} from "./workspace-agent-connection"; +export type { + RemoteWorkspaceControlSocket, +} from "./workspace-agent-connection"; +export { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES, + isRemoteWorkspaceAgentProfile, + serializeRemoteWorkspaceHubMessage, + serializeRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + parseRemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +export type { + RemoteWorkspaceAgentProfile, + RemoteWorkspaceHubMessage, + RemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +export { + ClaudeRemoteWorkspaceRuntimeFactory, +} from "./workspace-claude-runtime"; +export type { + ClaudeRemoteWorkspaceRuntimeOptions, +} from "./workspace-claude-runtime"; +export { + CodexRemoteWorkspaceRuntimeFactory, +} from "./workspace-codex-runtime"; +export type { + CodexRemoteWorkspaceRuntimeOptions, +} from "./workspace-codex-runtime"; +export { + resolveCodexLinuxSandboxBinary, + codexRemotePermissionProfileCompatibility, +} from "./workspace-codex-sandbox"; +export { + pinRemoteWorkspaceNativeHelper, + discoverRemoteWorkspaceNativeHelper, + parseRemoteWorkspaceNativeHelperDescriptor, + linuxRemoteWorkspaceCommandArgv, + createLinuxRemoteWorkspaceCommandRunner, + createNativeRemoteWorkspaceCommandRunner, + nativeRemoteWorkspaceCommandRunnerAvailable, + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandRunnerAvailable, +} from "./workspace-command-runner"; +export type { + LinuxRemoteWorkspaceCommandRunnerOptions, + RemoteWorkspaceNativeHelperDescriptor, + NativeRemoteWorkspaceCommandRunnerOptions, +} from "./workspace-command-runner"; +export { + remoteWorkspaceThreadStartParams, + RemoteWorkspaceCoordinator, +} from "./workspace-coordinator"; +export type { + RemoteWorkspaceSessionBinding, + RemoteWorkspaceTransport, + AppServerDynamicToolRequest, + AppServerDynamicToolResponse, +} from "./workspace-coordinator"; +export { + REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + normalizeRemoteWorkspaceHubUrl, + parseRemoteWorkspaceDeviceState, + RemoteWorkspaceDeviceFileStore, + pairRemoteWorkspaceDevice, + remoteWorkspaceCapabilitiesForCommandRunner, + connectRemoteWorkspaceAgent, + runRemoteWorkspaceAgent, +} from "./workspace-device"; +export type { + RemoteWorkspaceDeviceRoot, + RemoteWorkspaceDeviceState, + RemoteWorkspaceDeviceStateStore, + PairRemoteWorkspaceDeviceOptions, + RemoteWorkspaceWebSocketLike, + RemoteWorkspaceWebSocketFactory, + RemoteWorkspaceAgentHandle, + RemoteWorkspaceAgentRunStatus, +} from "./workspace-device"; +export { + findExecutableOnPath, +} from "./workspace-executable"; +export { + validateRemoteWorkspaceRelativePath, + RemoteWorkspaceExecutor, +} from "./workspace-executor"; +export type { + RemoteWorkspaceRoot, + RemoteWorkspaceExecutionRequest, + RemoteWorkspaceExecutorOptions, + RemoteWorkspaceCommandRequest, + RemoteWorkspaceCommandResult, + RemoteWorkspaceCommandRunner, +} from "./workspace-executor"; +export { + REMOTE_WORKSPACE_HUB_STATE_VERSION, + REMOTE_WORKSPACE_MAX_DEVICES, + REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE, + RemoteWorkspacePairingRateLimitError, + parseRemoteWorkspaceHubState, + RemoteWorkspaceHubFileStore, + RemoteWorkspaceHub, +} from "./workspace-hub"; +export type { + RemoteWorkspaceRootAdvertisement, + RemoteWorkspaceStoredDevice, + RemoteWorkspaceHubState, + RemoteWorkspaceHubStateStore, + RemoteWorkspacePublicDevice, + RemoteWorkspacePairingGrant, + RemoteWorkspacePairDeviceInput, + RemoteWorkspacePairDeviceResult, +} from "./workspace-hub"; +export { + PiRemoteWorkspaceRuntimeFactory, +} from "./workspace-pi-runtime"; +export type { + PiRemoteWorkspaceRuntimeOptions, +} from "./workspace-pi-runtime"; +export { + remoteWorkspaceProcessInvocation, + waitForRemoteWorkspaceProcessExit, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + removeRemoteWorkspaceIsolation, +} from "./workspace-process"; +export type { + RemoteWorkspaceProcessInvocationOptions, + RemoteWorkspaceOwnedProcess, + StopRemoteWorkspaceProcessOptions, +} from "./workspace-process"; +export { + REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, + frameRemoteWorkspaceRpcMessage, + RemoteWorkspaceRpcReassembler, +} from "./workspace-rpc-framing"; +export { + EncryptedRemoteWorkspaceTransport, + EncryptedRemoteWorkspaceExecutorEndpoint, +} from "./workspace-rpc"; +export type { + EncryptedRemoteWorkspaceTransportOptions, + EncryptedRemoteWorkspaceExecutorEndpointOptions, +} from "./workspace-rpc"; +export { + REMOTE_WORKSPACE_SESSION_STATE_VERSION, + parseRemoteWorkspaceSessionState, + RemoteWorkspaceSessionFileStore, + RemoteWorkspaceSessionService, +} from "./workspace-sessions"; +export type { + RemoteWorkspaceSessionStatus, + RemoteWorkspaceAccessMode, + RemoteWorkspaceSessionEvent, + RemoteWorkspaceSessionSummary, + RemoteWorkspaceRuntimeHandle, + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceSessionState, + RemoteWorkspaceSessionStateStore, +} from "./workspace-sessions"; +export { + startRemoteWorkspaceToolBridge, +} from "./workspace-tool-bridge"; +export type { + RemoteWorkspaceToolBridge, +} from "./workspace-tool-bridge"; export { REMOTE_WORKSPACE_TOOL_NAMESPACE, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, @@ -76,25 +246,6 @@ export type { RemoteWorkspaceToolCallParams, RemoteWorkspaceToolResult, } from "./workspace-tools"; -export { - REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, - REMOTE_WORKSPACE_AGENT_MAX_CONTROL_BYTES, - isRemoteWorkspaceAgentProfile, - serializeRemoteWorkspaceHubMessage, - serializeRemoteWorkspaceAgentMessage, - parseRemoteWorkspaceHubMessage, - parseRemoteWorkspaceAgentMessage, -} from "./workspace-agent-protocol"; -export type { - RemoteWorkspaceAgentProfile, - RemoteWorkspaceHubMessage, - RemoteWorkspaceAgentMessage, -} from "./workspace-agent-protocol"; -export { - REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, - frameRemoteWorkspaceRpcMessage, - RemoteWorkspaceRpcReassembler, -} from "./workspace-rpc-framing"; export { truncateRemoteWorkspaceUtf8, } from "./workspace-utf8"; diff --git a/src/remote-control/workspace-agent-connection.ts b/src/remote-control/workspace-agent-connection.ts new file mode 100644 index 0000000000..095fcad6ca --- /dev/null +++ b/src/remote-control/workspace-agent-connection.ts @@ -0,0 +1,366 @@ +import type { RemoteControlIdentityKeyPair } from "./crypto"; +import { + RemoteControlClientHandshake, + acceptRemoteControlClientHello, +} from "./crypto"; +import type { RemoteWorkspaceExecutor } from "./workspace-executor"; +import { REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE } from "./protocol"; +import { + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, +} from "./workspace-rpc"; +import { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + parseRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + serializeRemoteWorkspaceAgentMessage, + serializeRemoteWorkspaceHubMessage, + type RemoteWorkspaceAgentProfile, +} from "./workspace-agent-protocol"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; + +const SESSION_OPEN_TIMEOUT_MS = 10_000; + +export interface RemoteWorkspaceControlSocket { + send(value: string): void | Promise; + close(code: number, reason: string): void; +} + +interface PendingHubSession { + handshake: RemoteControlClientHandshake; + resolve(transport: EncryptedRemoteWorkspaceTransport): void; + reject(error: Error): void; + timer: ReturnType; +} + +function safeReason(value: string): string { + const cleaned = value.replace(/[\x00-\x1f\x7f]/g, " ").trim(); + const selected = cleaned || "remote workspace session closed"; + return truncateRemoteWorkspaceUtf8(selected, 120); +} + +/** Hub-side representation of one authenticated, online OCX-only executor. */ +export class RemoteWorkspaceHubAgentConnection { + private readonly pending = new Map(); + private readonly active = new Map(); + private readonly cancelledSessionIds = new Set(); + private closed = false; + private presenceAccepted = false; + private presencePending = false; + private currentCapabilities: RemoteWorkspaceCapability[]; + + constructor(private readonly options: { + deviceId: string; + devicePublicKey: string; + hubIdentity: RemoteControlIdentityKeyPair; + socket: RemoteWorkspaceControlSocket; + capabilities?: readonly RemoteWorkspaceCapability[]; + onCapabilities?: (capabilities: readonly RemoteWorkspaceCapability[]) => void; + sessionOpenTimeoutMs?: number; + }) { + this.currentCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + } + + isOnline(): boolean { + return !this.closed && this.presenceAccepted; + } + + capabilities(): RemoteWorkspaceCapability[] { + return [...this.currentCapabilities]; + } + + async openSession(options: { + sessionId: string; + rootId: string; + profile: RemoteWorkspaceAgentProfile; + capabilities: readonly RemoteWorkspaceCapability[]; + }): Promise { + if (!this.isOnline()) throw new Error("remote workspace executor is offline"); + if (this.pending.has(options.sessionId) || this.active.has(options.sessionId)) { + throw new Error("remote workspace session already exists"); + } + if (this.pending.size + this.active.size >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + if (!Array.isArray(options.capabilities)) throw new Error("remote workspace session requires explicit capabilities"); + const requestedCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + if (requestedCapabilities.some(capability => !this.currentCapabilities.includes(capability))) { + throw new Error("remote workspace session requests an unavailable capability"); + } + const handshake = RemoteControlClientHandshake.create({ + sessionId: options.sessionId, + deviceId: this.options.deviceId, + commandProfile: options.profile, + capabilities: requestedCapabilities, + accountPrivateKey: this.options.hubIdentity.privateKey, + }); + const timeoutMs = this.options.sessionOpenTimeoutMs ?? SESSION_OPEN_TIMEOUT_MS; + const opened = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(options.sessionId); + this.rememberCancelledSession(options.sessionId); + reject(new Error("remote workspace session handshake timed out")); + }, timeoutMs); + this.pending.set(options.sessionId, { handshake, resolve, reject, timer }); + }); + try { + await this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_open", + rootId: options.rootId, + clientHello: handshake.hello, + })); + } catch (error) { + const pending = this.pending.get(options.sessionId); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(options.sessionId); + pending.reject(error instanceof Error ? error : new Error("remote workspace session send failed")); + } + } + return await opened; + } + + receive(raw: string | Uint8Array): void { + if (this.closed) throw new Error("remote workspace executor is offline"); + const message = parseRemoteWorkspaceAgentMessage(raw); + if (message.type === "presence") { + if (this.presenceAccepted || this.presencePending) { + throw new Error("remote workspace executor sent duplicate presence"); + } + const approved = parseRemoteWorkspaceCapabilities(this.options.capabilities); + const capabilities = parseRemoteWorkspaceCapabilities(message.capabilities.filter(capability => approved.includes(capability))); + this.presencePending = true; + const accept = () => { + if (this.closed) return; + this.options.onCapabilities?.(capabilities); + this.currentCapabilities = capabilities; + this.presenceAccepted = true; + this.presencePending = false; + }; + let sent: void | Promise; + try { + sent = this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence_ack", + capabilities, + })); + } catch (error) { + this.presencePending = false; + throw error; + } + if (sent && typeof sent.then === "function") { + void sent.then(accept).catch(() => this.close("remote workspace presence acknowledgement failed")); + } else { + accept(); + } + return; + } + if (!this.presenceAccepted) { + throw new Error("remote workspace executor presence is required before session traffic"); + } + if (message.type === "heartbeat") return; + if (message.type === "session_accept") { + const pending = this.pending.get(message.sessionId); + if (!pending) { + if (!this.cancelledSessionIds.delete(message.sessionId)) { + throw new Error("remote workspace accepted an unknown session"); + } + void Promise.resolve(this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_close", + sessionId: message.sessionId, + reason: "remote workspace session was already cancelled", + }))).catch(() => this.close("remote workspace cancelled-session cleanup failed")); + return; + } + const cipher = pending.handshake.complete(message.hostHello, this.options.devicePublicKey); + const transport = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: this.options.deviceId, + cipher, + sendCiphertext: value => this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "ciphertext", + sessionId: message.sessionId, + payload: value, + })), + }); + clearTimeout(pending.timer); + this.pending.delete(message.sessionId); + this.active.set(message.sessionId, transport); + pending.resolve(transport); + return; + } + if (message.type === "session_reject") { + const pending = this.pending.get(message.sessionId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.sessionId); + pending.reject(new Error(safeReason(message.reason))); + return; + } + const transport = this.active.get(message.sessionId); + if (!transport) throw new Error("remote workspace ciphertext targeted an unknown session"); + transport.receiveCiphertext(message.payload); + } + + async closeSession(sessionId: string, reason = "remote workspace session closed"): Promise { + const pending = this.pending.get(sessionId); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(sessionId); + this.rememberCancelledSession(sessionId); + pending.reject(new Error(safeReason(reason))); + } + const transport = this.active.get(sessionId); + if (transport) { + this.active.delete(sessionId); + transport.close(safeReason(reason)); + } + if (this.closed) return; + await this.options.socket.send(serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_close", + sessionId, + reason: safeReason(reason), + })); + } + + close(reason = "remote workspace executor disconnected"): void { + if (this.closed) return; + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error(safeReason(reason))); + } + this.pending.clear(); + for (const transport of this.active.values()) transport.close(safeReason(reason)); + this.active.clear(); + this.cancelledSessionIds.clear(); + try { this.options.socket.close(1008, safeReason(reason)); } catch { /* socket is already gone */ } + } + + private rememberCancelledSession(sessionId: string): void { + this.cancelledSessionIds.add(sessionId); + while (this.cancelledSessionIds.size > 16) { + const oldest = this.cancelledSessionIds.values().next(); + if (oldest.done) break; + this.cancelledSessionIds.delete(oldest.value); + } + } +} + +/** Executor-side connection. It owns no Codex, Claude Code, Pi, provider key, or model session. */ +export class RemoteWorkspaceExecutorAgentConnection { + private readonly sessions = new Map(); + private closed = false; + + constructor(private readonly options: { + deviceId: string; + deviceIdentity: RemoteControlIdentityKeyPair; + hubPublicKey: string; + executor: RemoteWorkspaceExecutor; + capabilities?: readonly RemoteWorkspaceCapability[]; + onPresenceAccepted?: () => void; + socket: RemoteWorkspaceControlSocket; + }) { + this.currentCapabilities = parseRemoteWorkspaceCapabilities(options.capabilities); + } + + private currentCapabilities: RemoteWorkspaceCapability[]; + + async receive(raw: string | Uint8Array): Promise { + if (this.closed) throw new Error("remote workspace agent connection is closed"); + const message = parseRemoteWorkspaceHubMessage(raw); + if (message.type === "presence_ack") { + if (message.capabilities.some(capability => !this.currentCapabilities.includes(capability))) { + throw new Error("remote workspace Hub acknowledged different executor capabilities"); + } + this.currentCapabilities = [...message.capabilities]; + this.options.onPresenceAccepted?.(); + return; + } + if (message.type === "session_open") { + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint | null = null; + try { + if (this.sessions.has(message.clientHello.sessionId)) { + throw new Error("remote workspace executor session already exists"); + } + if (this.sessions.size >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + if (message.clientHello.deviceId !== this.options.deviceId) { + throw new Error("remote workspace session targeted another executor"); + } + if (!this.options.executor.hasApprovedRoot(message.rootId)) { + throw new Error("remote workspace root is not approved"); + } + const accepted = acceptRemoteControlClientHello(message.clientHello, { + expectedSessionId: message.clientHello.sessionId, + expectedDeviceId: this.options.deviceId, + accountPublicKey: this.options.hubPublicKey, + devicePrivateKey: this.options.deviceIdentity.privateKey, + allowedCapabilities: this.currentCapabilities, + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: this.options.deviceId, + sessionId: message.clientHello.sessionId, + rootId: message.rootId, + capabilities: parseRemoteWorkspaceCapabilities(accepted.hello.capabilities), + cipher: accepted.cipher, + executor: this.options.executor, + sendCiphertext: value => this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "ciphertext", + sessionId: message.clientHello.sessionId, + payload: value, + })), + }); + this.sessions.set(message.clientHello.sessionId, endpoint); + await this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_accept", + sessionId: message.clientHello.sessionId, + hostHello: accepted.hello, + })); + } catch (error) { + if (endpoint) { + this.sessions.delete(message.clientHello.sessionId); + endpoint.close(); + } + await this.options.socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_reject", + sessionId: message.clientHello.sessionId, + reason: safeReason(error instanceof Error ? error.message : "remote workspace session refused"), + })); + } + return; + } + if (message.type === "session_close") { + this.sessions.get(message.sessionId)?.close(); + this.sessions.delete(message.sessionId); + return; + } + const endpoint = this.sessions.get(message.sessionId); + if (!endpoint) throw new Error("remote workspace ciphertext targeted an unknown executor session"); + // Decryption and counter validation happen synchronously before this returns. The execution + // promise is intentionally detached so an unencrypted session_close control frame can abort a + // long-running command instead of waiting behind that command on the socket's ordered queue. + void endpoint.receiveCiphertext(message.payload).catch(() => { + this.close(); + this.options.socket.close(1008, "remote workspace protocol error"); + }); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const endpoint of this.sessions.values()) endpoint.close(); + this.sessions.clear(); + } +} diff --git a/src/remote-control/workspace-claude-runtime.ts b/src/remote-control/workspace-claude-runtime.ts new file mode 100644 index 0000000000..243ee5e1da --- /dev/null +++ b/src/remote-control/workspace-claude-runtime.ts @@ -0,0 +1,243 @@ +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { remoteWorkspaceDeveloperInstructions } from "./workspace-tools"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, +} from "./workspace-process"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, +} from "./workspace-sessions"; + +const MAX_OUTPUT_LINE_BYTES = 2 * 1024 * 1024; +const MAX_STDERR_BYTES = 64 * 1024; + +function safeError(value: unknown, fallback: string): string { + return (value instanceof Error ? value.message : typeof value === "string" ? value : fallback) + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function assistantText(value: unknown): string | null { + const message = record(value); + if (!message || !Array.isArray(message.content)) return null; + const text = message.content.flatMap(raw => { + const part = record(raw); + return part?.type === "text" && typeof part.text === "string" ? [part.text] : []; + }).join(""); + return text || null; +} + +async function drain(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let retained = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + if (retained >= MAX_STDERR_BYTES) continue; + const chunk = next.value.subarray(0, MAX_STDERR_BYTES - retained); + chunks.push(chunk); + retained += chunk.byteLength; + } + } finally { + reader.releaseLock(); + } + const merged = new Uint8Array(retained); + let offset = 0; + for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.byteLength; } + return new TextDecoder().decode(merged); +} + +export interface ClaudeRemoteWorkspaceRuntimeOptions { + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class ClaudeRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "claude" as const; + + constructor(private readonly options: ClaudeRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + const command = this.options.command && this.options.command.length > 0 + ? this.options.command[0] + : findExecutableOnPath("claude"); + return command + ? { available: true, ...(this.options.version ? { version: this.options.version } : {}) } + : { available: false, reason: "Claude Code is not installed on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const configuredCommand = this.options.command && this.options.command.length > 0 + ? [...this.options.command] + : null; + const executable = configuredCommand?.[0] ?? findExecutableOnPath("claude"); + if (!executable) throw new Error("Claude Code is not installed on this Hub"); + const commandPrefix = configuredCommand ?? [executable]; + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-claude-")); + try { + chmodSync(isolation, 0o700); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const threadId = options.resumeThreadId ?? randomUUID(); + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const mcpPath = join(isolation, "mcp.json"); + try { + writeFileSync(mcpPath, `${JSON.stringify({ + mcpServers: { + ocx_remote_workspace: { + type: "http", + url: `${bridge.url}/mcp`, + headers: { Authorization: `Bearer ${bridge.token}` }, + }, + }, + })}\n`, { mode: 0o600 }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + let firstTurn = options.resumeThreadId === undefined; + let active: Bun.Subprocess<"pipe", "pipe", "pipe"> | null = null; + let stopped = false; + let stopOperation: Promise | null = null; + + const runPrompt = async (text: string): Promise => { + if (stopped) throw new Error("Claude Remote Workspace session is stopped"); + if (active) throw new Error("Claude Remote Workspace turn is already active"); + const args = [ + ...commandPrefix, + "-p", + "--input-format", "text", + "--output-format", "stream-json", + "--verbose", + "--strict-mcp-config", + "--mcp-config", mcpPath, + "--setting-sources", "", + "--tools", "", + "--allowedTools", "mcp__ocx_remote_workspace__*", + "--permission-mode", "dontAsk", + "--disable-slash-commands", + "--no-chrome", + "--system-prompt", remoteWorkspaceDeveloperInstructions(options.deviceName, options.tools), + firstTurn ? "--session-id" : "--resume", + threadId, + ]; + const childEnv = { ...process.env, ...this.options.env }; + const invocation = remoteWorkspaceProcessInvocation(args, { env: childEnv }); + const child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + active = child; + try { + child.stdin.write(text); + child.stdin.end(); + } catch (error) { + await stopRemoteWorkspaceProcess(child); + if (active === child) active = null; + throw error; + } + const stderrPromise = drain(child.stderr); + const reader = child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + let emittedAssistant = false; + let resultError: string | null = null; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_OUTPUT_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Claude Code output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_OUTPUT_LINE_BYTES) throw new Error("Claude Code output line is too large"); + if (line) { + const event = record(JSON.parse(line)); + if (event?.type === "assistant") { + const answer = assistantText(event.message); + if (answer) { options.emit("assistant", answer); emittedAssistant = true; } + } + if (event?.type === "result") { + if (event.is_error === true) resultError = safeError(event.result, "Claude Code turn failed"); + else if (!emittedAssistant && typeof event.result === "string" && event.result) { + options.emit("assistant", event.result); + emittedAssistant = true; + } + } + } + newline = buffer.indexOf("\n"); + } + } + const exitCode = await child.exited; + const stderr = await stderrPromise; + if (resultError) throw new Error(resultError); + if (exitCode !== 0) throw new Error(safeError(stderr, `Claude Code exited with code ${exitCode}`)); + firstTurn = false; + } catch (error) { + await stopRemoteWorkspaceProcess(child); + await stderrPromise.catch(() => ""); + throw error; + } finally { + reader.releaseLock(); + if (active === child) active = null; + } + }; + + return { + threadId, + canResume: () => !firstTurn, + prompt: runPrompt, + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + const child = active; + stopOperation = runRemoteWorkspaceCleanupSteps([ + async () => { if (child) await stopRemoteWorkspaceProcess(child); }, + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } +} diff --git a/src/remote-control/workspace-codex-runtime.ts b/src/remote-control/workspace-codex-runtime.ts new file mode 100644 index 0000000000..b064e3c9b8 --- /dev/null +++ b/src/remote-control/workspace-codex-runtime.ts @@ -0,0 +1,531 @@ +import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { resolveCodexRuntime } from "../codex/runtime"; +import { remoteWorkspaceThreadStartParams } from "./workspace-coordinator"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; +import { REMOTE_WORKSPACE_TOOL_NAMESPACE } from "./workspace-tools"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + waitForRemoteWorkspaceProcessExit, +} from "./workspace-process"; +import { + codexRemotePermissionProfileCompatibility, + resolveCodexLinuxSandboxBinary, +} from "./workspace-codex-sandbox"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, + RemoteWorkspaceSessionEvent, +} from "./workspace-sessions"; + +const MAX_JSON_LINE_BYTES = 2 * 1024 * 1024; +const MAX_STDERR_BYTES = 64 * 1024; +const MAX_BUFFERED_ASSISTANT_ITEMS = 32; +const MAX_BUFFERED_ASSISTANT_BYTES = 64 * 1024; +const MAX_EARLY_TURN_COMPLETIONS = 16; +const START_TIMEOUT_MS = 15_000; +const REQUEST_TIMEOUT_MS = 60_000; + +interface JsonRpcMessage { + id?: string | number; + method?: string; + params?: Record; + result?: Record; + error?: { message?: unknown }; +} + +interface PendingRpc { + resolve(message: JsonRpcMessage): void; + reject(error: Error): void; + timer: ReturnType; +} + +function parseJsonRpcMessage(value: unknown): JsonRpcMessage { + const raw = object(value); + if (!raw) throw new Error("invalid Codex App Server message"); + if (raw.id !== undefined && typeof raw.id !== "string" && typeof raw.id !== "number") { + throw new Error("invalid Codex App Server message ID"); + } + if (raw.method !== undefined && typeof raw.method !== "string") { + throw new Error("invalid Codex App Server method"); + } + const params = raw.params === undefined ? undefined : object(raw.params); + const result = raw.result === undefined ? undefined : object(raw.result); + const error = raw.error === undefined ? undefined : object(raw.error); + if ((raw.params !== undefined && !params) + || (raw.result !== undefined && !result) + || (raw.error !== undefined && !error)) { + throw new Error("invalid Codex App Server message fields"); + } + return { + ...(raw.id !== undefined ? { id: raw.id } : {}), + ...(typeof raw.method === "string" ? { method: raw.method } : {}), + ...(params ? { params } : {}), + ...(result ? { result } : {}), + ...(error ? { error: { message: error.message } } : {}), + }; +} + +function errorMessage(value: unknown, fallback: string): string { + const raw = value instanceof Error ? value.message : typeof value === "string" ? value : fallback; + return raw.replace(/[^\x20-\x7e\n\t]/g, " ").slice(0, 4_096) || fallback; +} + +function object(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function nestedString(value: unknown, keys: readonly string[]): string | null { + let current: unknown = value; + for (const key of keys) current = object(current)?.[key]; + return typeof current === "string" && current.length > 0 ? current : null; +} + +function itemText(value: unknown): string | null { + const item = object(value); + if (!item) return null; + if (typeof item.text === "string" && item.text.length > 0) return item.text; + if (!Array.isArray(item.content)) return null; + const parts: string[] = []; + for (const raw of item.content) { + const part = object(raw); + const text = part && typeof part.text === "string" ? part.text : null; + if (text) parts.push(text); + } + return parts.length > 0 ? parts.join("") : null; +} + +function appendBoundedUtf8(current: string, delta: string, maximum: number): string { + const marker = "\n[truncated]"; + if (current.endsWith(marker)) return current; + const combined = `${current}${delta}`; + if (Buffer.byteLength(combined, "utf8") <= maximum) return combined; + const bodyLimit = maximum - Buffer.byteLength(marker, "utf8"); + return `${truncateRemoteWorkspaceUtf8(combined, bodyLimit)}${marker}`; +} + +function setBounded(map: Map, key: K, value: V, maximum: number): void { + if (!map.has(key) && map.size >= maximum) { + const oldest = map.keys().next(); + if (!oldest.done) map.delete(oldest.value); + } + map.set(key, value); +} + +class JsonLineRpcProcess { + private readonly pending = new Map(); + private nextId = 0; + private closed = false; + private closeError: Error | null = null; + + onRequest: ((message: JsonRpcMessage) => Promise) | null = null; + onNotification: ((message: JsonRpcMessage) => void) | null = null; + onClose: ((error: Error) => void) | null = null; + + constructor(private readonly child: Bun.Subprocess<"pipe", "pipe", "pipe">) { + void this.readStdout(); + void this.drainStderr(); + void child.exited.then(code => this.fail(new Error(`Codex App Server exited with code ${code}`))); + } + + request(method: string, params: Record, timeoutMs = REQUEST_TIMEOUT_MS): Promise { + if (this.closed) return Promise.reject(this.closeError ?? new Error("Codex App Server is closed")); + const id = ++this.nextId; + const result = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Codex App Server ${method} timed out`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + }); + try { + this.send({ jsonrpc: "2.0", id, method, params }); + } catch (error) { + const pending = this.pending.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(id); + pending.reject(error instanceof Error ? error : new Error("Codex App Server write failed")); + } + } + return result; + } + + notify(method: string, params: Record): void { + this.send({ jsonrpc: "2.0", method, params }); + } + + async close(): Promise { + try { + if (!this.closed) { + try { this.child.stdin.end(); } catch { /* child already closed */ } + } + const graceful = await waitForRemoteWorkspaceProcessExit(this.child, 1_500); + if (!graceful) { + await stopRemoteWorkspaceProcess(this.child); + } + } finally { + // Pending callers must settle even if the OS refuses to reap the child. + this.fail(new Error("Codex App Server session closed")); + } + } + + private send(message: Record): void { + if (this.closed) throw this.closeError ?? new Error("Codex App Server is closed"); + const line = `${JSON.stringify(message)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Codex App Server message is too large"); + this.child.stdin.write(line); + this.child.stdin.flush(); + } + + private async readStdout(): Promise { + const reader = this.child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_JSON_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Codex App Server output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Codex App Server output line is too large"); + if (line) this.receive(parseJsonRpcMessage(JSON.parse(line))); + newline = buffer.indexOf("\n"); + } + } + } catch (error) { + void stopRemoteWorkspaceProcess(this.child).catch(() => {}); + this.fail(new Error(errorMessage(error, "Codex App Server output failed"))); + } finally { + reader.releaseLock(); + } + } + + private async drainStderr(): Promise { + const reader = this.child.stderr.getReader(); + let retained = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + retained = Math.min(MAX_STDERR_BYTES, retained + next.value.byteLength); + } + } catch { + // stdout and the exit code own the user-visible process failure. + } finally { + reader.releaseLock(); + void retained; + } + } + + private receive(message: JsonRpcMessage): void { + if (!message || typeof message !== "object") throw new Error("invalid Codex App Server message"); + if (message.id !== undefined && typeof message.method !== "string") { + const pending = this.pending.get(message.id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(errorMessage(message.error.message, "Codex App Server request failed"))); + else pending.resolve(message); + return; + } + if (typeof message.method !== "string") return; + if (message.id === undefined) { + this.onNotification?.(message); + return; + } + const id = message.id; + const request = this.onRequest; + if (!request) { + this.send({ jsonrpc: "2.0", id, error: { code: -32_601, message: "client request handler is unavailable" } }); + return; + } + void request(message).then( + response => this.send({ jsonrpc: "2.0", ...response }), + error => this.send({ + jsonrpc: "2.0", + id, + error: { code: -32_000, message: errorMessage(error, "Remote Workspace tool failed") }, + }), + ); + } + + private fail(error: Error): void { + if (this.closed) return; + this.closed = true; + this.closeError = error; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + this.onClose?.(error); + } +} + +interface ActiveTurn { + id: string; + resolve(): void; + reject(error: Error): void; +} + +export interface CodexRemoteWorkspaceRuntimeOptions { + /** Test seam. Production resolves the configured, trusted Codex runtime. */ + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class CodexRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "codex" as const; + + constructor(private readonly options: CodexRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + if (this.options.command && this.options.command.length > 0) { + return { available: true, version: this.options.version ?? "test" }; + } + const resolved = resolveCodexRuntime(); + const compatibility = codexRemotePermissionProfileCompatibility(); + if (!compatibility.compatible) return { available: false, reason: compatibility.reason }; + return resolved.runtime.version + ? { available: true, version: resolved.runtime.version } + : { available: false, reason: "Codex CLI is not installed or runnable on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const command = this.options.command + ? [...this.options.command] + : [resolveCodexRuntime().runtime.command]; + if (command.length < 1) throw new Error("Codex CLI is unavailable on this Hub"); + const executablePath = isAbsolute(command[0]!) ? command[0]! : findExecutableOnPath(command[0]!); + if (!executablePath) throw new Error("Codex CLI executable could not be resolved on this Hub"); + command[0] = executablePath; + const runtimeDirectory = dirname(realpathSync(executablePath)); + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-codex-")); + let processPath = process.env.PATH ?? "/usr/bin:/bin"; + const runtimeReadPaths = [runtimeDirectory]; + try { + chmodSync(isolation, 0o700); + if (process.platform === "linux") { + const native = resolveCodexLinuxSandboxBinary(executablePath); + if (!native) { + throw new Error("Codex Remote Workspace could not locate the native Linux permission-profile helper"); + } + const helperDir = join(isolation, "sandbox-bin"); + mkdirSync(helperDir, { mode: 0o700 }); + const helper = join(helperDir, "codex-linux-sandbox"); + try { linkSync(native, helper); } + catch { symlinkSync(native, helper); } + processPath = `${helperDir}:${processPath}`; + runtimeReadPaths.push(dirname(native), helperDir); + } + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const thread = { id: "" }; + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId: () => thread.id, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const tokenEnvVar = "OCX_REMOTE_WORKSPACE_MCP_TOKEN"; + const mcpPrefix = `mcp_servers.${REMOTE_WORKSPACE_TOOL_NAMESPACE}`; + const childEnv = { ...process.env, ...this.options.env, PATH: processPath, [tokenEnvVar]: bridge.token }; + const invocation = remoteWorkspaceProcessInvocation([ + ...command, + "-c", `${mcpPrefix}.url=${JSON.stringify(`${bridge.url}/mcp`)}`, + "-c", `${mcpPrefix}.bearer_token_env_var=${JSON.stringify(tokenEnvVar)}`, + "-c", `${mcpPrefix}.required=true`, + "-c", `${mcpPrefix}.enabled_tools=${JSON.stringify(options.tools)}`, + "-c", `${mcpPrefix}.default_tools_approval_mode="approve"`, + "app-server", "--listen", "stdio://", + ], { env: childEnv }); + let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + try { + child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const peer = new JsonLineRpcProcess(child); + let activeTurn: ActiveTurn | null = null; + let stopped = false; + const completedBeforeWait = new Map(); + const assistantDeltas = new Map(); + let stopOperation: Promise | null = null; + + const finishTurn = (turnId: string, status: string, detail: string | null): void => { + if (!activeTurn || activeTurn.id !== turnId) { + setBounded(completedBeforeWait, turnId, { status, error: detail }, MAX_EARLY_TURN_COMPLETIONS); + return; + } + const current = activeTurn; + activeTurn = null; + assistantDeltas.clear(); + if (status === "completed") current.resolve(); + else current.reject(new Error(detail ?? `Codex turn ${status}`)); + }; + + peer.onRequest = async message => { + if (message.method !== "item/tool/call" || message.id === undefined) { + throw new Error("unsupported Codex App Server client request"); + } + const tool = nestedString(message.params, ["tool"]) ?? "remote tool"; + options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`); + return options.coordinator.handle({ + method: "item/tool/call", + id: message.id, + params: message.params, + }); + }; + peer.onNotification = message => { + const params = message.params ?? {}; + if (message.method === "item/agentMessage/delta") { + const itemId = nestedString(params, ["itemId"]) ?? nestedString(params, ["item", "id"]); + const delta = nestedString(params, ["delta"]); + if (itemId && delta) { + setBounded( + assistantDeltas, + itemId, + appendBoundedUtf8(assistantDeltas.get(itemId) ?? "", delta, MAX_BUFFERED_ASSISTANT_BYTES), + MAX_BUFFERED_ASSISTANT_ITEMS, + ); + } + return; + } + if (message.method === "item/completed") { + const item = object(params.item); + const itemId = item && typeof item.id === "string" ? item.id : null; + const text = itemText(item) ?? (itemId ? assistantDeltas.get(itemId) ?? null : null); + if (itemId) assistantDeltas.delete(itemId); + if (text) options.emit("assistant", text); + return; + } + if (message.method === "turn/completed") { + const turn = object(params.turn); + const turnId = turn && typeof turn.id === "string" ? turn.id : null; + if (!turnId) return; + const status = typeof turn?.status === "string" ? turn.status : "failed"; + const detail = nestedString(turn, ["error", "message"]); + finishTurn(turnId, status, detail); + } + }; + peer.onClose = error => { + const current = activeTurn; + activeTurn = null; + completedBeforeWait.clear(); + assistantDeltas.clear(); + current?.reject(error); + }; + + try { + await peer.request("initialize", { + clientInfo: { name: "opencodex_remote_workspace", title: "OpenCodex Remote Workspace", version: "1" }, + capabilities: { experimentalApi: true }, + }, START_TIMEOUT_MS); + peer.notify("initialized", {}); + const effective = await peer.request("config/read", { cwd: isolation, includeLayers: false }, START_TIMEOUT_MS); + const effectiveConfig = object(effective.result?.config) ?? {}; + if (typeof effectiveConfig.sandbox_mode === "string" || effectiveConfig.sandbox_workspace_write) { + throw new Error("Codex Remote Workspace requires permission profiles; remove legacy sandbox_mode settings from the selected Codex profile first"); + } + const disabledServerNames = Object.keys(object(effectiveConfig.mcp_servers) ?? {}); + const disabledHookNames = Object.keys(object(effectiveConfig.hooks) ?? {}); + const threadParams = remoteWorkspaceThreadStartParams({ + executorName: options.deviceName, + coordinatorIsolationPath: isolation, + tools: options.tools, + mcp: { + url: `${bridge.url}/mcp`, + bearerTokenEnvVar: tokenEnvVar, + disabledServerNames, + disabledHookNames, + hubRuntimeReadPaths: runtimeReadPaths, + }, + }); + const { ephemeral: _startOnlyEphemeral, ...resumeParams } = threadParams; + const started = options.resumeThreadId + ? await peer.request("thread/resume", { ...resumeParams, threadId: options.resumeThreadId }, START_TIMEOUT_MS) + : await peer.request("thread/start", threadParams, START_TIMEOUT_MS); + const threadId = nestedString(started.result, ["thread", "id"]); + if (!threadId) throw new Error("Codex App Server returned no thread ID"); + if (options.resumeThreadId && threadId !== options.resumeThreadId) { + throw new Error("Codex App Server resumed a different Remote Workspace thread"); + } + thread.id = threadId; + + return { + threadId, + async prompt(text: string): Promise { + if (stopped) throw new Error("Codex Remote Workspace session is stopped"); + if (activeTurn) throw new Error("Codex Remote Workspace turn is already active"); + const startedTurn = await peer.request("turn/start", { + threadId, + input: [{ type: "text", text }], + approvalPolicy: "never", + }); + const turnId = nestedString(startedTurn.result, ["turn", "id"]); + if (!turnId) throw new Error("Codex App Server returned no turn ID"); + const early = completedBeforeWait.get(turnId); + if (early) { + completedBeforeWait.delete(turnId); + if (early.status === "completed") return; + throw new Error(early.error ?? `Codex turn ${early.status}`); + } + await new Promise((resolve, reject) => { activeTurn = { id: turnId, resolve, reject }; }); + }, + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + const turn = activeTurn; + stopOperation = runRemoteWorkspaceCleanupSteps([ + async () => { + if (turn) await peer.request("turn/interrupt", { threadId, turnId: turn.id }, 3_000).catch(() => {}); + }, + () => peer.close(), + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } catch (error) { + await peer.close().catch(() => {}); + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + } +} diff --git a/src/remote-control/workspace-codex-sandbox.ts b/src/remote-control/workspace-codex-sandbox.ts new file mode 100644 index 0000000000..21630db764 --- /dev/null +++ b/src/remote-control/workspace-codex-sandbox.ts @@ -0,0 +1,115 @@ +import { accessSync, constants, existsSync, openSync, closeSync, readFileSync, readSync, realpathSync, statSync } from "node:fs"; +import { arch } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { inspectCodexShimBackingForCommand } from "../codex/shim"; +import { findExecutableOnPath } from "./workspace-executable"; +import { resolveCodexHomeDir } from "../codex/home"; + +function isNativeExecutable(path: string): boolean { + let descriptor: number | null = null; + try { + descriptor = openSync(path, "r"); + const header = Buffer.alloc(4); + if (readSync(descriptor, header, 0, header.length, 0) !== header.length) return false; + return header.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + } catch { + return false; + } finally { + if (descriptor !== null) closeSync(descriptor); + } +} + +function packageRootForEntrypoint(path: string): string | null { + let current = dirname(path); + for (let depth = 0; depth < 10; depth += 1) { + const manifest = join(current, "package.json"); + if (existsSync(manifest)) { + try { + const parsed = JSON.parse(readFileSync(manifest, "utf8")) as { name?: unknown }; + if (parsed.name === "@openai/codex") return current; + } catch { /* keep walking */ } + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return null; +} + +function checkedNative(path: string): string | null { + try { + const canonical = realpathSync(path); + if (!statSync(canonical).isFile() || !isNativeExecutable(canonical)) return null; + accessSync(canonical, constants.X_OK); + return canonical; + } catch { + return null; + } +} + +function generatedShimBacking(path: string): string | null { + try { + const source = readFileSync(path, "utf8"); + if (Buffer.byteLength(source, "utf8") > 128 * 1024 + || !source.includes("# opencodex codex autostart shim")) return null; + const match = /^exec '([^'\r\n]+)' "\$@"\s*$/m.exec(source); + return match?.[1] && isAbsolute(match[1]) ? match[1] : null; + } catch { + return null; + } +} + +/** + * Permission profiles invoke the same native Codex binary under argv[0] + * `codex-linux-sandbox`. npm and OpenCodex shims expose a JS/shell launcher instead, + * so resolve the package-owned native binary without executing or modifying the install. + */ +export function resolveCodexLinuxSandboxBinary(command: string): string | null { + if (process.platform !== "linux") return null; + const selected = isAbsolute(command) ? command : findExecutableOnPath(command); + if (!selected) return null; + const shim = inspectCodexShimBackingForCommand(selected); + const entrypoint = shim.status === "matched" + ? shim.backingPath + : generatedShimBacking(selected) ?? selected; + const direct = checkedNative(entrypoint); + if (direct) return direct; + let canonical: string; + try { canonical = realpathSync(entrypoint); } catch { return null; } + const root = packageRootForEntrypoint(canonical); + if (!root) return null; + const target = arch() === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"; + const packageName = arch() === "arm64" ? "codex-linux-arm64" : "codex-linux-x64"; + const candidates = [ + join(root, "node_modules", "@openai", packageName, "vendor", target, "bin", "codex"), + join(root, "vendor", target, "bin", "codex"), + ]; + for (const candidate of candidates) { + const native = checkedNative(candidate); + if (native) return native; + } + return null; +} + +export function codexRemotePermissionProfileCompatibility( + codexHome = resolveCodexHomeDir(), +): { compatible: boolean; reason?: string } { + const configPath = join(codexHome, "config.toml"); + if (!existsSync(configPath)) return { compatible: true }; + try { + const metadata = statSync(configPath); + if (!metadata.isFile() || metadata.size > 4 * 1024 * 1024) { + return { compatible: false, reason: "Codex config cannot be safely inspected for Remote Workspace permissions." }; + } + const config = Bun.TOML.parse(readFileSync(configPath, "utf8")) as Record; + if (typeof config.sandbox_mode === "string" || config.sandbox_workspace_write !== undefined) { + return { + compatible: false, + reason: "Codex Remote Workspace needs permission profiles, but this Codex config still selects legacy sandbox_mode.", + }; + } + return { compatible: true }; + } catch { + return { compatible: false, reason: "Codex config could not be parsed for Remote Workspace permissions." }; + } +} diff --git a/src/remote-control/workspace-command-runner.ts b/src/remote-control/workspace-command-runner.ts new file mode 100644 index 0000000000..f9a3625caa --- /dev/null +++ b/src/remote-control/workspace-command-runner.ts @@ -0,0 +1,749 @@ +import { createHash } from "node:crypto"; +import { + accessSync, + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + opendirSync, + openSync, + readSync, + realpathSync, + statSync, +} from "node:fs"; +import { arch } from "node:os"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; +import type { + RemoteWorkspaceCommandRequest, + RemoteWorkspaceCommandResult, + RemoteWorkspaceCommandRunner, +} from "./workspace-executor"; + +const DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const NATIVE_HELPER_PROTOCOL_VERSION = 1; +const MAX_NATIVE_HELPER_BYTES = 64 * 1024 * 1024; +const MAX_NATIVE_HELPER_ERROR_CHARS = 512; +const MAX_NATIVE_HELPER_STDERR_BYTES = 16 * 1024; +const MAX_WORKSPACE_PREFLIGHT_ENTRIES = 250_000; +const SANDBOX_BUN_PATH = "/ocx-runtime/bin/bun"; +const READABLE_SYSTEM_PATHS = [ + "/usr", + "/bin", + "/sbin", + "/lib", + "/lib64", +] as const; +const READABLE_ETC_PATHS = [ + "/etc/alternatives", + "/etc/ca-certificates", + "/etc/ssl", + "/etc/hosts", + "/etc/nsswitch.conf", + "/etc/passwd", + "/etc/group", + "/etc/localtime", + "/etc/resolv.conf", +] as const; + +export interface LinuxRemoteWorkspaceCommandRunnerOptions { + bubblewrapPath?: string; + networkAccess?: boolean; + /** Additional read-only toolchain trees explicitly approved by the device owner. */ + toolchainRoots?: readonly string[]; + /** Exact Bun executable used by OCX; mounted as one file rather than exposing its host directory. */ + runtimeExecutablePath?: string; + /** Writable roots inspected before command capability is advertised. */ + writableRoots?: readonly string[]; + spawn?: typeof Bun.spawn; + /** Cross-platform test seam for the real namespace capability probe. */ + probe?: (argv: readonly string[]) => boolean; +} + +export interface RemoteWorkspaceNativeHelperDescriptor { + path: string; + sha256: string; +} + +interface NativeHelperRequest { + version: typeof NATIVE_HELPER_PROTOCOL_VERSION; + operation: "probe" | "run"; + root?: string; + cwd?: string; + command?: string[]; + toolchainRoots?: string[]; + timeoutMs?: number; + maxOutputBytes?: number; + networkAccess?: boolean; +} + +interface NativeHelperProbeResponse { + version: typeof NATIVE_HELPER_PROTOCOL_VERSION; + ok: true; + probe: true; +} + +export interface NativeRemoteWorkspaceCommandRunnerOptions { + helper: RemoteWorkspaceNativeHelperDescriptor; + toolchainRoots?: readonly string[]; + /** Writable workspace roots that must never contain the executable enforcing their sandbox. */ + writableRoots: readonly string[]; + networkAccess?: boolean; + platform?: NodeJS.Platform; + spawn?: typeof Bun.spawn; + spawnSync?: typeof Bun.spawnSync; + /** Pure test seam. Production always executes the digest-pinned helper's real probe. */ + probe?: (request: NativeHelperRequest) => unknown; +} + +const availabilityCache = new Map(); + +function exactObject(value: unknown, keys: readonly string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("remote workspace native helper returned an invalid response"); + } + const record = value as Record; + const allowed = new Set(keys); + if (Object.keys(record).some(key => !allowed.has(key))) { + throw new Error("remote workspace native helper returned an invalid response"); + } + return record; +} + +function parseNativeHelperProbeResponse(value: unknown): NativeHelperProbeResponse { + const raw = exactObject(value, ["version", "ok", "probe"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== true || raw.probe !== true) { + throw new Error("remote workspace native helper failed its confinement probe"); + } + return { version: NATIVE_HELPER_PROTOCOL_VERSION, ok: true, probe: true }; +} + +function boundedBase64(value: unknown, label: string, maximum: number): Buffer { + if (typeof value !== "string" || value.length > Math.ceil(maximum / 3) * 4 + 4 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new Error(`remote workspace native helper returned invalid ${label}`); + } + const decoded = Buffer.from(value, "base64"); + if (decoded.byteLength > maximum || decoded.toString("base64") !== value) { + throw new Error(`remote workspace native helper returned invalid ${label}`); + } + return decoded; +} + +function parseNativeHelperCommandResponse(value: unknown, maximum: number): RemoteWorkspaceCommandResult { + const raw = exactObject(value, ["version", "ok", "exitCode", "stdoutBase64", "stderrBase64"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== true + || typeof raw.exitCode !== "number" || !Number.isSafeInteger(raw.exitCode) + || raw.exitCode < -2_147_483_648 || raw.exitCode > 4_294_967_295) { + throw new Error("remote workspace native helper returned an invalid command result"); + } + const stdout = boundedBase64(raw.stdoutBase64, "stdout", maximum); + const stderr = boundedBase64(raw.stderrBase64, "stderr", maximum); + if (stdout.byteLength + stderr.byteLength > maximum) { + throw new Error("remote workspace native helper exceeded its output contract"); + } + const decoder = new TextDecoder("utf-8", { fatal: false }); + return { + exitCode: raw.exitCode, + stdout: decoder.decode(stdout), + stderr: decoder.decode(stderr), + }; +} + +function parseNativeHelperJson(value: Uint8Array): unknown { + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)); + } catch { + throw new Error("remote workspace native helper returned malformed JSON"); + } +} + +function sha256File(path: string): string { + const descriptor = openSync(path, constants.O_RDONLY); + try { + const metadata = fstatSync(descriptor); + if (!metadata.isFile() || metadata.size < 1 || metadata.size > MAX_NATIVE_HELPER_BYTES) { + throw new Error("remote workspace native helper has an invalid size"); + } + const hash = createHash("sha256"); + const chunk = Buffer.allocUnsafe(64 * 1024); + let offset = 0; + while (offset < metadata.size) { + const count = readSync(descriptor, chunk, 0, Math.min(chunk.byteLength, metadata.size - offset), offset); + if (count === 0) throw new Error("remote workspace native helper changed while hashing"); + hash.update(chunk.subarray(0, count)); + offset += count; + } + const after = fstatSync(descriptor); + if (after.size !== metadata.size || after.mtimeMs !== metadata.mtimeMs + || after.dev !== metadata.dev || after.ino !== metadata.ino) { + throw new Error("remote workspace native helper changed while hashing"); + } + return hash.digest("hex"); + } finally { + closeSync(descriptor); + } +} + +export function pinRemoteWorkspaceNativeHelper(path: string): RemoteWorkspaceNativeHelperDescriptor { + if (!isAbsolute(path) || path.includes("\0")) { + throw new Error("remote workspace native helper must be an absolute path"); + } + const linked = lstatSync(path); + if (!linked.isFile() || linked.isSymbolicLink()) { + throw new Error("remote workspace native helper must remain a real file"); + } + const canonical = realpathSync(path); + accessSync(canonical, process.platform === "win32" ? constants.F_OK : constants.X_OK); + if (process.platform !== "win32" && (statSync(canonical).mode & 0o022) !== 0) { + throw new Error("remote workspace native helper must not be group or world writable"); + } + return { path: canonical, sha256: sha256File(canonical) }; +} + +export function discoverRemoteWorkspaceNativeHelper(options: { + platform?: NodeJS.Platform; + architecture?: string; +} = {}): RemoteWorkspaceNativeHelperDescriptor | undefined { + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "win32") return undefined; + const architecture = options.architecture ?? arch(); + const executable = platform === "win32" + ? "opencodex-remote-workspace-helper.exe" + : "opencodex-remote-workspace-helper"; + const candidates = [ + // Signed release bundles place the helper here. + `${import.meta.dir}/../../native-bin/${platform}-${architecture}/${executable}`, + // Source/private-dogfood builds produced by `bun run build:remote-workspace-helper`. + `${import.meta.dir}/../../native/remote-workspace-helper/target/release/${executable}`, + ]; + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + try { + return pinRemoteWorkspaceNativeHelper(candidate); + } catch { + return undefined; + } + } + return undefined; +} + +export function parseRemoteWorkspaceNativeHelperDescriptor(value: unknown): RemoteWorkspaceNativeHelperDescriptor { + const raw = exactObject(value, ["path", "sha256"]); + if (typeof raw.path !== "string" || !isAbsolute(raw.path) || raw.path.includes("\0") || raw.path.length > 4096 + || typeof raw.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(raw.sha256)) { + throw new Error("invalid remote workspace native helper descriptor"); + } + return { path: raw.path, sha256: raw.sha256 }; +} + +function assertNativeHelperIntegrity(value: RemoteWorkspaceNativeHelperDescriptor): RemoteWorkspaceNativeHelperDescriptor { + const helper = parseRemoteWorkspaceNativeHelperDescriptor(value); + const linked = lstatSync(helper.path); + if (!linked.isFile() || linked.isSymbolicLink() || realpathSync(helper.path) !== helper.path) { + throw new Error("remote workspace native helper identity changed; pair it again"); + } + accessSync(helper.path, process.platform === "win32" ? constants.F_OK : constants.X_OK); + if (process.platform !== "win32" && (linked.mode & 0o022) !== 0) { + throw new Error("remote workspace native helper permissions are unsafe"); + } + if (sha256File(helper.path) !== helper.sha256) { + throw new Error("remote workspace native helper digest changed; pair it again"); + } + return helper; +} + +function assertNativeHelperOutsideWritableRoots( + helper: RemoteWorkspaceNativeHelperDescriptor, + roots: readonly string[], +): string[] { + if (roots.length < 1 || roots.length > 32) { + throw new Error("remote workspace native runner needs one to 32 writable roots"); + } + const canonicalRoots: string[] = []; + for (const root of roots) { + if (!isAbsolute(root) || root.includes("\0")) { + throw new Error("remote workspace writable root must be an absolute path"); + } + const canonicalRoot = realpathSync(root); + if (canonicalRoots.includes(canonicalRoot)) { + throw new Error("remote workspace native runner received a duplicate writable root"); + } + if (inside(canonicalRoot, helper.path)) { + // A sandboxed command can write anywhere below its approved root. Executing the sandbox + // helper from that same tree would turn the hash-then-spawn pathname into a writable trust + // anchor that a workspace command can replace before a later invocation. + throw new Error("remote workspace native helper must be outside every writable workspace root"); + } + canonicalRoots.push(canonicalRoot); + } + return canonicalRoots; +} + +function nativeHelperEnvironment(platform: NodeJS.Platform): Record { + const result: Record = {}; + const names = platform === "win32" + ? ["SystemRoot", "WINDIR", "TEMP", "TMP"] + : ["TMPDIR"]; + for (const name of names) { + const value = process.env[name]; + if (value) result[name] = value; + } + return result; +} + +function inside(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +function assertWorkspaceHasNoExternalHardlinkAliases(root: string): void { + const canonicalRoot = realpathSync(root); + const pending = [canonicalRoot]; + let entries = 0; + while (pending.length > 0) { + const current = pending.pop()!; + const directory = opendirSync(current); + try { + for (;;) { + const entry = directory.readSync(); + if (!entry) break; + entries += 1; + if (entries > MAX_WORKSPACE_PREFLIGHT_ENTRIES) { + throw new Error("remote workspace is too large for safe command preflight"); + } + const target = join(current, entry.name); + const metadata = lstatSync(target); + if (metadata.isDirectory() && !metadata.isSymbolicLink()) { + pending.push(target); + } else if (!metadata.isDirectory() && metadata.nlink > 1) { + // A bind mount or Seatbelt path rule cannot distinguish two names for one inode. Reject + // rather than let a workspace alias read or mutate a file whose other name is outside. + throw new Error("remote workspace command root contains a hard-linked file"); + } + } + } finally { + directory.closeSync(); + } + } +} + +function assertCommandRootsSafe(roots: readonly string[]): void { + for (const root of roots) assertWorkspaceHasNoExternalHardlinkAliases(root); +} + +function sandboxPath(root: string, cwd: string): string { + if (!inside(root, cwd)) throw new Error("remote workspace command cwd escaped its root"); + const rel = relative(root, cwd); + return rel ? `/workspace/${rel.split(sep).join("/")}` : "/workspace"; +} + +function bindArgs(flag: "--ro-bind" | "--ro-bind-try", paths: readonly string[]): string[] { + const result: string[] = []; + for (const path of paths) { + if (flag === "--ro-bind-try" || existsSync(path)) result.push(flag, path, path); + } + return result; +} + +function approvedToolchainRoots(values: readonly string[]): string[] { + const result: string[] = []; + for (const value of values) { + if (!isAbsolute(value) || !existsSync(value) || value.includes("\0")) { + throw new Error("remote workspace toolchain root must be an existing absolute path"); + } + const metadata = lstatSync(value); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace toolchain root must remain a real directory"); + } + result.push(realpathSync(value)); + } + return [...new Set(result)]; +} + +function approvedRuntimeExecutable(value: string | undefined): string | null { + if (value === undefined) return null; + if (!isAbsolute(value) || value.includes("\0")) { + throw new Error("remote workspace runtime executable must be an absolute path"); + } + const canonical = realpathSync(value); + if (!statSync(canonical).isFile()) throw new Error("remote workspace runtime executable must be a file"); + accessSync(canonical, constants.X_OK); + return canonical; +} + +function trustedBubblewrap(path: string, roots: readonly string[]): string { + if (!isAbsolute(path)) throw new Error("bubblewrap must be an absolute executable path"); + const canonical = realpathSync(path); + const file = lstatSync(canonical); + if (!file.isFile() || file.nlink !== 1) throw new Error("bubblewrap must be a private executable file"); + for (const root of roots) { + if (inside(realpathSync(root), canonical)) { + throw new Error("bubblewrap must be outside every writable workspace root"); + } + } + accessSync(canonical, constants.X_OK); + let current = canonical; + for (;;) { + const metadata = lstatSync(current); + if (process.platform !== "win32" && (metadata.mode & 0o022) !== 0) { + throw new Error("bubblewrap executable and parent directories must not be group or world writable"); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return canonical; +} + +export function linuxRemoteWorkspaceCommandArgv( + request: RemoteWorkspaceCommandRequest, + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): string[] { + const bubblewrap = trustedBubblewrap(options.bubblewrapPath ?? "/usr/bin/bwrap", [...(options.writableRoots ?? []), request.root]); + const toolchains = approvedToolchainRoots(options.toolchainRoots ?? []); + const runtimeExecutable = approvedRuntimeExecutable(options.runtimeExecutablePath); + const commandPath = [...(runtimeExecutable ? ["/ocx-runtime/bin"] : []), ...toolchains, DEFAULT_PATH].join(":"); + return [ + bubblewrap, + "--die-with-parent", + "--new-session", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + ...(options.networkAccess === true ? [] : ["--unshare-net"]), + "--proc", "/proc", + "--dev", "/dev", + "--tmpfs", "/tmp", + ...(runtimeExecutable ? [ + "--dir", "/ocx-runtime", + "--dir", "/ocx-runtime/bin", + "--ro-bind", runtimeExecutable, SANDBOX_BUN_PATH, + ] : []), + ...bindArgs("--ro-bind", READABLE_SYSTEM_PATHS), + ...bindArgs("--ro-bind-try", READABLE_ETC_PATHS), + ...toolchains.flatMap(path => ["--ro-bind", path, path]), + "--bind", request.root, "/workspace", + "--chdir", sandboxPath(request.root, request.cwd), + "--clearenv", + "--setenv", "HOME", "/workspace", + "--setenv", "PATH", commandPath, + "--setenv", "LANG", "C.UTF-8", + "--setenv", "LC_ALL", "C.UTF-8", + "--", + ...request.command, + ]; +} + +async function collectBoundedOutput( + stream: ReadableStream, + reserve: (bytes: number) => boolean, + onOverflow: () => void, +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + if (!reserve(next.value.byteLength)) { + onOverflow(); + throw new Error("remote workspace command output limit exceeded"); + } + chunks.push(next.value); + total += next.value.byteLength; + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder("utf-8", { fatal: false }).decode(body); +} + +export function createLinuxRemoteWorkspaceCommandRunner( + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): RemoteWorkspaceCommandRunner { + const spawn = options.spawn ?? Bun.spawn; + return { + async run(request): Promise { + assertWorkspaceHasNoExternalHardlinkAliases(request.root); + const argv = linuxRemoteWorkspaceCommandArgv(request, options); + const child = spawn(argv, { + cwd: request.root, + env: { PATH: DEFAULT_PATH, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let retained = 0; + let timedOut = false; + let overflowed = false; + let cancelled = false; + const stop = () => { + try { child.kill(); } catch { /* process already exited */ } + }; + const cancel = () => { + cancelled = true; + stop(); + }; + request.signal?.addEventListener("abort", cancel, { once: true }); + if (request.signal?.aborted) cancel(); + const reserve = (bytes: number): boolean => { + if (retained + bytes > request.maxOutputBytes) { + overflowed = true; + return false; + } + retained += bytes; + return true; + }; + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, request.timeoutMs); + try { + const [stdoutResult, stderrResult, exitCode] = await Promise.allSettled([ + collectBoundedOutput(child.stdout, reserve, stop), + collectBoundedOutput(child.stderr, reserve, stop), + child.exited, + ]); + if (cancelled) throw new Error("remote workspace command was cancelled"); + if (timedOut) throw new Error("remote workspace command timed out"); + if (overflowed) throw new Error("remote workspace command output limit exceeded"); + if (stdoutResult.status === "rejected") throw stdoutResult.reason; + if (stderrResult.status === "rejected") throw stderrResult.reason; + if (exitCode.status === "rejected") throw exitCode.reason; + return { exitCode: exitCode.value, stdout: stdoutResult.value, stderr: stderrResult.value }; + } finally { + clearTimeout(timer); + request.signal?.removeEventListener("abort", cancel); + } + }, + }; +} + +function nativeHelperFailure(value: unknown): Error { + const raw = exactObject(value, ["version", "ok", "error"]); + if (raw.version !== NATIVE_HELPER_PROTOCOL_VERSION || raw.ok !== false + || typeof raw.error !== "string" || raw.error.length < 1 + || [...raw.error].length > MAX_NATIVE_HELPER_ERROR_CHARS || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(raw.error)) { + return new Error("remote workspace native helper returned an invalid failure"); + } + return new Error(raw.error); +} + +function nativeHelperRequest(options: NativeRemoteWorkspaceCommandRunnerOptions, request: RemoteWorkspaceCommandRequest): NativeHelperRequest { + return { + version: NATIVE_HELPER_PROTOCOL_VERSION, + operation: "run", + root: request.root, + cwd: request.cwd, + command: [...request.command], + toolchainRoots: approvedToolchainRoots(options.toolchainRoots ?? []), + timeoutMs: request.timeoutMs, + maxOutputBytes: request.maxOutputBytes, + networkAccess: options.networkAccess === true, + }; +} + +export function createNativeRemoteWorkspaceCommandRunner( + options: NativeRemoteWorkspaceCommandRunnerOptions, +): RemoteWorkspaceCommandRunner { + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "win32") { + throw new Error("remote workspace native command helper is supported only on macOS and Windows"); + } + if (!nativeRemoteWorkspaceCommandRunnerAvailable(options)) { + throw new Error("remote workspace native command helper failed its confinement probe"); + } + const spawn = options.spawn ?? Bun.spawn; + return { + async run(request): Promise { + const helper = assertNativeHelperIntegrity(options.helper); + const writableRoots = assertNativeHelperOutsideWritableRoots(helper, options.writableRoots); + const requestRoot = realpathSync(request.root); + if (!writableRoots.includes(requestRoot)) { + throw new Error("remote workspace command root is outside the native runner grant"); + } + assertWorkspaceHasNoExternalHardlinkAliases(requestRoot); + const body = JSON.stringify(nativeHelperRequest(options, request)); + if (Buffer.byteLength(body, "utf8") > 64 * 1024) { + throw new Error("remote workspace native helper request is too large"); + } + const child = spawn([helper.path], { + cwd: request.root, + env: nativeHelperEnvironment(platform), + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }); + let retained = 0; + let overflowed = false; + let cancelled = false; + let timedOut = false; + const stop = () => { + try { child.kill(); } catch { /* helper already exited */ } + }; + const cancel = () => { + cancelled = true; + stop(); + }; + request.signal?.addEventListener("abort", cancel, { once: true }); + if (request.signal?.aborted) cancel(); + const maximumResponseBytes = Math.ceil(request.maxOutputBytes / 3) * 4 + 4_096; + const reserve = (bytes: number): boolean => { + if (retained + bytes > maximumResponseBytes + MAX_NATIVE_HELPER_STDERR_BYTES) { + overflowed = true; + return false; + } + retained += bytes; + return true; + }; + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, request.timeoutMs + 2_000); + try { + if (!cancelled) { + child.stdin.write(body); + child.stdin.end(); + } + const [stdoutResult, stderrResult, exitResult] = await Promise.allSettled([ + collectBoundedOutput(child.stdout, reserve, stop), + collectBoundedOutput(child.stderr, reserve, stop), + child.exited, + ]); + if (cancelled) throw new Error("remote workspace command was cancelled"); + if (timedOut) throw new Error("remote workspace native helper timed out"); + if (overflowed) throw new Error("remote workspace native helper output limit exceeded"); + if (stdoutResult.status === "rejected" || stderrResult.status === "rejected" || exitResult.status === "rejected") { + throw new Error("remote workspace native helper failed"); + } + if (Buffer.byteLength(stdoutResult.value, "utf8") > maximumResponseBytes + || Buffer.byteLength(stderrResult.value, "utf8") > MAX_NATIVE_HELPER_STDERR_BYTES + || exitResult.value !== 0) { + throw new Error("remote workspace native helper failed"); + } + const response = parseNativeHelperJson(Buffer.from(stdoutResult.value, "utf8")); + if (response && typeof response === "object" && !Array.isArray(response) + && (response as Record).ok === false) { + throw nativeHelperFailure(response); + } + return parseNativeHelperCommandResponse(response, request.maxOutputBytes); + } finally { + clearTimeout(timer); + request.signal?.removeEventListener("abort", cancel); + try { child.stdin.end(); } catch { /* helper already closed stdin */ } + } + }, + }; +} + +export function nativeRemoteWorkspaceCommandRunnerAvailable( + options: NativeRemoteWorkspaceCommandRunnerOptions, +): boolean { + const platform = options.platform ?? process.platform; + if (platform !== "darwin") return false; // Windows awaits a surviving native cleanup owner. + try { + const helper = assertNativeHelperIntegrity(options.helper); + const writableRoots = assertNativeHelperOutsideWritableRoots(helper, options.writableRoots); + assertCommandRootsSafe(writableRoots); + const request: NativeHelperRequest = { version: NATIVE_HELPER_PROTOCOL_VERSION, operation: "probe" }; + const raw = options.probe + ? options.probe(request) + : (() => { + const result = (options.spawnSync ?? Bun.spawnSync)([helper.path], { + cwd: dirname(helper.path), + env: nativeHelperEnvironment(platform), + stdin: Buffer.from(JSON.stringify(request), "utf8"), + stdout: "pipe", + stderr: "ignore", + timeout: 8_000, + windowsHide: true, + }); + if (!result.success || result.stdout.byteLength > 4_096) { + throw new Error("remote workspace native helper probe failed"); + } + return parseNativeHelperJson(result.stdout); + })(); + parseNativeHelperProbeResponse(raw); + return true; + } catch { + return false; + } +} + +export function createPlatformRemoteWorkspaceCommandRunner(options: { + platform?: NodeJS.Platform; + linux?: LinuxRemoteWorkspaceCommandRunnerOptions; + native?: Omit; +} = {}): RemoteWorkspaceCommandRunner | undefined { + const platform = options.platform ?? process.platform; + if (platform === "linux" && linuxRemoteWorkspaceCommandRunnerAvailable(options.linux)) { + const linux = { + ...options.linux, + runtimeExecutablePath: options.linux?.runtimeExecutablePath ?? process.execPath, + }; + return createLinuxRemoteWorkspaceCommandRunner(linux); + } + if ((platform === "darwin" || platform === "win32") && options.native) { + const native = { ...options.native, platform }; + try { + return createNativeRemoteWorkspaceCommandRunner(native); + } catch { + return undefined; + } + } + return undefined; +} + +export function linuxRemoteWorkspaceCommandRunnerAvailable( + options: LinuxRemoteWorkspaceCommandRunnerOptions = {}, +): boolean { + let path: string; + try { + path = trustedBubblewrap(options.bubblewrapPath ?? "/usr/bin/bwrap", options.writableRoots ?? []); + if (options.writableRoots) assertCommandRootsSafe(options.writableRoots); + } catch { + return false; + } + const argv = [ + path, + "--die-with-parent", + "--new-session", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + ...(options.networkAccess === true ? [] : ["--unshare-net"]), + "--proc", "/proc", + "--dev", "/dev", + ...bindArgs("--ro-bind", READABLE_SYSTEM_PATHS), + "--", + "/bin/true", + ]; + if (options.probe) return options.probe(argv); + const cacheKey = `${path}\0${options.networkAccess === true ? "network" : "isolated"}`; + const cached = availabilityCache.get(cacheKey); + if (cached !== undefined) return cached; + let available = false; + try { + available = Bun.spawnSync(argv, { + env: { PATH: DEFAULT_PATH, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + timeout: 2_000, + }).success; + } catch { + available = false; + } + availabilityCache.set(cacheKey, available); + return available; +} + diff --git a/src/remote-control/workspace-coordinator.ts b/src/remote-control/workspace-coordinator.ts new file mode 100644 index 0000000000..684746622a --- /dev/null +++ b/src/remote-control/workspace-coordinator.ts @@ -0,0 +1,230 @@ +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; +import { resolveTrustedWindowsSystemDirectory } from "../lib/windows-elevation"; +import { + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + parseRemoteWorkspaceToolCall, + remoteWorkspaceCodexDeveloperInstructions, + remoteWorkspaceCapabilityForTool, + remoteWorkspaceDeveloperInstructions, + remoteWorkspaceToolsForCapabilities, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolCallParams, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; +import type { RemoteWorkspaceExecutionRequest } from "./workspace-executor"; + +export interface RemoteWorkspaceSessionBinding { + sessionId: string; + threadId: string; + executorDeviceId: string; + executorName: string; + rootId: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; +} + +export interface RemoteWorkspaceTransport { + isOnline(deviceId: string): boolean; + invoke(request: RemoteWorkspaceExecutionRequest): Promise; +} + +export interface AppServerDynamicToolRequest { + method: "item/tool/call"; + id: string | number; + params: unknown; +} + +export interface AppServerDynamicToolResponse { + id: string | number; + result: { + contentItems: Array<{ type: "inputText"; text: string }>; + success: boolean; + }; +} + +function identifier(value: string, label: string): string { + if (value.length < 1 || value.length > 256 || /[\x00-\x1f\x7f]/.test(value)) { + throw new Error(`invalid remote workspace ${label}`); + } + return value; +} + +function resultText(result: RemoteWorkspaceToolResult): string { + const encoded = JSON.stringify(result); + if (Buffer.byteLength(encoded, "utf8") > REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES) { + return JSON.stringify({ ok: false, error: "remote workspace tool result exceeded the coordinator limit" }); + } + return encoded; +} + +export function remoteWorkspaceThreadStartParams(options: { + executorName: string; + coordinatorIsolationPath: string; + tools: readonly RemoteWorkspaceToolName[]; + platform?: NodeJS.Platform; + windowsSystemDirectory?: string; + mcp?: { + url: string; + bearerTokenEnvVar: string; + disabledServerNames?: readonly string[]; + disabledHookNames?: readonly string[]; + hubRuntimeReadPaths?: readonly string[]; + }; +}): Record { + if (!isAbsolute(options.coordinatorIsolationPath) || options.coordinatorIsolationPath.includes("\0")) { + throw new Error("remote workspace coordinator isolation path must be absolute"); + } + const platform = options.platform ?? process.platform; + const shellEnvironment = platform === "win32" + ? { + HOME: options.coordinatorIsolationPath, + USERPROFILE: options.coordinatorIsolationPath, + TEMP: options.coordinatorIsolationPath, + TMP: options.coordinatorIsolationPath, + PATH: options.windowsSystemDirectory ?? resolveTrustedWindowsSystemDirectory(), + } + : { + HOME: options.coordinatorIsolationPath, + PATH: platform === "darwin" ? "/usr/bin:/bin" : "/usr/local/bin:/usr/bin:/bin", + LANG: "C.UTF-8", + }; + const config = options.mcp ? { + // A Remote Workspace thread may authenticate/model-call from the Hub, but every + // model-visible action must either be the one OCX MCP server or fail closed. + default_permissions: "ocx-remote-deny-local", + permissions: { + "ocx-remote-deny-local": { + description: "Deny Hub-local command filesystem and network access for Remote Workspace.", + filesystem: { + ":minimal": "read", + ":workspace_roots": { ".": "read" }, + ...Object.fromEntries((options.mcp.hubRuntimeReadPaths ?? []).map(path => [path, "read"])), + }, + network: { enabled: false }, + }, + }, + approval_policy: "never", + allow_login_shell: false, + shell_environment_policy: { + inherit: "none", + ignore_default_excludes: false, + set: shellEnvironment, + }, + web_search: "disabled", + tools: { view_image: false, web_search: false }, + agents: { enabled: false }, + apps: { _default: { enabled: false } }, + features: { + apps: false, + browser_use: false, + computer_use: false, + in_app_browser: false, + memories: false, + multi_agent: false, + plugins: false, + remote_plugin: false, + }, + memories: { use_memories: false, generate_memories: false }, + hooks: Object.fromEntries((options.mcp.disabledHookNames ?? []).map(name => [name, []])), + mcp_servers: { + ...Object.fromEntries((options.mcp.disabledServerNames ?? []) + .filter(name => name !== REMOTE_WORKSPACE_TOOL_NAMESPACE) + .map(name => [name, { enabled: false }])), + [REMOTE_WORKSPACE_TOOL_NAMESPACE]: { + enabled: true, + required: true, + url: options.mcp.url, + bearer_token_env_var: options.mcp.bearerTokenEnvVar, + enabled_tools: [...options.tools], + default_tools_approval_mode: "approve", + startup_timeout_sec: 5, + tool_timeout_sec: 65, + }, + }, + } : undefined; + return { + cwd: options.coordinatorIsolationPath, + runtimeWorkspaceRoots: [options.coordinatorIsolationPath], + approvalPolicy: "never", + ephemeral: false, + serviceName: "opencodex_remote_workspace", + developerInstructions: options.mcp + ? remoteWorkspaceCodexDeveloperInstructions(options.executorName, options.tools) + : remoteWorkspaceDeveloperInstructions(options.executorName, options.tools), + ...(config ? { config } : {}), + }; +} + +export class RemoteWorkspaceCoordinator { + private readonly sessions = new Map(); + + constructor(private readonly transport: RemoteWorkspaceTransport) {} + + register(binding: RemoteWorkspaceSessionBinding): () => void { + const capabilities = [...binding.capabilities]; + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + if (tools.length < 1) throw new Error("remote workspace binding has no usable tools"); + const normalized: RemoteWorkspaceSessionBinding = { + sessionId: identifier(binding.sessionId, "session ID"), + threadId: identifier(binding.threadId, "thread ID"), + executorDeviceId: identifier(binding.executorDeviceId, "executor device ID"), + executorName: identifier(binding.executorName, "executor name"), + rootId: identifier(binding.rootId, "root ID"), + capabilities, + tools, + }; + if (this.sessions.has(normalized.threadId)) throw new Error("remote workspace thread is already bound"); + this.sessions.set(normalized.threadId, normalized); + return () => { + if (this.sessions.get(normalized.threadId)?.sessionId === normalized.sessionId) { + this.sessions.delete(normalized.threadId); + } + }; + } + + async handle(request: AppServerDynamicToolRequest): Promise { + if (request.method !== "item/tool/call") throw new Error("unsupported App Server request"); + let call: RemoteWorkspaceToolCallParams; + try { + call = parseRemoteWorkspaceToolCall(request.params); + } catch (error) { + return this.response(request.id, { ok: false, error: error instanceof Error ? error.message : "invalid remote tool call" }); + } + const binding = this.sessions.get(call.threadId); + if (!binding) return this.response(request.id, { ok: false, error: "remote workspace thread is not bound" }); + if (!binding.tools.includes(call.tool) + || !binding.capabilities.includes(remoteWorkspaceCapabilityForTool(call.tool))) { + return this.response(request.id, { ok: false, error: "remote workspace tool is not supported by this executor" }); + } + if (!this.transport.isOnline(binding.executorDeviceId)) { + return this.response(request.id, { ok: false, error: "remote executor is offline; local fallback is disabled" }); + } + let result: RemoteWorkspaceToolResult; + try { + result = await this.transport.invoke({ + requestId: randomUUID(), + sessionId: binding.sessionId, + executorDeviceId: binding.executorDeviceId, + rootId: binding.rootId, + tool: call.tool, + arguments: call.arguments, + }); + } catch { + result = { ok: false, error: "remote executor transport failed; local fallback is disabled" }; + } + return this.response(request.id, result); + } + + private response(id: string | number, result: RemoteWorkspaceToolResult): AppServerDynamicToolResponse { + return { + id, + result: { + contentItems: [{ type: "inputText", text: resultText(result) }], + success: result.ok, + }, + }; + } +} diff --git a/src/remote-control/workspace-device.ts b/src/remote-control/workspace-device.ts new file mode 100644 index 0000000000..40e42d817b --- /dev/null +++ b/src/remote-control/workspace-device.ts @@ -0,0 +1,585 @@ +import { createPrivateKey, createPublicKey, randomUUID, sign, verify } from "node:crypto"; +import { arch, hostname, platform } from "node:os"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteControlIdentityKeyPair, +} from "./crypto"; +import { RemoteWorkspaceExecutor } from "./workspace-executor"; +import { RemoteWorkspaceExecutorAgentConnection } from "./workspace-agent-connection"; +import { + createPlatformRemoteWorkspaceCommandRunner, + discoverRemoteWorkspaceNativeHelper, + parseRemoteWorkspaceNativeHelperDescriptor, + pinRemoteWorkspaceNativeHelper, + type RemoteWorkspaceNativeHelperDescriptor, +} from "./workspace-command-runner"; +import type { RemoteWorkspaceCommandRunner } from "./workspace-executor"; +import { + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + serializeRemoteWorkspaceAgentMessage, +} from "./workspace-agent-protocol"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_DEVICE_STATE_VERSION = 1 as const; +const DEVICE_TOKEN_PATTERN = /^ocxrw_[A-Za-z0-9_-]{43}$/; +const MAX_PAIR_RESPONSE_BYTES = 64 * 1024; +const MAX_DEVICE_STATE_BYTES = 1024 * 1024; +const PAIR_TIMEOUT_MS = 15_000; + +export interface RemoteWorkspaceDeviceRoot { + id: string; + label: string; + path: string; +} + +export interface RemoteWorkspaceDeviceState { + version: typeof REMOTE_WORKSPACE_DEVICE_STATE_VERSION; + hubUrl: string; + agentUrl: string; + deviceId: string; + deviceName: string; + devicePlatform: string; + capabilities: RemoteWorkspaceCapability[]; + deviceToken: string; + deviceIdentity: RemoteControlIdentityKeyPair; + hubPublicKey: string; + roots: RemoteWorkspaceDeviceRoot[]; + toolchainRoots: string[]; + nativeHelper?: RemoteWorkspaceNativeHelperDescriptor; +} + +export interface RemoteWorkspaceDeviceStateStore { + load(): RemoteWorkspaceDeviceState | null; + save(state: RemoteWorkspaceDeviceState): void; +} + +export interface PairRemoteWorkspaceDeviceOptions { + hubUrl: string; + pairingCode: string; + name?: string; + roots: Array<{ path: string; label?: string }>; + fetchImpl?: typeof fetch; + store?: RemoteWorkspaceDeviceStateStore; + devicePlatform?: string; + capabilities?: RemoteWorkspaceCapability[]; + toolchainRoots?: string[]; + nativeHelperPath?: string; +} + +export interface RemoteWorkspaceWebSocketLike { + readyState: number; + send(value: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: "open" | "close" | "error" | "message", listener: (event: Event | MessageEvent) => void): void; +} + +export type RemoteWorkspaceWebSocketFactory = ( + url: string, + headers: Record, +) => RemoteWorkspaceWebSocketLike; + +function boundedText(value: unknown, label: string, max: number): string { + if (typeof value !== "string") throw new Error(`invalid remote workspace ${label}`); + const normalized = value.trim(); + if (normalized.length < 1 || normalized.length > max || /[\x00-\x1f\x7f]/.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function uuid(value: unknown, label: string): string { + const text = boundedText(value, label, 64); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) { + throw new Error(`invalid remote workspace ${label}`); + } + return text; +} + +function publicKey(value: unknown, label: string): string { + const encoded = boundedText(value, label, 1024); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error(`invalid remote workspace ${label}`); + const key = createPublicKey({ key: Buffer.from(encoded, "base64url"), type: "spki", format: "der" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error(`remote workspace ${label} must use Ed25519`); + return encoded; +} + +function identity(value: unknown): RemoteControlIdentityKeyPair { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace device identity"); + const raw = value as Record; + const pub = publicKey(raw.publicKey, "device public key"); + const priv = boundedText(raw.privateKey, "device private key", 2048); + const privateKey = createPrivateKey({ key: Buffer.from(priv, "base64url"), type: "pkcs8", format: "der" }); + if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("remote workspace device key must use Ed25519"); + const challenge = Buffer.from("opencodex remote workspace device identity v1", "utf8"); + if (!verify( + null, + challenge, + createPublicKey({ key: Buffer.from(pub, "base64url"), type: "spki", format: "der" }), + sign(null, challenge, privateKey), + )) throw new Error("remote workspace device identity key pair does not match"); + return { publicKey: pub, privateKey: priv }; +} + +export function normalizeRemoteWorkspaceHubUrl(value: string): string { + const url = new URL(value); + const local = (url.hostname === "127.0.0.1" || url.hostname === "localhost") && url.protocol === "http:"; + if (url.protocol !== "https:" && !local) throw new Error("remote workspace hub must use HTTPS"); + if (url.username || url.password || url.search || url.hash) throw new Error("remote workspace hub URL must not contain credentials or fragments"); + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; + return url.toString().replace(/\/$/, ""); +} + +function agentUrlForHub(hubUrl: string): string { + const url = new URL("/remote-workspace/agent", `${hubUrl}/`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function validateRootInputs(values: Array<{ path: string; label?: string }>): RemoteWorkspaceDeviceRoot[] { + if (values.length < 1 || values.length > 32) throw new Error("remote workspace device needs one to 32 roots"); + const paths = new Set(); + const labels = new Set(); + return values.map(value => { + if (!isAbsolute(value.path) || value.path.includes("\0")) throw new Error("remote workspace root must be an absolute path"); + const metadata = lstatSync(value.path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory"); + const path = realpathSync(value.path); + const label = boundedText(value.label ?? basename(path), "root label", 80); + const folded = label.toLocaleLowerCase("en-US"); + if (paths.has(path) || labels.has(folded)) throw new Error("duplicate remote workspace root"); + paths.add(path); + labels.add(folded); + return { id: randomUUID(), label, path }; + }); +} + +function parseRoots(value: unknown): RemoteWorkspaceDeviceRoot[] { + if (!Array.isArray(value) || value.length < 1 || value.length > 32) throw new Error("invalid remote workspace device roots"); + const paths = new Set(); + const ids = new Set(); + return value.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace device root"); + const raw = item as Record; + const id = uuid(raw.id, "root ID"); + const label = boundedText(raw.label, "root label", 80); + const path = boundedText(raw.path, "root path", 4096); + if (!isAbsolute(path) || ids.has(id) || paths.has(path)) throw new Error("invalid remote workspace device root"); + ids.add(id); + paths.add(path); + return { id, label, path }; + }); +} + +function validateToolchainRoots(value: unknown): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 16) throw new Error("invalid remote workspace toolchain roots"); + const paths = new Set(); + for (const candidate of value) { + if (typeof candidate !== "string" || !isAbsolute(candidate) || candidate.includes("\0")) { + throw new Error("remote workspace toolchain root must be an absolute directory"); + } + const metadata = lstatSync(candidate); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace toolchain root must be a real directory"); + } + paths.add(realpathSync(candidate)); + } + return [...paths]; +} + +export function parseRemoteWorkspaceDeviceState(value: unknown): RemoteWorkspaceDeviceState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace device state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_DEVICE_STATE_VERSION) throw new Error("unsupported remote workspace device state"); + const hubUrl = normalizeRemoteWorkspaceHubUrl(boundedText(raw.hubUrl, "hub URL", 2048)); + const agentUrl = boundedText(raw.agentUrl, "agent URL", 2048); + if (agentUrl !== agentUrlForHub(hubUrl)) throw new Error("remote workspace agent URL does not match its hub"); + const deviceToken = boundedText(raw.deviceToken, "device token", 128); + if (!DEVICE_TOKEN_PATTERN.test(deviceToken)) throw new Error("invalid remote workspace device token"); + return { + version: REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + hubUrl, + agentUrl, + deviceId: uuid(raw.deviceId, "device ID"), + deviceName: boundedText(raw.deviceName, "device name", 80), + devicePlatform: boundedText(raw.devicePlatform, "device platform", 80), + capabilities: parseRemoteWorkspaceCapabilities(raw.capabilities), + deviceToken, + deviceIdentity: identity(raw.deviceIdentity), + hubPublicKey: publicKey(raw.hubPublicKey, "hub public key"), + roots: parseRoots(raw.roots), + toolchainRoots: validateToolchainRoots(raw.toolchainRoots), + ...(raw.nativeHelper === undefined + ? {} + : { nativeHelper: parseRemoteWorkspaceNativeHelperDescriptor(raw.nativeHelper) }), + }; +} + +export class RemoteWorkspaceDeviceFileStore implements RemoteWorkspaceDeviceStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-device.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceDeviceState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_DEVICE_STATE_BYTES) { + throw new Error("remote workspace device state is too large"); + } + return parseRemoteWorkspaceDeviceState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceDeviceState): void { + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, `${JSON.stringify(parseRemoteWorkspaceDeviceState(state), null, 2)}\n`); + } +} + +async function boundedJson(response: Response): Promise { + const declared = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > MAX_PAIR_RESPONSE_BYTES) throw new Error("remote workspace hub response is too large"); + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + if (reader) { + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_PAIR_RESPONSE_BYTES) { + await reader.cancel("remote workspace hub response is too large").catch(() => {}); + throw new Error("remote workspace hub response is too large"); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(body); + try { return text ? JSON.parse(text) : {}; } + catch { throw new Error(`remote workspace hub returned HTTP ${response.status}`); } +} + +export async function pairRemoteWorkspaceDevice(options: PairRemoteWorkspaceDeviceOptions): Promise { + const hubUrl = normalizeRemoteWorkspaceHubUrl(options.hubUrl); + const roots = validateRootInputs(options.roots); + const deviceName = boundedText(options.name ?? hostname(), "device name", 80); + const devicePlatform = boundedText(options.devicePlatform ?? `${platform()}-${arch()}`, "device platform", 80); + const toolchainRoots = validateToolchainRoots(options.toolchainRoots); + const nativeHelper = options.nativeHelperPath + ? pinRemoteWorkspaceNativeHelper(options.nativeHelperPath) + : discoverRemoteWorkspaceNativeHelper(); + const commandRunner = createPlatformRemoteWorkspaceCommandRunner({ + linux: { toolchainRoots, writableRoots: roots.map(root => root.path) }, + ...(nativeHelper ? { native: { + helper: nativeHelper, + toolchainRoots, + writableRoots: roots.map(root => root.path), + } } : {}), + }); + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(commandRunner, options.capabilities); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const response = await (options.fetchImpl ?? fetch)(new URL("/remote-workspace/pair", `${hubUrl}/`), { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(PAIR_TIMEOUT_MS), + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + code: options.pairingCode, + name: deviceName, + platform: devicePlatform, + publicKey: deviceIdentity.publicKey, + capabilities, + roots: roots.map(root => ({ id: root.id, label: root.label })), + }), + }); + const body = await boundedJson(response); + if (!response.ok || !body || typeof body !== "object" || Array.isArray(body)) { + const error = body && typeof body === "object" && "error" in body && typeof body.error === "string" + ? body.error + : `remote workspace pairing failed (${response.status})`; + throw new Error(error); + } + const raw = body as Record; + const device = raw.device && typeof raw.device === "object" && !Array.isArray(raw.device) + ? raw.device as Record + : null; + if (!device) throw new Error("remote workspace hub returned an invalid device"); + const state = parseRemoteWorkspaceDeviceState({ + version: REMOTE_WORKSPACE_DEVICE_STATE_VERSION, + hubUrl, + agentUrl: agentUrlForHub(hubUrl), + deviceId: device.id, + deviceName, + devicePlatform, + capabilities, + deviceToken: raw.deviceToken, + deviceIdentity, + hubPublicKey: raw.hubPublicKey, + roots, + toolchainRoots, + ...(nativeHelper ? { nativeHelper } : {}), + }); + (options.store ?? new RemoteWorkspaceDeviceFileStore()).save(state); + return state; +} + +function defaultWebSocketFactory(url: string, headers: Record): RemoteWorkspaceWebSocketLike { + return new WebSocket(url, { headers } as unknown as string[]) as unknown as RemoteWorkspaceWebSocketLike; +} + +async function messageBytes(event: MessageEvent): Promise { + if (typeof event.data === "string") return event.data; + if (event.data instanceof ArrayBuffer) return new Uint8Array(event.data); + if (ArrayBuffer.isView(event.data)) return new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + if (event.data instanceof Blob) return new Uint8Array(await event.data.arrayBuffer()); + throw new Error("remote workspace agent received an unsupported frame"); +} + +export interface RemoteWorkspaceAgentHandle { + connected: Promise; + closed: Promise; + stop(): void; +} + +export interface RemoteWorkspaceAgentRunStatus { + state: "connecting" | "online" | "reconnecting" | "stopped"; + attempt: number; + message?: string; +} + +/** Never advertise more authority than both local support and the pairing-time grant allow. */ +export function remoteWorkspaceCapabilitiesForCommandRunner( + commandRunner: RemoteWorkspaceCommandRunner | undefined, + approved?: readonly RemoteWorkspaceCapability[], +): RemoteWorkspaceCapability[] { + const available = parseRemoteWorkspaceCapabilities([ + "workspace.read", + "workspace.write", + ...(commandRunner ? ["workspace.exec" as const] : []), + ]); + const requested = parseRemoteWorkspaceCapabilities(approved ?? available); + const allowed = new Set(available); + return parseRemoteWorkspaceCapabilities(requested.filter(capability => allowed.has(capability))); +} + +export function connectRemoteWorkspaceAgent(options: { + state: RemoteWorkspaceDeviceState; + webSocketFactory?: RemoteWorkspaceWebSocketFactory; + commandRunner?: RemoteWorkspaceCommandRunner | null; +}): RemoteWorkspaceAgentHandle { + const state = parseRemoteWorkspaceDeviceState(options.state); + const commandRunner = options.commandRunner === undefined + ? createPlatformRemoteWorkspaceCommandRunner({ + linux: { + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + }, + ...(state.nativeHelper ? { native: { + helper: state.nativeHelper, + toolchainRoots: state.toolchainRoots, + writableRoots: state.roots.map(root => root.path), + } } : {}), + }) + : options.commandRunner ?? undefined; + const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(commandRunner, state.capabilities); + const executor = new RemoteWorkspaceExecutor({ + deviceId: state.deviceId, + roots: state.roots.map(root => ({ id: root.id, path: root.path })), + commandRunner, + }); + const socket = (options.webSocketFactory ?? defaultWebSocketFactory)(state.agentUrl, { + authorization: `Bearer ${state.deviceToken}`, + }); + let agent: RemoteWorkspaceExecutorAgentConnection | null = null; + let opened = false; + let presenceAccepted = false; + let stopped = false; + let settleConnected!: () => void; + let rejectConnected!: (error: Error) => void; + let settleClosed!: () => void; + const connected = new Promise((resolve, reject) => { + settleConnected = resolve; + rejectConnected = reject; + }); + const closed = new Promise(resolve => { settleClosed = resolve; }); + let queue = Promise.resolve(); + let heartbeat: ReturnType | null = null; + let presenceTimer: ReturnType | null = null; + const acceptPresence = () => { + if (stopped) return; + if (presenceAccepted) return; + presenceAccepted = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + settleConnected(); + }; + + socket.addEventListener("open", () => { + if (stopped) { + try { socket.close(1000, "remote workspace agent stopped"); } catch { /* already closed */ } + return; + } + opened = true; + presenceTimer = setTimeout(() => { + rejectConnected(new Error("remote workspace Hub did not acknowledge executor capabilities")); + socket.close(1008, "remote workspace presence timed out"); + }, 10_000); + agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId: state.deviceId, + deviceIdentity: state.deviceIdentity, + hubPublicKey: state.hubPublicKey, + executor, + capabilities, + onPresenceAccepted: acceptPresence, + socket: { + send: value => socket.send(value), + close: (code, reason) => socket.close(code, reason), + }, + }); + socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities, + })); + heartbeat = setInterval(() => { + if (socket.readyState !== 1) return; + socket.send(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "heartbeat", + nonce: randomUUID(), + })); + }, 20_000); + }); + socket.addEventListener("message", event => { + if (stopped || !(event instanceof MessageEvent) || !agent) return; + queue = queue.then(async () => agent?.receive(await messageBytes(event))).catch(() => { + socket.close(1008, "remote workspace protocol error"); + }); + }); + socket.addEventListener("error", () => { + if (!stopped && !presenceAccepted) rejectConnected(new Error("remote workspace agent connection failed")); + }); + socket.addEventListener("close", () => { + stopped = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + const currentAgent = agent; + agent = null; + currentAgent?.close(); + if (!presenceAccepted) rejectConnected(new Error( + opened + ? "remote workspace agent connection closed before presence acknowledgement" + : "remote workspace agent connection closed before opening", + )); + settleClosed(); + }); + return { + connected, + closed, + stop() { + if (stopped) return; + stopped = true; + if (presenceTimer) clearTimeout(presenceTimer); + presenceTimer = null; + if (heartbeat) clearInterval(heartbeat); + heartbeat = null; + const currentAgent = agent; + agent = null; + currentAgent?.close(); + if (!presenceAccepted) rejectConnected(new Error("remote workspace agent stopped")); + settleClosed(); + try { socket.close(1000, "remote workspace agent stopped"); } catch { /* CONNECTING sockets differ by runtime */ } + }, + }; +} + +function waitForReconnect(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise(resolve => { + const timer = setTimeout(finish, delayMs); + function finish() { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + } + signal.addEventListener("abort", finish, { once: true }); + }); +} + +export async function runRemoteWorkspaceAgent(options: { + state: RemoteWorkspaceDeviceState; + signal: AbortSignal; + webSocketFactory?: RemoteWorkspaceWebSocketFactory; + commandRunner?: RemoteWorkspaceCommandRunner | null; + onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void; + minReconnectMs?: number; + maxReconnectMs?: number; + random?: () => number; +}): Promise { + const state = parseRemoteWorkspaceDeviceState(options.state); + const minimum = options.minReconnectMs ?? 500; + const maximum = options.maxReconnectMs ?? 15_000; + if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || minimum < 10 || maximum < minimum) { + throw new Error("invalid remote workspace reconnect policy"); + } + let attempt = 0; + let delayMs = minimum; + while (!options.signal.aborted) { + attempt += 1; + options.onStatus?.({ state: "connecting", attempt }); + const handle = connectRemoteWorkspaceAgent({ + state, + ...(options.webSocketFactory ? { webSocketFactory: options.webSocketFactory } : {}), + ...(options.commandRunner !== undefined ? { commandRunner: options.commandRunner } : {}), + }); + const stop = () => handle.stop(); + options.signal.addEventListener("abort", stop, { once: true }); + try { + await handle.connected; + delayMs = minimum; + options.onStatus?.({ state: "online", attempt }); + await handle.closed; + } catch (error) { + handle.stop(); + if (!options.signal.aborted) { + options.onStatus?.({ + state: "reconnecting", + attempt, + message: error instanceof Error ? error.message : "remote workspace connection failed", + }); + } + } finally { + options.signal.removeEventListener("abort", stop); + } + if (options.signal.aborted) break; + options.onStatus?.({ state: "reconnecting", attempt }); + const random = Math.min(1, Math.max(0, (options.random ?? Math.random)())); + const jitteredDelay = Math.max(10, Math.round(delayMs * (0.8 + random * 0.4))); + await waitForReconnect(jitteredDelay, options.signal); + delayMs = Math.min(maximum, delayMs * 2); + } + options.onStatus?.({ state: "stopped", attempt }); +} diff --git a/src/remote-control/workspace-executable.ts b/src/remote-control/workspace-executable.ts new file mode 100644 index 0000000000..db6153303c --- /dev/null +++ b/src/remote-control/workspace-executable.ts @@ -0,0 +1,43 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { posix, win32 } from "node:path"; + +function executableCandidate(path: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK); + return true; + } catch { + return false; + } +} + +/** Resolve only durable PATH entries; an empty/current-directory entry is never trusted. */ +export function findExecutableOnPath(name: string, options: { + path?: string; + pathExt?: string; + platform?: NodeJS.Platform; + /** Pure cross-platform test seam; production checks the real filesystem. */ + probe?: (candidate: string) => boolean; +} = {}): string | null { + const path = options.path ?? process.env.PATH; + const platform = options.platform ?? process.platform; + if (!path) return null; + const paths = platform === "win32" ? win32 : posix; + const spawnableWindowsExtensions = new Set([".com", ".exe", ".bat", ".cmd"]); + const suffixes = platform === "win32" + ? (options.pathExt ?? process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .map(value => value.trim()) + .filter(value => spawnableWindowsExtensions.has(value.toLowerCase())) + : [""]; + if (platform === "win32" && win32.extname(name)) suffixes.unshift(""); + const probe = options.probe ?? (candidate => executableCandidate(candidate, platform)); + for (const directory of path.split(paths.delimiter)) { + if (!directory) continue; + for (const suffix of suffixes) { + const candidate = paths.join(directory, `${name}${suffix.toLowerCase()}`); + if (probe(candidate)) return candidate; + } + } + return null; +} diff --git a/src/remote-control/workspace-executor.ts b/src/remote-control/workspace-executor.ts new file mode 100644 index 0000000000..3312e8348f --- /dev/null +++ b/src/remote-control/workspace-executor.ts @@ -0,0 +1,396 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + opendirSync, + readSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from "node:path"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; + +export interface RemoteWorkspaceRoot { + id: string; + path: string; +} + +export interface RemoteWorkspaceExecutionRequest { + requestId: string; + sessionId: string; + executorDeviceId: string; + rootId: string; + tool: RemoteWorkspaceToolName; + arguments: unknown; +} + +export interface RemoteWorkspaceExecutorOptions { + deviceId: string; + roots: readonly RemoteWorkspaceRoot[]; + maxOutputBytes?: number; + platform?: NodeJS.Platform; + /** Production must provide an OS-sandboxed runner. Omission disables command execution. */ + commandRunner?: RemoteWorkspaceCommandRunner; +} + +export interface RemoteWorkspaceCommandRequest { + command: string[]; + root: string; + cwd: string; + timeoutMs: number; + maxOutputBytes: number; + signal?: AbortSignal; +} + +export interface RemoteWorkspaceCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface RemoteWorkspaceCommandRunner { + run(request: RemoteWorkspaceCommandRequest): Promise; +} + +interface ApprovedRoot { + id: string; + path: string; + dev: number; + ino: number; + birthtimeMs: number; +} + +function objectArguments(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("remote workspace arguments must be an object"); + } + return value as Record; +} + +function noExtraKeys(value: Record, allowed: readonly string[]): void { + const set = new Set(allowed); + if (Object.keys(value).some(key => !set.has(key))) throw new Error("unknown remote workspace argument"); +} + +const WINDOWS_RESERVED_BASENAME = /^(?:con|prn|aux|nul|clock\$|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\..*)?$/i; + +export function validateRemoteWorkspaceRelativePath( + value: unknown, + fallback?: string, + platform: NodeJS.Platform = process.platform, +): string { + const path = value === undefined ? fallback : value; + if (typeof path !== "string" || path.length < 1 || path.length > 4096 || path.includes("\0")) { + throw new Error("invalid remote workspace path"); + } + const paths = platform === "win32" ? win32 : posix; + if (paths.isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\")) { + throw new Error("remote workspace path must be relative"); + } + if (platform === "win32") { + for (const segment of path.split(/[\\/]/)) { + if (!segment || segment === "." || segment === "..") continue; + if (/[\x01-\x1f<>:"|?*]/.test(segment) || /[ .]$/.test(segment) || WINDOWS_RESERVED_BASENAME.test(segment)) { + throw new Error("remote workspace path is not a safe Windows file path"); + } + } + } + return path; +} + +function inside(root: string, candidate: string): boolean { + const fromRoot = relative(root, candidate); + return fromRoot === "" || (!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".." && !isAbsolute(fromRoot)); +} + +function errorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object" || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +function assertNoSymlinkComponents(root: string, candidate: string, includeLeaf: boolean): void { + const rel = relative(root, candidate); + const parts = rel === "" ? [] : rel.split(sep); + const limit = includeLeaf ? parts.length : Math.max(0, parts.length - 1); + let current = root; + for (let index = 0; index < limit; index += 1) { + current = resolve(current, parts[index]!); + if (lstatSync(current).isSymbolicLink()) throw new Error("remote workspace symlink traversal is not allowed"); + } +} + +function resolveExisting(root: string, value: unknown, platform = process.platform): string { + const candidate = resolve(root, validateRemoteWorkspaceRelativePath(value, ".", platform)); + if (!inside(root, candidate)) throw new Error("remote workspace path escapes the approved root"); + assertNoSymlinkComponents(root, candidate, true); + const canonical = realpathSync(candidate); + if (!inside(root, canonical)) throw new Error("remote workspace path escapes the approved root"); + return canonical; +} + +function resolveWritable(root: string, value: unknown, platform = process.platform): string { + const candidate = resolve(root, validateRemoteWorkspaceRelativePath(value, undefined, platform)); + if (!inside(root, candidate) || candidate === root) throw new Error("remote workspace path escapes the approved root"); + const parent = dirname(candidate); + assertNoSymlinkComponents(root, parent, true); + const canonicalParent = realpathSync(parent); + if (!inside(root, canonicalParent)) throw new Error("remote workspace parent escapes the approved root"); + try { + assertNoSymlinkComponents(root, candidate, true); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + } + return resolve(canonicalParent, basename(candidate)); +} + +function sha256(value: Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} + +function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number { + const selected = value === undefined ? fallback : value; + if (typeof selected !== "number" || !Number.isSafeInteger(selected) || selected < minimum || selected > maximum) { + throw new Error("invalid remote workspace numeric argument"); + } + return selected; +} + +function decodeUtf8(value: Uint8Array): string { + return new TextDecoder("utf-8", { fatal: false }).decode(value); +} + +function assertOpenedRegularFile(root: string, target: string, descriptor: number, maximum: number) { + const opened = fstatSync(descriptor); + const linked = lstatSync(target); + if (opened.isFile() && linked.isFile() && (opened.nlink !== 1 || linked.nlink !== 1)) { + throw new Error("remote workspace hard-linked files are not allowed"); + } + if (!opened.isFile() || !linked.isFile() || linked.isSymbolicLink() + || opened.dev !== linked.dev || opened.ino !== linked.ino + || opened.birthtimeMs !== linked.birthtimeMs) { + throw new Error("remote workspace file identity changed during access"); + } + const canonical = realpathSync(target); + if (!inside(root, canonical)) throw new Error("remote workspace path escapes the approved root"); + if (opened.size > maximum) throw new Error("remote workspace file exceeds the read limit"); + return opened; +} + +function readBoundedRegularFile(root: string, target: string, maximum: number): { body: Buffer; mode: number } { + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + const descriptor = openSync(target, constants.O_RDONLY | noFollow); + try { + const metadata = assertOpenedRegularFile(root, target, descriptor, maximum); + const body = Buffer.alloc(metadata.size); + let offset = 0; + while (offset < body.byteLength) { + const read = readSync(descriptor, body, offset, body.byteLength - offset, null); + if (read === 0) break; + offset += read; + } + assertOpenedRegularFile(root, target, descriptor, maximum); + return { body: offset === body.byteLength ? body : body.subarray(0, offset), mode: metadata.mode & 0o777 }; + } finally { + closeSync(descriptor); + } +} + +function assertStableWritableParent(root: string, target: string): void { + const parent = dirname(target); + assertNoSymlinkComponents(root, parent, true); + const canonical = realpathSync(parent); + if (!inside(root, canonical) || relative(parent, canonical) !== "") { + throw new Error("remote workspace write parent changed during access"); + } +} + +function assertApprovedRootIdentity(root: ApprovedRoot): void { + const linked = lstatSync(root.path); + const canonical = realpathSync(root.path); + if (!linked.isDirectory() || linked.isSymbolicLink() + || linked.dev !== root.dev || linked.ino !== root.ino + || linked.birthtimeMs !== root.birthtimeMs + || relative(root.path, canonical) !== "") { + throw new Error("remote workspace approved root identity changed; pair the folder again"); + } +} + +function assertWritePrecondition(root: string, target: string, expectedSha256: string | null): number { + try { + const current = readBoundedRegularFile(root, target, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES); + if (expectedSha256 === null || sha256(current.body) !== expectedSha256) { + throw new Error("remote workspace file changed before write"); + } + return current.mode; + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + if (expectedSha256 !== null) throw new Error("remote workspace file is missing"); + return 0o600; + } +} + +export class RemoteWorkspaceExecutor { + private readonly roots = new Map(); + private readonly maxOutputBytes: number; + private operationTail: Promise = Promise.resolve(); + + constructor(private readonly options: RemoteWorkspaceExecutorOptions) { + if (!options.deviceId || options.deviceId.length > 256) throw new Error("invalid remote workspace executor device ID"); + this.maxOutputBytes = options.maxOutputBytes ?? REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES; + if (!Number.isSafeInteger(this.maxOutputBytes) || this.maxOutputBytes < 1024) { + throw new Error("invalid remote workspace output limit"); + } + for (const root of options.roots) { + if (!root.id || root.id.length > 128 || this.roots.has(root.id)) throw new Error("invalid remote workspace root ID"); + const metadata = lstatSync(root.path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory"); + const canonical = realpathSync(root.path); + const identity = lstatSync(canonical); + this.roots.set(root.id, { + id: root.id, + path: canonical, + dev: identity.dev, + ino: identity.ino, + birthtimeMs: identity.birthtimeMs, + }); + } + if (this.roots.size === 0) throw new Error("remote workspace executor needs one approved root"); + } + + hasApprovedRoot(rootId: string): boolean { + return this.roots.has(rootId); + } + + async invoke(request: RemoteWorkspaceExecutionRequest, signal?: AbortSignal): Promise { + if (request.executorDeviceId !== this.options.deviceId) { + return { ok: false, error: "remote workspace executor identity mismatch" }; + } + const root = this.roots.get(request.rootId); + if (!root) return { ok: false, error: "remote workspace root is not approved" }; + if (!request.requestId || !request.sessionId) return { ok: false, error: "invalid remote workspace request identity" }; + const previous = this.operationTail; + let release!: () => void; + this.operationTail = new Promise(resolvePromise => { release = resolvePromise; }); + await previous; + try { + if (signal?.aborted) throw new Error("remote workspace operation was cancelled"); + assertApprovedRootIdentity(root); + switch (request.tool) { + case "list_directory": return { ok: true, value: this.listDirectory(root, request.arguments) }; + case "read_file": return { ok: true, value: this.readFile(root, request.arguments) }; + case "write_file": return { ok: true, value: this.writeFile(root, request.arguments) }; + case "exec": return { ok: true, value: await this.exec(root, request.arguments, signal) }; + } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "remote workspace operation failed" }; + } finally { + release(); + } + } + + private listDirectory(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path"]); + const target = resolveExisting(root.path, args.path ?? ".", this.options.platform); + if (!statSync(target).isDirectory()) throw new Error("remote workspace list target is not a directory"); + const directory = opendirSync(target); + const entries: Array<{ name: string; type: "directory" | "file" | "symlink" | "other" }> = []; + try { + while (true) { + const entry = directory.readSync(); + if (!entry) break; + if (entries.length >= 4096) throw new Error("remote workspace directory has too many entries"); + entries.push({ + name: entry.name, + type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other", + }); + } + } finally { + directory.closeSync(); + } + return { + path: relative(root.path, target) || ".", + entries, + }; + } + + private readFile(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path", "maxBytes"]); + const target = resolveExisting(root.path, args.path, this.options.platform); + const maxBytes = boundedInteger(args.maxBytes, REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, 1, this.maxOutputBytes); + const { body } = readBoundedRegularFile(root.path, target, maxBytes); + return { path: relative(root.path, target), content: decodeUtf8(body), sha256: sha256(body), bytes: body.byteLength }; + } + + private writeFile(root: ApprovedRoot, input: unknown): unknown { + const args = objectArguments(input); + noExtraKeys(args, ["path", "content", "expectedSha256"]); + if (typeof args.content !== "string") throw new Error("remote workspace file content must be text"); + const body = Buffer.from(args.content, "utf8"); + if (body.byteLength > REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES) throw new Error("remote workspace file exceeds the write limit"); + const expectedSha256 = args.expectedSha256; + if (expectedSha256 !== null && (typeof expectedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(expectedSha256))) { + throw new Error("invalid remote workspace expected file hash"); + } + const target = resolveWritable(root.path, args.path, this.options.platform); + const mode = assertWritePrecondition(root.path, target, expectedSha256); + const temporary = resolve(dirname(target), `.${randomUUID()}.ocx-remote-write`); + try { + writeFileSync(temporary, body, { flag: "wx", mode }); + assertStableWritableParent(root.path, target); + assertWritePrecondition(root.path, target, expectedSha256); + renameAtomicFile(temporary, target, undefined, "remote-workspace"); + } finally { + try { unlinkSync(temporary); } catch { /* committed or already absent */ } + } + return { path: relative(root.path, target), sha256: sha256(body), bytes: body.byteLength }; + } + + private async exec(root: ApprovedRoot, input: unknown, signal?: AbortSignal): Promise { + if (!this.options.commandRunner) { + throw new Error("remote workspace command runner is disabled until an OS sandbox is configured"); + } + const args = objectArguments(input); + noExtraKeys(args, ["command", "cwd", "timeoutMs"]); + if (!Array.isArray(args.command) || args.command.length < 1 || args.command.length > 64) { + throw new Error("invalid remote workspace command vector"); + } + const command: string[] = []; + for (const value of args.command) { + if (typeof value !== "string" || value.length < 1 || value.length > 4096 || value.includes("\0")) { + throw new Error("invalid remote workspace command vector"); + } + command.push(value); + } + if (command.reduce((total, value) => total + value.length, 0) > 16 * 1024) { + throw new Error("remote workspace command vector is too large"); + } + const cwd = resolveExisting(root.path, args.cwd ?? ".", this.options.platform); + if (!statSync(cwd).isDirectory()) throw new Error("remote workspace command cwd is not a directory"); + const timeoutMs = boundedInteger(args.timeoutMs, 30_000, 1, 60_000); + const result = await this.options.commandRunner.run({ + command, + root: root.path, + cwd, + timeoutMs, + maxOutputBytes: this.maxOutputBytes, + signal, + }); + const outputBytes = Buffer.byteLength(result.stdout, "utf8") + Buffer.byteLength(result.stderr, "utf8"); + if (outputBytes > this.maxOutputBytes) { + throw new Error("remote workspace command runner exceeded its output contract"); + } + return { cwd: relative(root.path, cwd) || ".", ...result }; + } +} diff --git a/src/remote-control/workspace-hub.ts b/src/remote-control/workspace-hub.ts new file mode 100644 index 0000000000..b1c08e4c3b --- /dev/null +++ b/src/remote-control/workspace-hub.ts @@ -0,0 +1,519 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + randomBytes, + randomUUID, + sign, + timingSafeEqual, + verify, +} from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteControlIdentityKeyPair, +} from "./crypto"; +import type { RemoteWorkspaceHubAgentConnection } from "./workspace-agent-connection"; +import { + parseRemoteWorkspaceCapabilities, + type RemoteWorkspaceCapability, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_HUB_STATE_VERSION = 1 as const; +export const REMOTE_WORKSPACE_MAX_DEVICES = 32; +export const REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE = 32; +const PAIRING_LIFETIME_MS = 10 * 60_000; +const MAX_PAIRING_GRANTS = 16; +const MAX_HUB_STATE_BYTES = 1024 * 1024; +const TOKEN_PREFIX = "ocxrw_"; +const PAIRING_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; +const PAIRING_SOURCE_WINDOW_MS = 10 * 60_000; +const PAIRING_SOURCE_FAILURE_LIMIT = 10; +const PAIRING_SOURCE_LIMIT = 1_024; + +export interface RemoteWorkspaceRootAdvertisement { + id: string; + label: string; +} + +export interface RemoteWorkspaceStoredDevice { + id: string; + name: string; + platform: string; + publicKey: string; + tokenHash: string; + capabilities: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; + createdAt: string; + lastSeenAt: string | null; +} + +export interface RemoteWorkspaceHubState { + version: typeof REMOTE_WORKSPACE_HUB_STATE_VERSION; + identity: RemoteControlIdentityKeyPair; + devices: RemoteWorkspaceStoredDevice[]; +} + +export interface RemoteWorkspaceHubStateStore { + load(): RemoteWorkspaceHubState | null; + save(state: RemoteWorkspaceHubState): void; +} + +export interface RemoteWorkspacePublicDevice { + id: string; + name: string; + platform: string; + capabilities: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; + online: boolean; + createdAt: string; + lastSeenAt: string | null; +} + +export interface RemoteWorkspacePairingGrant { + code: string; + expiresAt: string; +} + +export interface RemoteWorkspacePairDeviceInput { + code: string; + name: string; + platform: string; + publicKey: string; + capabilities?: RemoteWorkspaceCapability[]; + roots: RemoteWorkspaceRootAdvertisement[]; +} + +export interface RemoteWorkspacePairDeviceResult { + device: RemoteWorkspacePublicDevice; + deviceToken: string; + hubPublicKey: string; +} + +interface PendingPairingGrant { + hash: Buffer; + expiresAt: number; +} + +interface PairingSourceFailureRecord { + failures: number; + windowStartedAt: number; +} + +export class RemoteWorkspacePairingRateLimitError extends Error { + constructor( + readonly retryAfterSeconds: number, + readonly reason: "source" | "capacity", + ) { + super("remote workspace pairing rate limit exceeded"); + this.name = "RemoteWorkspacePairingRateLimitError"; + } +} + +function sha256(value: string): Buffer { + return createHash("sha256").update(value, "utf8").digest(); +} + +function encodeHash(value: Buffer): string { + return value.toString("base64url"); +} + +function parseHash(value: unknown): Buffer { + if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) throw new Error("invalid remote workspace token hash"); + const decoded = Buffer.from(value, "base64url"); + if (decoded.byteLength !== 32) throw new Error("invalid remote workspace token hash"); + return decoded; +} + +function normalizeCode(value: string): string { + return value.replace(/[\s-]/g, "").toUpperCase(); +} + +function newPairingCode(): string { + const bytes = randomBytes(12); + let code = ""; + for (let index = 0; index < bytes.length; index += 1) { + code += PAIRING_ALPHABET[bytes[index]! % PAIRING_ALPHABET.length]; + } + return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8)}`; +} + +function boundedText(value: unknown, label: string, max: number): string { + if (typeof value !== "string") throw new Error(`invalid remote workspace ${label}`); + const normalized = value.trim(); + if (normalized.length < 1 || normalized.length > max || /[\x00-\x1f\x7f]/.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function objectRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("invalid remote workspace device metadata"); + } + return value as Record; +} + +function exactPairingFields(value: Record): void { + const required = ["code", "name", "platform", "publicKey", "roots"] as const; + const allowed = new Set([...required, "capabilities"]); + if (required.some(key => !Object.hasOwn(value, key)) + || Object.keys(value).some(key => !allowed.has(key))) { + throw new Error("invalid remote workspace device metadata"); + } +} + +function validUuid(value: unknown, label: string): string { + const normalized = boundedText(value, label, 64); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalized)) { + throw new Error(`invalid remote workspace ${label}`); + } + return normalized; +} + +function validatePublicKey(value: unknown): string { + const encoded = boundedText(value, "device public key", 1024); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error("invalid remote workspace device public key"); + const key = createPublicKey({ key: Buffer.from(encoded, "base64url"), type: "spki", format: "der" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error("remote workspace device key must use Ed25519"); + return encoded; +} + +function validateIdentity(value: unknown): RemoteControlIdentityKeyPair { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace hub identity"); + const raw = value as Record; + const publicKey = validatePublicKey(raw.publicKey); + const privateKey = boundedText(raw.privateKey, "hub private key", 2048); + const privateDer = Buffer.from(privateKey, "base64url"); + const parsed = createPrivateKey({ key: privateDer, type: "pkcs8", format: "der" }); + if (parsed.asymmetricKeyType !== "ed25519") throw new Error("remote workspace hub key must use Ed25519"); + const challenge = Buffer.from("opencodex remote workspace hub identity v1", "utf8"); + const signature = sign(null, challenge, parsed); + const verifier = createPublicKey({ key: Buffer.from(publicKey, "base64url"), type: "spki", format: "der" }); + if (!verify(null, challenge, verifier, signature)) { + throw new Error("remote workspace hub identity key pair does not match"); + } + return { publicKey, privateKey }; +} + +function validateRoots(value: unknown): RemoteWorkspaceRootAdvertisement[] { + if (!Array.isArray(value) || value.length < 1 || value.length > REMOTE_WORKSPACE_MAX_ROOTS_PER_DEVICE) { + throw new Error("remote workspace device needs one to 32 roots"); + } + const ids = new Set(); + const labels = new Set(); + return value.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace root"); + const raw = item as Record; + const id = validUuid(raw.id, "root ID"); + const label = boundedText(raw.label, "root label", 80); + const folded = label.toLocaleLowerCase("en-US"); + if (ids.has(id) || labels.has(folded)) throw new Error("duplicate remote workspace root"); + ids.add(id); + labels.add(folded); + return { id, label }; + }); +} + +function validateDate(value: unknown, nullable = false): string | null { + if (nullable && value === null) return null; + if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new Error("invalid remote workspace timestamp"); + return value; +} + +export function parseRemoteWorkspaceHubState(value: unknown): RemoteWorkspaceHubState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace hub state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_HUB_STATE_VERSION || !Array.isArray(raw.devices)) { + throw new Error("unsupported remote workspace hub state"); + } + if (raw.devices.length > REMOTE_WORKSPACE_MAX_DEVICES) throw new Error("remote workspace device limit exceeded"); + const ids = new Set(); + const names = new Set(); + const devices = raw.devices.map(item => { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace device state"); + const device = item as Record; + const id = validUuid(device.id, "device ID"); + const name = boundedText(device.name, "device name", 80); + const folded = name.toLocaleLowerCase("en-US"); + if (ids.has(id) || names.has(folded)) throw new Error("duplicate remote workspace device identity"); + ids.add(id); + names.add(folded); + if (typeof device.tokenHash !== "string") throw new Error("invalid remote workspace token hash"); + parseHash(device.tokenHash); + const tokenHash = device.tokenHash; + return { + id, + name, + platform: boundedText(device.platform, "device platform", 80), + publicKey: validatePublicKey(device.publicKey), + tokenHash, + capabilities: parseRemoteWorkspaceCapabilities(device.capabilities), + roots: validateRoots(device.roots), + createdAt: validateDate(device.createdAt)!, + lastSeenAt: validateDate(device.lastSeenAt, true), + }; + }); + return { + version: REMOTE_WORKSPACE_HUB_STATE_VERSION, + identity: validateIdentity(raw.identity), + devices, + }; +} + +export class RemoteWorkspaceHubFileStore implements RemoteWorkspaceHubStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-hub.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceHubState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_HUB_STATE_BYTES) { + throw new Error("remote workspace hub state is too large"); + } + return parseRemoteWorkspaceHubState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceHubState): void { + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, `${JSON.stringify(parseRemoteWorkspaceHubState(state), null, 2)}\n`); + } +} + +export class RemoteWorkspaceHub { + private state: RemoteWorkspaceHubState; + private readonly grants = new Map(); + private readonly pairingSourceFailures = new Map(); + private readonly connections = new Map(); + + constructor( + private readonly store: RemoteWorkspaceHubStateStore, + private readonly now: () => number = Date.now, + ) { + const loaded = store.load(); + this.state = loaded ?? { + version: REMOTE_WORKSPACE_HUB_STATE_VERSION, + identity: generateRemoteControlIdentityKeyPair(), + devices: [], + }; + if (loaded === null) this.store.save(this.state); + } + + identity(): RemoteControlIdentityKeyPair { + return { ...this.state.identity }; + } + + createPairingGrant(): RemoteWorkspacePairingGrant { + this.pruneGrants(); + if (this.grants.size >= MAX_PAIRING_GRANTS) throw new Error("remote workspace pairing capacity reached"); + let code: string; + let digest: Buffer; + do { + code = newPairingCode(); + digest = sha256(normalizeCode(code)); + } while (this.grants.has(encodeHash(digest))); + const expiresAt = this.now() + PAIRING_LIFETIME_MS; + this.grants.set(encodeHash(digest), { hash: digest, expiresAt }); + return { code, expiresAt: new Date(expiresAt).toISOString() }; + } + + private pairingSourceKey(source: string): string { + return encodeHash(sha256(`remote-workspace-pairing-source\0${source}`)); + } + + private prunePairingSourceFailures(now: number): void { + // Records never extend their original fixed window, so insertion order is expiry order. Stop + // at the first live entry instead of making every unauthenticated request scan the full cap. + for (const [key, record] of this.pairingSourceFailures) { + if (record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS > now) break; + this.pairingSourceFailures.delete(key); + } + } + + private pairingSourceRecord(source: string, now: number): [string, PairingSourceFailureRecord | undefined] { + this.prunePairingSourceFailures(now); + const key = this.pairingSourceKey(source); + return [key, this.pairingSourceFailures.get(key)]; + } + + private admitPairingSource(source: string, now: number): string { + const [key, record] = this.pairingSourceRecord(source, now); + if (record && record.failures >= PAIRING_SOURCE_FAILURE_LIMIT) { + const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now); + throw new RemoteWorkspacePairingRateLimitError(Math.ceil(remaining / 1000), "source"); + } + return key; + } + + assertPairingSourceAllowed(source = "anonymous"): void { + this.admitPairingSource(source, this.now()); + } + + private recordPairingSourceFailure(key: string, now: number): void { + let record = this.pairingSourceFailures.get(key); + if (!record) { + if (this.pairingSourceFailures.size >= PAIRING_SOURCE_LIMIT) { + throw new RemoteWorkspacePairingRateLimitError(1, "capacity"); + } + record = { failures: 0, windowStartedAt: now }; + this.pairingSourceFailures.set(key, record); + } + record.failures += 1; + if (record.failures >= PAIRING_SOURCE_FAILURE_LIMIT) { + const remaining = Math.max(1, record.windowStartedAt + PAIRING_SOURCE_WINDOW_MS - now); + throw new RemoteWorkspacePairingRateLimitError(Math.ceil(remaining / 1000), "source"); + } + } + + pairDevice(input: unknown, source = "anonymous"): RemoteWorkspacePairDeviceResult { + this.pruneGrants(); + const nowMs = this.now(); + const sourceKey = this.admitPairingSource(source, nowMs); + const raw = objectRecord(input); + const normalizedCode = normalizeCode(typeof raw.code === "string" ? raw.code : ""); + if (normalizedCode.length !== 12 || ![...normalizedCode].every(character => PAIRING_ALPHABET.includes(character))) { + this.recordPairingSourceFailure(sourceKey, nowMs); + throw new Error("invalid or expired remote workspace pairing code"); + } + const digest = sha256(normalizedCode); + const key = encodeHash(digest); + const grant = this.grants.get(key); + if (!grant || grant.expiresAt <= nowMs || !timingSafeEqual(grant.hash, digest)) { + this.recordPairingSourceFailure(sourceKey, nowMs); + throw new Error("invalid or expired remote workspace pairing code"); + } + this.pairingSourceFailures.delete(sourceKey); + // A valid grant is one-shot even when the submitted device metadata is rejected. Keeping it + // alive after a conflict would let the same copied secret authorize repeated enrollment tries. + this.grants.delete(key); + exactPairingFields(raw); + if (this.state.devices.length >= REMOTE_WORKSPACE_MAX_DEVICES) throw new Error("remote workspace device limit reached"); + const name = boundedText(raw.name, "device name", 80); + const folded = name.toLocaleLowerCase("en-US"); + if (this.state.devices.some(device => device.name.toLocaleLowerCase("en-US") === folded)) { + throw new Error("remote workspace device name is already in use"); + } + const now = new Date(nowMs).toISOString(); + const token = `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`; + const device: RemoteWorkspaceStoredDevice = { + id: randomUUID(), + name, + platform: boundedText(raw.platform, "device platform", 80), + publicKey: validatePublicKey(raw.publicKey), + tokenHash: encodeHash(sha256(token)), + capabilities: parseRemoteWorkspaceCapabilities(raw.capabilities), + roots: validateRoots(raw.roots), + createdAt: now, + lastSeenAt: null, + }; + this.state = { ...this.state, devices: [...this.state.devices, device] }; + this.store.save(this.state); + return { + device: this.publicDevice(device), + deviceToken: token, + hubPublicKey: this.state.identity.publicKey, + }; + } + + authenticateDeviceToken(token: string): RemoteWorkspaceStoredDevice | null { + if (!token.startsWith(TOKEN_PREFIX) || token.length !== TOKEN_PREFIX.length + 43) return null; + const digest = sha256(token); + for (const device of this.state.devices) { + const stored = parseHash(device.tokenHash); + if (timingSafeEqual(stored, digest)) { + return { ...device, capabilities: [...device.capabilities], roots: device.roots.map(root => ({ ...root })) }; + } + } + return null; + } + + attachConnection(deviceId: string, connection: RemoteWorkspaceHubAgentConnection): void { + const index = this.state.devices.findIndex(device => device.id === deviceId); + if (index < 0) throw new Error("unknown remote workspace device"); + const previous = this.connections.get(deviceId); + if (previous && previous !== connection) previous.close("remote workspace executor reconnected"); + this.connections.set(deviceId, connection); + const seen = new Date(this.now()).toISOString(); + this.state = { + ...this.state, + devices: this.state.devices.map((device, deviceIndex) => ( + deviceIndex === index ? { ...device, lastSeenAt: seen } : device + )), + }; + this.store.save(this.state); + } + + updateDeviceCapabilities(deviceId: string, capabilities: readonly RemoteWorkspaceCapability[]): void { + const device = this.state.devices.find(candidate => candidate.id === deviceId); + if (!device) throw new Error("unknown remote workspace device"); + const normalized = parseRemoteWorkspaceCapabilities(capabilities); + if (normalized.some(capability => !device.capabilities.includes(capability))) { + throw new Error("remote workspace presence exceeds enrollment grant"); + } + // Connection availability is transient; the persisted enrollment grant is unchanged. + } + + detachConnection(deviceId: string, connection: RemoteWorkspaceHubAgentConnection): void { + if (this.connections.get(deviceId) !== connection) return; + this.connections.delete(deviceId); + connection.close(); + } + + connection(deviceId: string): RemoteWorkspaceHubAgentConnection | null { + const connection = this.connections.get(deviceId); + return connection?.isOnline() ? connection : null; + } + + listDevices(): RemoteWorkspacePublicDevice[] { + return this.state.devices.map(device => this.publicDevice(device)); + } + + revokeDevice(deviceId: string): boolean { + const before = this.state.devices.length; + this.state = { ...this.state, devices: this.state.devices.filter(device => device.id !== deviceId) }; + if (this.state.devices.length === before) return false; + const connection = this.connections.get(deviceId); + this.connections.delete(deviceId); + connection?.close("remote workspace device was revoked"); + this.store.save(this.state); + return true; + } + + closeAllConnections(reason = "remote workspace hub stopped"): void { + const connections = [...this.connections.values()]; + this.connections.clear(); + for (const connection of connections) connection.close(reason); + } + + private publicDevice(device: RemoteWorkspaceStoredDevice): RemoteWorkspacePublicDevice { + return { + id: device.id, + name: device.name, + platform: device.platform, + capabilities: device.capabilities.filter(capability => { + const connection = this.connections.get(device.id); + return !connection || connection.capabilities().includes(capability); + }), + roots: device.roots.map(root => ({ ...root })), + online: this.connections.get(device.id)?.isOnline() ?? false, + createdAt: device.createdAt, + lastSeenAt: device.lastSeenAt, + }; + } + + private pruneGrants(): void { + const now = this.now(); + for (const [key, grant] of this.grants) { + if (grant.expiresAt <= now) this.grants.delete(key); + } + } +} diff --git a/src/remote-control/workspace-pi-runtime.ts b/src/remote-control/workspace-pi-runtime.ts new file mode 100644 index 0000000000..a5987a5fe1 --- /dev/null +++ b/src/remote-control/workspace-pi-runtime.ts @@ -0,0 +1,382 @@ +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { findExecutableOnPath } from "./workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + removeRemoteWorkspaceIsolation, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + waitForRemoteWorkspaceProcessExit, +} from "./workspace-process"; +import { startRemoteWorkspaceToolBridge } from "./workspace-tool-bridge"; +import { REMOTE_WORKSPACE_DYNAMIC_TOOLS } from "./workspace-tools"; +import type { + RemoteWorkspaceRuntimeFactory, + RemoteWorkspaceRuntimeHandle, +} from "./workspace-sessions"; + +const MAX_JSON_LINE_BYTES = 2 * 1024 * 1024; + +interface PendingResponse { + resolve(value: Record): void; + reject(error: Error): void; + timer: ReturnType; +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function safeError(value: unknown, fallback: string): string { + return (value instanceof Error ? value.message : typeof value === "string" ? value : fallback) + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function messageText(value: unknown): string | null { + const message = record(value); + if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return null; + const text = message.content.flatMap(raw => { + const part = record(raw); + return part?.type === "text" && typeof part.text === "string" ? [part.text] : []; + }).join(""); + return text || null; +} + +function remotePiInstructions(deviceName: string, tools: readonly string[]): string { + const name = deviceName.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120) || "remote executor"; + const remoteTools = tools.map(tool => `remote_${tool}`).join(", "); + return [ + `You operate only on the OpenCodex remote executor named ${JSON.stringify(name)}.`, + `Use only these tools for filesystem and command work: ${remoteTools}.`, + "The Hub working directory is an empty isolation boundary, not the user's project.", + "If a remote tool fails or the executor is offline, stop and report it. Never substitute local operations.", + ].join(" "); +} + +function extensionSource(tools: readonly string[]): string { + const allowed = new Set(tools); + const definitions = REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.filter(tool => allowed.has(tool.name)).map(tool => ({ + remoteName: `remote_${tool.name}`, + tool: tool.name, + description: tool.description, + parameters: tool.inputSchema, + })); + return `const definitions = ${JSON.stringify(definitions)}; +const endpoint = process.env.OCX_REMOTE_WORKSPACE_BRIDGE_URL; +const token = process.env.OCX_REMOTE_WORKSPACE_BRIDGE_TOKEN; + +export default function registerRemoteWorkspace(pi) { + if (!endpoint || !token) throw new Error("Remote Workspace bridge is unavailable"); + for (const definition of definitions) { + pi.registerTool({ + name: definition.remoteName, + label: definition.remoteName, + description: definition.description, + parameters: definition.parameters, + async execute(_toolCallId, parameters, signal) { + const response = await fetch(endpoint + "/invoke", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer " + token }, + body: JSON.stringify({ tool: definition.tool, arguments: parameters }), + signal, + }); + const result = await response.json(); + if (!response.ok || !result || result.success !== true) { + throw new Error(result && typeof result.text === "string" ? result.text : "Remote Workspace tool failed"); + } + return { content: [{ type: "text", text: result.text }], details: { remote: true } }; + }, + }); + } +} +`; +} + +class PiRpcProcess { + private readonly pending = new Map(); + private nextId = 0; + private closed = false; + private activeSettle: { resolve(): void; reject(error: Error): void } | null = null; + + onEvent: ((event: Record) => void) | null = null; + + constructor(private readonly child: Bun.Subprocess<"pipe", "pipe", "pipe">) { + void this.read(); + void this.drainStderr(); + void child.exited.then(code => this.fail(new Error(`Pi RPC exited with code ${code}`))); + } + + async command(type: string, fields: Record = {}, timeoutMs = 15_000): Promise> { + if (this.closed) throw new Error("Pi RPC is closed"); + const id = `ocx-${++this.nextId}`; + const result = new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${type} timed out`)); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + }); + try { + this.send({ id, type, ...fields }); + } catch (error) { + const pending = this.pending.get(id); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(id); + pending.reject(error instanceof Error ? error : new Error("Pi RPC write failed")); + } + } + return result; + } + + async prompt(message: string): Promise { + if (this.activeSettle) throw new Error("Pi Remote Workspace turn is already active"); + const settled = new Promise((resolve, reject) => { this.activeSettle = { resolve, reject }; }); + try { + const accepted = await this.command("prompt", { message }); + if (accepted.success !== true) throw new Error(safeError(accepted.error, "Pi rejected the prompt")); + await settled; + } catch (error) { + this.activeSettle = null; + throw error; + } + } + + async abort(): Promise { + if (!this.activeSettle) return; + await this.command("abort", {}, 3_000).catch(() => {}); + } + + async close(): Promise { + try { + if (!this.closed) { + try { this.child.stdin.end(); } catch { /* already closed */ } + } + const graceful = await waitForRemoteWorkspaceProcessExit(this.child, 1_500); + if (!graceful) { + await stopRemoteWorkspaceProcess(this.child); + } + } finally { + // Active and pending RPC waiters cannot survive a failed process teardown. + this.fail(new Error("Pi Remote Workspace session closed")); + } + } + + private send(value: Record): void { + const line = `${JSON.stringify(value)}\n`; + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Pi RPC message is too large"); + this.child.stdin.write(line); + this.child.stdin.flush(); + } + + private async read(): Promise { + const reader = this.child.stdout.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + if (Buffer.byteLength(buffer, "utf8") > MAX_JSON_LINE_BYTES && !buffer.includes("\n")) { + throw new Error("Pi RPC output line is too large"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).replace(/\r$/, ""); + buffer = buffer.slice(newline + 1); + if (Buffer.byteLength(line, "utf8") > MAX_JSON_LINE_BYTES) throw new Error("Pi RPC output line is too large"); + if (line) { + const event = record(JSON.parse(line)); + if (!event) throw new Error("invalid Pi RPC event"); + this.receive(event); + } + newline = buffer.indexOf("\n"); + } + } + } catch (error) { + void stopRemoteWorkspaceProcess(this.child).catch(() => {}); + this.fail(new Error(safeError(error, "Pi RPC output failed"))); + } finally { + reader.releaseLock(); + } + } + + private async drainStderr(): Promise { + const reader = this.child.stderr.getReader(); + try { while (!(await reader.read()).done) { /* drain without retaining secrets */ } } + catch { /* stdout/exit code owns the failure */ } + finally { reader.releaseLock(); } + } + + private receive(event: Record): void { + if (event.type === "response" && typeof event.id === "string") { + const pending = this.pending.get(event.id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(event.id); + pending.resolve(event); + return; + } + if (event.type === "agent_settled") { + const active = this.activeSettle; + this.activeSettle = null; + active?.resolve(); + } + if (event.type === "extension_error") { + const active = this.activeSettle; + this.activeSettle = null; + active?.reject(new Error(safeError(event.error, "Pi Remote Workspace extension failed"))); + } + this.onEvent?.(event); + } + + private fail(error: Error): void { + if (this.closed) return; + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + const active = this.activeSettle; + this.activeSettle = null; + active?.reject(error); + } +} + +export interface PiRemoteWorkspaceRuntimeOptions { + command?: readonly string[]; + env?: Record; + version?: string; +} + +export class PiRemoteWorkspaceRuntimeFactory implements RemoteWorkspaceRuntimeFactory { + readonly profile = "pi" as const; + + constructor(private readonly options: PiRemoteWorkspaceRuntimeOptions = {}) {} + + async available(): Promise<{ available: boolean; version?: string; reason?: string }> { + const command = this.options.command && this.options.command.length > 0 + ? this.options.command[0] + : findExecutableOnPath("pi"); + return command + ? { available: true, ...(this.options.version ? { version: this.options.version } : {}) } + : { available: false, reason: "Pi is not installed on this Hub." }; + } + + async start(options: Parameters[0]): Promise { + const configuredCommand = this.options.command && this.options.command.length > 0 + ? [...this.options.command] + : null; + const executable = configuredCommand?.[0] ?? findExecutableOnPath("pi"); + if (!executable) throw new Error("Pi is not installed on this Hub"); + const commandPrefix = configuredCommand ?? [executable]; + const isolation = mkdtempSync(join(tmpdir(), "ocx-remote-pi-")); + try { + chmodSync(isolation, 0o700); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const extensionPath = join(isolation, "remote-workspace-extension.js"); + try { + writeFileSync(extensionPath, extensionSource(options.tools), { mode: 0o600 }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const threadId = options.resumeThreadId ?? randomUUID(); + const bridge = (() => { + try { + return startRemoteWorkspaceToolBridge({ + coordinator: options.coordinator, + threadId, + tools: options.tools, + onTool: tool => options.emit("tool", `Running ${tool} on ${options.deviceName}/${options.rootLabel}`), + }); + } catch (error) { + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + })(); + const childEnv = { + ...process.env, + ...this.options.env, + OCX_REMOTE_WORKSPACE_BRIDGE_URL: bridge.url, + OCX_REMOTE_WORKSPACE_BRIDGE_TOKEN: bridge.token, + }; + const invocation = remoteWorkspaceProcessInvocation([ + ...commandPrefix, + "--mode", "rpc", + "--session-id", threadId, + "--name", `OCX Remote: ${options.deviceName}`, + "--no-builtin-tools", + "--no-extensions", + "--no-skills", + "--no-prompt-templates", + "--no-themes", + "--no-context-files", + "--no-approve", + "--extension", extensionPath, + "--tools", options.tools.map(tool => `remote_${tool}`).join(","), + "--system-prompt", remotePiInstructions(options.deviceName, options.tools), + ], { env: childEnv }); + let child: Bun.Subprocess<"pipe", "pipe", "pipe">; + try { + child = Bun.spawn([invocation.file, ...invocation.args], { + cwd: isolation, + env: childEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + }); + } catch (error) { + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + const rpc = new PiRpcProcess(child); + rpc.onEvent = event => { + if (event.type === "message_end") { + const text = messageText(event.message); + if (text) options.emit("assistant", text); + } + if (event.type === "tool_execution_start" && typeof event.toolName === "string") { + options.emit("tool", `Pi requested ${event.toolName}`); + } + }; + try { + const state = await rpc.command("get_state"); + if (state.success !== true) throw new Error(safeError(state.error, "Pi RPC failed to initialize")); + } catch (error) { + await rpc.close().catch(() => {}); + await bridge.stop(); + removeRemoteWorkspaceIsolation(isolation); + throw error; + } + let stopped = false; + let stopOperation: Promise | null = null; + return { + threadId, + prompt: text => rpc.prompt(text), + stop(): Promise { + if (stopOperation) return stopOperation; + stopped = true; + stopOperation = runRemoteWorkspaceCleanupSteps([ + () => rpc.abort(), + () => rpc.close(), + () => bridge.stop(), + () => removeRemoteWorkspaceIsolation(isolation), + ]); + return stopOperation; + }, + }; + } +} diff --git a/src/remote-control/workspace-process.ts b/src/remote-control/workspace-process.ts new file mode 100644 index 0000000000..cc177e5ebc --- /dev/null +++ b/src/remote-control/workspace-process.ts @@ -0,0 +1,129 @@ +import { execFileSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { commandInvocation, type SpawnInvocation } from "../lib/win-exec"; +import { resolveTrustedWindowsTaskkillExe } from "../lib/windows-elevation"; + +export interface RemoteWorkspaceProcessInvocationOptions { + platform?: NodeJS.Platform; + env?: Record; +} + +/** + * Preserve argv boundaries on Unix and route Windows npm `.cmd`/`.bat` shims through the + * repository's audited ComSpec escaping. `shell: true` is deliberately never used. + */ +export function remoteWorkspaceProcessInvocation( + command: readonly string[], + options: RemoteWorkspaceProcessInvocationOptions = {}, +): SpawnInvocation { + if (command.length < 1 || !command[0]) throw new Error("remote workspace process command is empty"); + return commandInvocation( + command[0], + command.slice(1), + options.platform ?? process.platform, + { env: options.env ?? process.env }, + ); +} + +export interface RemoteWorkspaceOwnedProcess { + pid: number; + exitCode: number | null; + exited: Promise; + kill(signal?: number | NodeJS.Signals): void; +} + +export interface StopRemoteWorkspaceProcessOptions { + platform?: NodeJS.Platform; + taskkillPath?: string; + execFile?: (file: string, args: readonly string[]) => void; + waitMs?: number; +} + +export async function waitForRemoteWorkspaceProcessExit( + child: RemoteWorkspaceOwnedProcess, + waitMs: number, +): Promise { + if (!Number.isSafeInteger(waitMs) || waitMs < 1) throw new Error("invalid remote workspace process wait"); + let timer: ReturnType | null = null; + try { + return await Promise.race([ + child.exited.then(() => true, () => true), + new Promise(resolve => { timer = setTimeout(() => resolve(false), waitMs); }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** Run every owned-resource cleanup step and report the first failure only after all were attempted. */ +export async function runRemoteWorkspaceCleanupSteps( + steps: readonly (() => void | Promise)[], +): Promise { + let failed = false; + let firstFailure: unknown; + for (const step of steps) { + try { + await step(); + } catch (error) { + if (!failed) firstFailure = error; + failed = true; + } + } + if (failed) { + throw firstFailure instanceof Error + ? firstFailure + : new Error("remote workspace cleanup failed"); + } +} + +/** Stop only the process OCX spawned; Windows must include its `.cmd` descendant tree. */ +export async function stopRemoteWorkspaceProcess( + child: RemoteWorkspaceOwnedProcess, + options: StopRemoteWorkspaceProcessOptions = {}, +): Promise { + if (child.exitCode !== null) return; + const platform = options.platform ?? process.platform; + if (platform === "win32") { + const exec = options.execFile ?? ((file: string, args: readonly string[]) => { + execFileSync(file, [...args], { stdio: "ignore", timeout: 5_000, windowsHide: true }); + }); + try { + exec(options.taskkillPath ?? resolveTrustedWindowsTaskkillExe(), ["/PID", String(child.pid), "/T", "/F"]); + } catch { + try { child.kill(); } catch { /* child already exited */ } + } + if (!await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500)) { + throw new Error("remote workspace Windows process tree did not exit"); + } + } else { + try { child.kill("SIGTERM"); } catch { /* child already exited */ } + const exited = await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500); + if (!exited) { + try { child.kill("SIGKILL"); } catch { /* child already exited */ } + if (!await waitForRemoteWorkspaceProcessExit(child, options.waitMs ?? 1_500)) { + throw new Error("remote workspace process did not exit after SIGKILL"); + } + } + } +} + +/** Windows AV/indexers can retain just-exited CLI files briefly; use Node's bounded retry. */ +export function removeRemoteWorkspaceIsolation(path: string): void { + rmSync(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); +} + +/** + * [Decision Log] + * - 목적과 의도: Make Hub-owned Codex, Claude Code, and Pi processes start and stop identically + * across Linux, macOS, and Windows without leaving npm-shim descendants behind. + * - 기존 구현 및 제약 조건: Unix can spawn executable scripts directly. Windows npm exposes + * `.cmd` files that Bun cannot safely launch shell-less, and killing cmd.exe alone can orphan Node. + * - 검토한 주요 대안: `shell: true`, three runtime-specific wrappers, direct `.cmd` spawn, or the + * repository's existing escaped ComSpec invocation plus trusted System32 taskkill. + * - 선택한 방식: Share one launcher and one owned-process stop helper across all three runtimes. + * - 다른 대안 대신 이 방식을 선택한 이유: It preserves exact argv boundaries, avoids a PATH- + * resolved shell/taskkill hijack, and matches already-tested OpenCodex Windows behavior. + * - 장점, 단점 및 영향: Windows npm installs work and stop cleanly. Windows stop is necessarily + * forceful because its normal process kill is already forceful; Unix gets a graceful SIGTERM + * window and then a bounded SIGKILL fallback so an ignoring child cannot outlive the session. + */ diff --git a/src/remote-control/workspace-rpc.ts b/src/remote-control/workspace-rpc.ts new file mode 100644 index 0000000000..15f8c7a381 --- /dev/null +++ b/src/remote-control/workspace-rpc.ts @@ -0,0 +1,304 @@ +import type { RemoteControlCipher } from "./crypto"; +import type { + RemoteWorkspaceExecutionRequest, + RemoteWorkspaceExecutor, +} from "./workspace-executor"; +import { + isRemoteWorkspaceToolName, + remoteWorkspaceCapabilityForTool, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; +import type { RemoteWorkspaceTransport } from "./workspace-coordinator"; +import { + REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES, + RemoteWorkspaceRpcReassembler, + frameRemoteWorkspaceRpcMessage, +} from "./workspace-rpc-framing"; + +const REMOTE_WORKSPACE_RPC_VERSION = 1 as const; +const REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS = 30_000; +const REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS = 8; +interface RemoteWorkspaceRpcRequest { + version: typeof REMOTE_WORKSPACE_RPC_VERSION; + kind: "request"; + request: RemoteWorkspaceExecutionRequest; +} + +interface RemoteWorkspaceRpcResponse { + version: typeof REMOTE_WORKSPACE_RPC_VERSION; + kind: "response"; + requestId: string; + result: RemoteWorkspaceToolResult; +} + +type RemoteWorkspaceRpcMessage = RemoteWorkspaceRpcRequest | RemoteWorkspaceRpcResponse; + +interface PendingRequest { + resolve(value: RemoteWorkspaceToolResult): void; + reject(error: Error): void; + timer: ReturnType; +} + +function boundedIdentifier(value: unknown): value is string { + return typeof value === "string" && value.length >= 1 && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value); +} + +function encodeMessage(value: RemoteWorkspaceRpcMessage): Uint8Array { + const encoded = new TextEncoder().encode(JSON.stringify(value)); + if (encoded.byteLength > REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES) { + throw new Error("remote workspace RPC message exceeds the bounded message limit"); + } + return encoded; +} + +function parseResult(value: unknown): RemoteWorkspaceToolResult { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace RPC result"); + const raw = value as Record; + if (Object.keys(raw).some(key => key !== "ok" && key !== "value" && key !== "error")) { + throw new Error("invalid remote workspace RPC result fields"); + } + if (raw.ok === true && raw.error === undefined) { + return raw.value === undefined ? { ok: true } : { ok: true, value: raw.value }; + } + if (raw.ok === false && raw.value === undefined + && typeof raw.error === "string" && raw.error.length >= 1 && raw.error.length <= 4096) { + return { ok: false, error: raw.error }; + } + throw new Error("invalid remote workspace RPC result status"); +} + +function parseRequest(value: unknown): RemoteWorkspaceExecutionRequest { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace RPC request"); + const raw = value as Record; + if ( + !boundedIdentifier(raw.requestId) + || !boundedIdentifier(raw.sessionId) + || !boundedIdentifier(raw.executorDeviceId) + || !boundedIdentifier(raw.rootId) + || !isRemoteWorkspaceToolName(raw.tool) + ) throw new Error("invalid remote workspace RPC request identity"); + return { + requestId: raw.requestId, + sessionId: raw.sessionId, + executorDeviceId: raw.executorDeviceId, + rootId: raw.rootId, + tool: raw.tool, + arguments: raw.arguments, + }; +} + +function parseMessage(value: Uint8Array): RemoteWorkspaceRpcMessage { + if (!(value instanceof Uint8Array) || value.byteLength < 1 || value.byteLength > REMOTE_WORKSPACE_RPC_MAX_MESSAGE_BYTES) { + throw new Error("invalid remote workspace RPC message length"); + } + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)); + } catch { + throw new Error("invalid remote workspace RPC JSON"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid remote workspace RPC message"); + const raw = parsed as Record; + if (raw.version !== REMOTE_WORKSPACE_RPC_VERSION) throw new Error("unsupported remote workspace RPC version"); + if (raw.kind === "request") { + return { version: REMOTE_WORKSPACE_RPC_VERSION, kind: "request", request: parseRequest(raw.request) }; + } + if (raw.kind === "response" && boundedIdentifier(raw.requestId)) { + return { + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: raw.requestId, + result: parseResult(raw.result), + }; + } + throw new Error("invalid remote workspace RPC message kind"); +} + +export interface EncryptedRemoteWorkspaceTransportOptions { + executorDeviceId: string; + cipher: RemoteControlCipher; + sendCiphertext(value: Uint8Array): void | Promise; + timeoutMs?: number; +} + +/** Coordinator-side transport. The WebSocket/relay adapter only has to carry ciphertext. */ +export class EncryptedRemoteWorkspaceTransport implements RemoteWorkspaceTransport { + private readonly pending = new Map(); + private readonly reassembler = new RemoteWorkspaceRpcReassembler(); + private readonly timeoutMs: number; + private sendTail: Promise = Promise.resolve(); + private online = true; + + constructor(private readonly options: EncryptedRemoteWorkspaceTransportOptions) { + this.timeoutMs = options.timeoutMs ?? REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS; + if (!boundedIdentifier(options.executorDeviceId) || !Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1) { + throw new Error("invalid encrypted remote workspace transport options"); + } + } + + isOnline(deviceId: string): boolean { + return this.online && deviceId === this.options.executorDeviceId; + } + + async invoke(request: RemoteWorkspaceExecutionRequest): Promise { + if (!this.isOnline(request.executorDeviceId)) throw new Error("remote workspace executor is offline"); + if (this.pending.has(request.requestId)) throw new Error("duplicate remote workspace request ID"); + if (this.pending.size >= REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS) { + throw new Error("remote workspace request limit reached"); + } + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(request.requestId); + reject(new Error("remote workspace request timed out")); + }, this.timeoutMs); + this.pending.set(request.requestId, { resolve, reject, timer }); + }); + try { + await this.sendMessage(encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "request", + request, + })); + } catch { + // A failed encrypted write consumes a directional counter. Continuing would make every + // later frame undecryptable, so fail every pending operation instead of waiting for timeout. + this.close("remote workspace send failed"); + } + return await response; + } + + receiveCiphertext(value: Uint8Array): void { + if (!this.online) throw new Error("remote workspace transport is closed"); + const responsePlaintext = this.reassembler.accept(this.options.cipher.decrypt(value)); + if (!responsePlaintext) return; + const message = parseMessage(responsePlaintext); + if (message.kind !== "response") throw new Error("coordinator received a remote workspace request"); + const pending = this.pending.get(message.requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.requestId); + pending.resolve(message.result); + } + + close(reason = "remote workspace transport closed"): void { + if (!this.online) return; + this.online = false; + this.reassembler.clear(); + this.options.cipher.destroy(); + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + } + this.pending.clear(); + } + + private sendMessage(message: Uint8Array): Promise { + const operation = this.sendTail.then(async () => { + if (!this.online) throw new Error("remote workspace transport is closed"); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await this.options.sendCiphertext(this.options.cipher.encrypt(frame)); + } + }); + this.sendTail = operation.catch(() => {}); + return operation; + } +} + +export interface EncryptedRemoteWorkspaceExecutorEndpointOptions { + executorDeviceId: string; + sessionId: string; + rootId: string; + capabilities: readonly RemoteWorkspaceCapability[]; + cipher: RemoteControlCipher; + executor: Pick; + sendCiphertext(value: Uint8Array): void | Promise; +} + +/** Executor-side endpoint. It accepts only authenticated, ordered E2EE session frames. */ +export class EncryptedRemoteWorkspaceExecutorEndpoint { + private closed = false; + private readonly active = new Map(); + private readonly reassembler = new RemoteWorkspaceRpcReassembler(); + private sendTail: Promise = Promise.resolve(); + + private readonly grantedCapabilities: ReadonlySet; + private readonly sessionId: string; + private readonly rootId: string; + + constructor(private readonly options: EncryptedRemoteWorkspaceExecutorEndpointOptions) { + if (!boundedIdentifier(options.executorDeviceId) || !boundedIdentifier(options.sessionId) + || !boundedIdentifier(options.rootId)) throw new Error("invalid remote workspace executor endpoint"); + this.options = { ...options }; + this.sessionId = options.sessionId; + this.rootId = options.rootId; + this.grantedCapabilities = new Set(options.capabilities); + } + + async receiveCiphertext(value: Uint8Array): Promise { + if (this.closed) throw new Error("remote workspace executor endpoint is closed"); + const requestPlaintext = this.reassembler.accept(this.options.cipher.decrypt(value)); + if (!requestPlaintext) return; + const message = parseMessage(requestPlaintext); + if (message.kind !== "request") throw new Error("executor received a remote workspace response"); + if (message.request.executorDeviceId !== this.options.executorDeviceId) { + throw new Error("remote workspace encrypted request targeted another executor"); + } + if (message.request.sessionId !== this.sessionId || message.request.rootId !== this.rootId) { + throw new Error("remote workspace encrypted request does not match its session binding"); + } + if (!this.grantedCapabilities.has(remoteWorkspaceCapabilityForTool(message.request.tool))) { + throw new Error("remote workspace tool capability was not granted to this session"); + } + if (this.active.has(message.request.requestId)) throw new Error("duplicate remote workspace executor request ID"); + if (this.active.size >= REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS) { + throw new Error("remote workspace executor request limit reached"); + } + const controller = new AbortController(); + this.active.set(message.request.requestId, controller); + let result: RemoteWorkspaceToolResult; + try { + result = await this.options.executor.invoke(message.request, controller.signal); + } finally { + this.active.delete(message.request.requestId); + } + if (this.closed) return; + let responsePlaintext: Uint8Array; + try { + responsePlaintext = encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: message.request.requestId, + result, + }); + } catch { + responsePlaintext = encodeMessage({ + version: REMOTE_WORKSPACE_RPC_VERSION, + kind: "response", + requestId: message.request.requestId, + result: { ok: false, error: "remote workspace result exceeded the encrypted frame limit" }, + }); + } + await this.sendMessage(responsePlaintext); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.reassembler.clear(); + for (const controller of this.active.values()) controller.abort(); + this.active.clear(); + this.options.cipher.destroy(); + } + + private sendMessage(message: Uint8Array): Promise { + const operation = this.sendTail.then(async () => { + if (this.closed) throw new Error("remote workspace executor endpoint is closed"); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await this.options.sendCiphertext(this.options.cipher.encrypt(frame)); + } + }); + this.sendTail = operation.catch(() => {}); + return operation; + } +} diff --git a/src/remote-control/workspace-runtime.ts b/src/remote-control/workspace-runtime.ts new file mode 100644 index 0000000000..01596753c2 --- /dev/null +++ b/src/remote-control/workspace-runtime.ts @@ -0,0 +1,60 @@ +import type { OcxConfig } from "../types"; +import { + RemoteWorkspaceHub, + RemoteWorkspaceHubFileStore, + type RemoteWorkspaceHubStateStore, +} from "./workspace-hub"; +import { CodexRemoteWorkspaceRuntimeFactory } from "./workspace-codex-runtime"; +import { ClaudeRemoteWorkspaceRuntimeFactory } from "./workspace-claude-runtime"; +import { PiRemoteWorkspaceRuntimeFactory } from "./workspace-pi-runtime"; +import { + RemoteWorkspaceSessionFileStore, + RemoteWorkspaceSessionService, +} from "./workspace-sessions"; + +const hubs = new WeakMap(); +const sessionServices = new WeakMap(); + +export function remoteWorkspaceHubForConfig( + config: Readonly, + store?: RemoteWorkspaceHubStateStore, +): RemoteWorkspaceHub { + if (config.runtimeRole !== "hub") throw new Error("remote workspace requires runtimeRole=hub"); + const existing = hubs.get(config); + if (existing) return existing; + const hub = new RemoteWorkspaceHub(store ?? new RemoteWorkspaceHubFileStore()); + hubs.set(config, hub); + return hub; +} + +export function remoteWorkspaceSessionsForConfig( + config: Readonly, +): RemoteWorkspaceSessionService { + if (config.runtimeRole !== "hub") throw new Error("remote workspace requires runtimeRole=hub"); + const existing = sessionServices.get(config); + if (existing) return existing; + const service = new RemoteWorkspaceSessionService( + remoteWorkspaceHubForConfig(config), + [ + new CodexRemoteWorkspaceRuntimeFactory(), + new ClaudeRemoteWorkspaceRuntimeFactory(), + new PiRemoteWorkspaceRuntimeFactory(), + ], + Date.now, + new RemoteWorkspaceSessionFileStore(), + ); + sessionServices.set(config, service); + return service; +} + +export function initializedRemoteWorkspaceHubForConfig( + config: Readonly, +): RemoteWorkspaceHub | null { + return hubs.get(config) ?? null; +} + +export function initializedRemoteWorkspaceSessionsForConfig( + config: Readonly, +): RemoteWorkspaceSessionService | null { + return sessionServices.get(config) ?? null; +} diff --git a/src/remote-control/workspace-secret-store.ts b/src/remote-control/workspace-secret-store.ts new file mode 100644 index 0000000000..9ff1c29249 --- /dev/null +++ b/src/remote-control/workspace-secret-store.ts @@ -0,0 +1,39 @@ +import { chmodSync, lstatSync, mkdirSync } from "node:fs"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; + +export interface WorkspaceSecretPermissions { + prepareDirectory(path: string): void; + hardenFile(path: string): void; +} + +/** Only ENOENT means first-run absence; permission failures must not reset identity. */ +export function workspaceSecretFileExists(path: string): boolean { + try { lstatSync(path); return true; } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +export const workspaceSecretPermissions: WorkspaceSecretPermissions = { + prepareDirectory(path) { + assertNotRealHomeUnderTest(path); + mkdirSync(path, { recursive: true, mode: 0o700 }); + const metadata = lstatSync(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("remote workspace secret directory must be a real directory"); + } + if (process.platform === "win32") hardenSecretDir(path, { required: true }); + else chmodSync(path, 0o700); + }, + hardenFile(path) { + assertNotRealHomeUnderTest(path); + const metadata = lstatSync(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) { + throw new Error("remote workspace secret must be a private regular file"); + } + if (process.platform === "win32") hardenSecretPath(path, { required: true }); + else chmodSync(path, 0o600); + }, +}; diff --git a/src/remote-control/workspace-sessions.ts b/src/remote-control/workspace-sessions.ts new file mode 100644 index 0000000000..775756200e --- /dev/null +++ b/src/remote-control/workspace-sessions.ts @@ -0,0 +1,730 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { atomicWriteFile } from "../config/atomic-write"; +import { getConfigDir } from "../config/paths"; +import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store"; +import { RemoteWorkspaceCoordinator, type RemoteWorkspaceTransport } from "./workspace-coordinator"; +import type { RemoteWorkspaceHub } from "./workspace-hub"; +import { isRemoteWorkspaceAgentProfile, type RemoteWorkspaceAgentProfile } from "./workspace-agent-protocol"; +import type { RemoteWorkspaceExecutionRequest } from "./workspace-executor"; +import { runRemoteWorkspaceCleanupSteps } from "./workspace-process"; +import { truncateRemoteWorkspaceUtf8 } from "./workspace-utf8"; +import { REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE } from "./protocol"; +import { + parseRemoteWorkspaceCapabilities, + remoteWorkspaceToolsForCapabilities, + type RemoteWorkspaceCapability, + type RemoteWorkspaceToolName, + type RemoteWorkspaceToolResult, +} from "./workspace-tools"; + +export const REMOTE_WORKSPACE_SESSION_STATE_VERSION = 1 as const; + +export type RemoteWorkspaceSessionStatus = + | "starting" + | "ready" + | "running" + | "waiting_for_executor" + | "failed" + | "stopped"; + +export type RemoteWorkspaceAccessMode = "read-only" | "workspace"; + +export interface RemoteWorkspaceSessionEvent { + sequence: number; + at: string; + type: "status" | "assistant" | "tool" | "error"; + text: string; +} + +export interface RemoteWorkspaceSessionSummary { + id: string; + profile: RemoteWorkspaceAgentProfile; + accessMode: RemoteWorkspaceAccessMode; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; + threadId: string | null; + /** True only after the runtime has created durable history that can be resumed. */ + resumable: boolean; + status: RemoteWorkspaceSessionStatus; + createdAt: string; + updatedAt: string; + events: RemoteWorkspaceSessionEvent[]; +} + +export interface RemoteWorkspaceRuntimeHandle { + threadId: string; + canResume?(): boolean; + prompt(text: string): Promise; + stop(): Promise; +} + +export interface RemoteWorkspaceRuntimeFactory { + profile: RemoteWorkspaceAgentProfile; + available(): Promise<{ available: boolean; version?: string; reason?: string }>; + start(options: { + sessionId: string; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteWorkspaceCapability[]; + tools: RemoteWorkspaceToolName[]; + resumeThreadId?: string; + coordinator: RemoteWorkspaceCoordinator; + emit(type: RemoteWorkspaceSessionEvent["type"], text: string): void; + }): Promise; +} + +export interface RemoteWorkspaceSessionState { + version: typeof REMOTE_WORKSPACE_SESSION_STATE_VERSION; + sessions: RemoteWorkspaceSessionSummary[]; +} + +export interface RemoteWorkspaceSessionStateStore { + load(): RemoteWorkspaceSessionState | null; + save(state: RemoteWorkspaceSessionState): void; +} + +interface LiveSession extends RemoteWorkspaceSessionSummary { + handle: RemoteWorkspaceRuntimeHandle | null; + unregister: (() => void) | null; + closeTransport: (() => Promise) | null; + operation: Promise; + stopOperation: Promise | null; + remoteTransport: SwitchableRemoteWorkspaceTransport | null; + turnActive: boolean; +} + +const MAX_EVENTS_PER_SESSION = 100; +const MAX_EVENT_TEXT_BYTES = 8 * 1024; +const MAX_PROMPT_BYTES = 256 * 1024; +const MAX_LIVE_SESSIONS = 8; +const MAX_RETAINED_SESSIONS = 64; +const MAX_LIST_EVENTS_PER_SESSION = 20; +const MAX_PERSISTED_EVENTS_PER_SESSION = 40; +const MAX_PERSISTED_EVENT_TEXT_BYTES = 4 * 1024; +const MAX_SESSION_STATE_BYTES = 16 * 1024 * 1024; +const AVAILABILITY_CACHE_MS = 30_000; +type RuntimeAvailability = Record; + +class SwitchableRemoteWorkspaceTransport implements RemoteWorkspaceTransport { + constructor(private current: RemoteWorkspaceTransport) {} + + replace(next: RemoteWorkspaceTransport): void { + this.current = next; + } + + isOnline(deviceId: string): boolean { + return this.current.isOnline(deviceId); + } + + invoke(request: RemoteWorkspaceExecutionRequest): Promise { + return this.current.invoke(request); + } +} + +function boundedPrompt(value: unknown): string { + if (typeof value !== "string" || value.trim().length < 1 || Buffer.byteLength(value, "utf8") > MAX_PROMPT_BYTES) { + throw new Error("remote workspace prompt must contain 1 to 262144 UTF-8 bytes"); + } + return value; +} + +function boundedEventText(value: string): string { + if (Buffer.byteLength(value, "utf8") <= MAX_EVENT_TEXT_BYTES) return value; + const marker = "\n[truncated]"; + return `${truncateRemoteWorkspaceUtf8(value, MAX_EVENT_TEXT_BYTES - Buffer.byteLength(marker, "utf8"))}${marker}`; +} + +function boundedPersistedEventText(value: string): string { + if (Buffer.byteLength(value, "utf8") <= MAX_PERSISTED_EVENT_TEXT_BYTES) return value; + const marker = "\n[truncated for restart snapshot]"; + const maximum = MAX_PERSISTED_EVENT_TEXT_BYTES - Buffer.byteLength(marker, "utf8"); + return `${truncateRemoteWorkspaceUtf8(value, maximum)}${marker}`; +} + +function boundedString(value: unknown, label: string, maximum = 256): string { + if (typeof value !== "string" || value.length < 1 || value.length > maximum || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) { + throw new Error(`invalid remote workspace ${label}`); + } + return value; +} + +function timestamp(value: unknown): string { + const result = boundedString(value, "timestamp", 64); + if (!Number.isFinite(Date.parse(result))) throw new Error("invalid remote workspace timestamp"); + return result; +} + +function parseStatus(value: unknown): RemoteWorkspaceSessionStatus { + if (value === "starting" || value === "ready" || value === "running" + || value === "waiting_for_executor" || value === "failed" || value === "stopped") return value; + throw new Error("invalid remote workspace session status"); +} + +function parseAccessMode(value: unknown): RemoteWorkspaceAccessMode { + if (value === undefined || value === "workspace") return "workspace"; + if (value === "read-only") return value; + throw new Error("invalid remote workspace access mode"); +} + +function parseEvent(value: unknown): RemoteWorkspaceSessionEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session event"); + const raw = value as Record; + if (typeof raw.sequence !== "number" || !Number.isSafeInteger(raw.sequence) || raw.sequence < 1) { + throw new Error("invalid remote workspace event sequence"); + } + if (raw.type !== "status" && raw.type !== "assistant" && raw.type !== "tool" && raw.type !== "error") { + throw new Error("invalid remote workspace event type"); + } + return { + sequence: raw.sequence, + at: timestamp(raw.at), + type: raw.type, + text: boundedString(raw.text, "event text", MAX_EVENT_TEXT_BYTES), + }; +} + +function parseSession(value: unknown): RemoteWorkspaceSessionSummary { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session"); + const raw = value as Record; + if (!isRemoteWorkspaceAgentProfile(raw.profile)) throw new Error("invalid remote workspace session profile"); + const accessMode = parseAccessMode(raw.accessMode); + const capabilities = parseRemoteWorkspaceCapabilities(raw.capabilities); + if (accessMode === "read-only" + && (capabilities.length !== 1 || capabilities[0] !== "workspace.read")) { + throw new Error("read-only remote workspace state contains write capabilities"); + } + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + if (!Array.isArray(raw.events) || raw.events.length > MAX_EVENTS_PER_SESSION) { + throw new Error("invalid remote workspace session events"); + } + if (raw.threadId !== null && typeof raw.threadId !== "string") throw new Error("invalid remote workspace thread ID"); + const resumable = raw.resumable === undefined + ? raw.threadId !== null + : raw.resumable === true; + if (raw.resumable !== undefined && typeof raw.resumable !== "boolean") { + throw new Error("invalid remote workspace resumable state"); + } + if (resumable && raw.threadId === null) throw new Error("resumable remote workspace session has no thread ID"); + return { + id: boundedString(raw.id, "session ID"), + profile: raw.profile, + accessMode, + deviceId: boundedString(raw.deviceId, "device ID"), + deviceName: boundedString(raw.deviceName, "device name", 80), + rootId: boundedString(raw.rootId, "root ID"), + rootLabel: boundedString(raw.rootLabel, "root label", 80), + capabilities, + tools, + threadId: raw.threadId === null ? null : boundedString(raw.threadId, "thread ID"), + resumable, + status: parseStatus(raw.status), + createdAt: timestamp(raw.createdAt), + updatedAt: timestamp(raw.updatedAt), + events: raw.events.map(parseEvent), + }; +} + +export function parseRemoteWorkspaceSessionState(value: unknown): RemoteWorkspaceSessionState { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace session state"); + const raw = value as Record; + if (raw.version !== REMOTE_WORKSPACE_SESSION_STATE_VERSION || !Array.isArray(raw.sessions)) { + throw new Error("unsupported remote workspace session state"); + } + if (raw.sessions.length > MAX_RETAINED_SESSIONS) throw new Error("remote workspace retained session limit exceeded"); + const ids = new Set(); + const sessions = raw.sessions.map(item => { + const session = parseSession(item); + if (ids.has(session.id)) throw new Error("duplicate remote workspace session ID"); + ids.add(session.id); + return session; + }); + return { version: REMOTE_WORKSPACE_SESSION_STATE_VERSION, sessions }; +} + +export class RemoteWorkspaceSessionFileStore implements RemoteWorkspaceSessionStateStore { + constructor( + private readonly path = join(getConfigDir(), "remote-workspace-sessions.json"), + private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions, + ) {} + + load(): RemoteWorkspaceSessionState | null { + if (!workspaceSecretFileExists(this.path)) return null; + this.permissions.prepareDirectory(dirname(this.path)); + this.permissions.hardenFile(this.path); + const metadata = statSync(this.path); + if (!metadata.isFile() || metadata.size > MAX_SESSION_STATE_BYTES) { + throw new Error("remote workspace session state is too large"); + } + return parseRemoteWorkspaceSessionState(JSON.parse(readFileSync(this.path, "utf8"))); + } + + save(state: RemoteWorkspaceSessionState): void { + const parsed = parseRemoteWorkspaceSessionState(state); + const body = `${JSON.stringify(parsed, null, 2)}\n`; + if (Buffer.byteLength(body, "utf8") > MAX_SESSION_STATE_BYTES) { + throw new Error("remote workspace session state is too large"); + } + this.permissions.prepareDirectory(dirname(this.path)); + if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path); + atomicWriteFile(this.path, body); + } +} + +export class RemoteWorkspaceSessionService { + private readonly sessions = new Map(); + private readonly runtimes = new Map(); + private sequence = 0; + private availabilityCache: { at: number; value: RuntimeAvailability } | null = null; + private availabilityFlight: Promise | null = null; + + constructor( + private readonly hub: RemoteWorkspaceHub, + factories: readonly RemoteWorkspaceRuntimeFactory[], + private readonly now: () => number = Date.now, + private readonly store?: RemoteWorkspaceSessionStateStore, + ) { + for (const factory of factories) { + if (this.runtimes.has(factory.profile)) throw new Error("duplicate remote workspace runtime profile"); + this.runtimes.set(factory.profile, factory); + } + for (const summary of this.store?.load()?.sessions ?? []) { + const restoredStatus = summary.status === "stopped" + ? "stopped" + : summary.threadId && summary.resumable + ? "waiting_for_executor" + : "failed"; + this.sessions.set(summary.id, { + ...summary, + status: restoredStatus, + handle: null, + unregister: null, + closeTransport: null, + operation: Promise.resolve(), + stopOperation: null, + remoteTransport: null, + turnActive: false, + }); + for (const event of summary.events) this.sequence = Math.max(this.sequence, event.sequence); + } + } + + async availability(): Promise { + if (this.availabilityCache && this.now() - this.availabilityCache.at < AVAILABILITY_CACHE_MS) { + return structuredClone(this.availabilityCache.value); + } + if (this.availabilityFlight) return structuredClone(await this.availabilityFlight); + this.availabilityFlight = (async () => { + const probe = async (profile: RemoteWorkspaceAgentProfile) => { + const factory = this.runtimes.get(profile); + if (!factory) return { available: false, reason: "runtime adapter is not installed" }; + try { return await factory.available(); } + catch { return { available: false, reason: "runtime availability probe failed" }; } + }; + const [codex, claude, pi] = await Promise.all([ + probe("codex"), + probe("claude"), + probe("pi"), + ]); + const value: RuntimeAvailability = { codex, claude, pi }; + this.availabilityCache = { at: this.now(), value }; + return value; + })(); + try { return structuredClone(await this.availabilityFlight); } + finally { this.availabilityFlight = null; } + } + + list(): RemoteWorkspaceSessionSummary[] { + this.refreshOfflineStates(); + return [...this.sessions.values()].map(session => this.publicSession(session, MAX_LIST_EVENTS_PER_SESSION)); + } + + get(sessionId: string): RemoteWorkspaceSessionSummary | null { + this.refreshOfflineStates(); + const session = this.sessions.get(sessionId); + return session ? this.publicSession(session) : null; + } + + async create(input: { + profile: RemoteWorkspaceAgentProfile; + deviceId: string; + rootId: string; + accessMode?: RemoteWorkspaceAccessMode; + }): Promise { + this.pruneRetainedSessions(); + const liveCount = [...this.sessions.values()].filter(session => session.handle !== null).length; + if (liveCount >= MAX_LIVE_SESSIONS) throw new Error("remote workspace active session limit reached"); + const deviceLiveCount = [...this.sessions.values()].filter(session => ( + session.deviceId === input.deviceId && session.handle !== null + )).length; + if (deviceLiveCount >= REMOTE_CONTROL_MAX_SESSIONS_PER_DEVICE) { + throw new Error("remote workspace executor session limit reached"); + } + const factory = this.runtimes.get(input.profile); + if (!factory) throw new Error(`remote workspace ${input.profile} runtime is not installed on the hub`); + const available = await factory.available(); + if (!available.available) throw new Error(available.reason ?? `remote workspace ${input.profile} runtime is unavailable`); + const device = this.hub.listDevices().find(candidate => candidate.id === input.deviceId); + if (!device) throw new Error("remote workspace device not found"); + const root = device.roots.find(candidate => candidate.id === input.rootId); + if (!root) throw new Error("remote workspace root not found on the selected device"); + const connection = this.hub.connection(device.id); + if (!connection) throw new Error("remote workspace executor is offline"); + const id = randomUUID(); + const accessMode = parseAccessMode(input.accessMode ?? "read-only"); + const deviceCapabilities = parseRemoteWorkspaceCapabilities(device.capabilities); + const capabilities = accessMode === "read-only" + ? parseRemoteWorkspaceCapabilities(["workspace.read"]) + : deviceCapabilities; + const tools = remoteWorkspaceToolsForCapabilities(capabilities); + const connectionCapabilities = connection.capabilities(); + if (capabilities.some(capability => !connectionCapabilities.includes(capability))) { + throw new Error("remote workspace executor capability advertisement is stale; refresh and try again"); + } + const timestamp = new Date(this.now()).toISOString(); + const session: LiveSession = { + id, + profile: input.profile, + accessMode, + deviceId: device.id, + deviceName: device.name, + rootId: root.id, + rootLabel: root.label, + capabilities, + tools, + threadId: null, + resumable: false, + status: "starting", + createdAt: timestamp, + updatedAt: timestamp, + events: [], + handle: null, + unregister: null, + closeTransport: null, + operation: Promise.resolve(), + stopOperation: null, + remoteTransport: null, + turnActive: false, + }; + this.sessions.set(id, session); + this.emit(session, "status", `Starting ${input.profile} on ${device.name}/${root.label}`); + try { + this.persist(); + } catch (error) { + this.sessions.delete(id); + throw error; + } + try { + session.closeTransport = () => connection.closeSession(id); + const transport = await connection.openSession({ sessionId: id, rootId: root.id, profile: input.profile, capabilities }); + if (session.stopOperation) { + await session.closeTransport().catch(() => {}); + session.closeTransport = null; + throw new Error("remote workspace session was stopped while starting"); + } + const remoteTransport = new SwitchableRemoteWorkspaceTransport(transport); + session.remoteTransport = remoteTransport; + const coordinator = new RemoteWorkspaceCoordinator(remoteTransport); + const handle = await factory.start({ + sessionId: id, + deviceId: device.id, + deviceName: device.name, + rootId: root.id, + rootLabel: root.label, + capabilities, + tools, + coordinator, + emit: (type, text) => this.emit(session, type, text), + }); + if (session.stopOperation) { + await handle.stop().catch(() => {}); + throw new Error("remote workspace session was stopped while starting"); + } + session.threadId = handle.threadId; + session.resumable = handle.canResume?.() ?? true; + session.handle = handle; + session.unregister = coordinator.register({ + sessionId: id, + threadId: handle.threadId, + executorDeviceId: device.id, + executorName: device.name, + rootId: root.id, + capabilities, + tools, + }); + this.status(session, "ready", `${input.profile} is ready on ${device.name}/${root.label}`); + return this.publicSession(session); + } catch (error) { + let reported = error; + if (session.status !== "stopped") { + try { + this.status(session, "failed", error instanceof Error ? error.message : "remote workspace session failed to start"); + } catch (persistenceError) { + reported = persistenceError; + } + } + session.unregister?.(); + session.unregister = null; + await session.handle?.stop().catch(() => {}); + session.handle = null; + await session.closeTransport?.().catch(() => {}); + session.closeTransport = null; + session.remoteTransport = null; + throw reported; + } + } + + async prompt(sessionId: string, value: unknown): Promise { + const prompt = boundedPrompt(value); + const session = this.sessions.get(sessionId); + if (!session || session.status === "stopped") throw new Error("remote workspace session is not ready"); + if (!session.handle && (!session.threadId || !session.resumable)) { + throw new Error("remote workspace session cannot be resumed"); + } + if (session.turnActive) throw new Error("remote workspace session already has an active turn"); + if (session.stopOperation) throw new Error("remote workspace session is stopping"); + session.turnActive = true; + const run = async () => { + try { + await this.ensureRemoteTransport(session); + await this.ensureRuntime(session); + if (session.stopOperation) throw new Error("remote workspace session is stopping"); + this.status(session, "running", "Turn started"); + await session.handle!.prompt(prompt); + session.resumable = session.handle!.canResume?.() ?? true; + if (!this.hub.connection(session.deviceId) + || !session.remoteTransport?.isOnline(session.deviceId)) { + this.status(session, "waiting_for_executor", "Turn completed; reconnect the remote executor before continuing."); + } else { + this.status(session, "ready", "Turn completed"); + } + } catch (error) { + const message = error instanceof Error ? error.message : "remote workspace turn failed"; + this.status(session, this.hub.connection(session.deviceId) ? "failed" : "waiting_for_executor", message); + throw error; + } finally { + session.turnActive = false; + } + }; + session.operation = run(); + await session.operation; + return this.publicSession(session); + } + + async stop(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + if (session.stopOperation) return session.stopOperation; + session.stopOperation = (async () => { + const handle = session.handle; + const activeOperation = session.operation; + // Cancellation has to run before waiting for the active turn. Waiting first makes + // Stop unable to interrupt a model request or remote command that never completes. + try { + await runRemoteWorkspaceCleanupSteps([ + async () => { if (handle) await handle.stop(); }, + () => activeOperation.catch(() => {}), + () => { session.unregister?.(); session.unregister = null; }, + async () => { if (session.closeTransport) await session.closeTransport(); }, + () => { + session.closeTransport = null; + session.handle = null; + session.remoteTransport = null; + }, + ]); + } catch (error) { + this.status(session, "failed", "Session cleanup failed; one or more owned resources did not close."); + throw error; + } + this.status(session, "stopped", "Session stopped"); + return true; + })(); + return session.stopOperation; + } + + async stopAll(): Promise { + const active = [...this.sessions.values()].filter(session => session.status !== "stopped"); + await Promise.all(active.map(session => this.stop(session.id).then(() => undefined))); + this.persist(); + } + + async shutdown(): Promise { + const active = [...this.sessions.values()].filter(session => session.status !== "stopped"); + await Promise.all(active.map(async session => { + if (session.stopOperation) { + await session.stopOperation; + return; + } + session.stopOperation = (async () => { + const handle = session.handle; + const activeOperation = session.operation; + try { + await runRemoteWorkspaceCleanupSteps([ + async () => { if (handle) await handle.stop(); }, + () => activeOperation.catch(() => {}), + () => { session.unregister?.(); session.unregister = null; }, + async () => { if (session.closeTransport) await session.closeTransport(); }, + () => { + session.closeTransport = null; + session.handle = null; + session.remoteTransport = null; + }, + ]); + } catch (error) { + this.status(session, "failed", "Hub shutdown could not close every Remote Workspace resource."); + throw error; + } + this.status( + session, + session.threadId && session.resumable ? "waiting_for_executor" : "failed", + session.threadId && session.resumable + ? "Hub stopped; reconnect the executor to resume this session." + : "Hub stopped before the model session was created.", + ); + return true; + })(); + await session.stopOperation; + })); + this.persist(); + } + + private status(session: LiveSession, status: RemoteWorkspaceSessionStatus, text: string): void { + session.status = status; + this.emit(session, status === "failed" ? "error" : "status", text); + this.persist(); + } + + private emit(session: LiveSession, type: RemoteWorkspaceSessionEvent["type"], text: string): void { + const at = new Date(this.now()).toISOString(); + session.updatedAt = at; + session.events.push({ sequence: ++this.sequence, at, type, text: boundedEventText(text) }); + if (session.events.length > MAX_EVENTS_PER_SESSION) { + session.events.splice(0, session.events.length - MAX_EVENTS_PER_SESSION); + } + } + + private publicSession(session: LiveSession, eventLimit = MAX_EVENTS_PER_SESSION): RemoteWorkspaceSessionSummary { + const { + handle: _handle, + unregister: _unregister, + closeTransport: _close, + operation: _operation, + stopOperation: _stopOperation, + remoteTransport: _remoteTransport, + turnActive: _turnActive, + ...publicState + } = session; + return structuredClone({ ...publicState, events: publicState.events.slice(-eventLimit) }); + } + + private async ensureRemoteTransport(session: LiveSession): Promise { + if (session.remoteTransport?.isOnline(session.deviceId)) return; + const connection = this.hub.connection(session.deviceId); + if (!connection) { + this.status(session, "waiting_for_executor", "Remote executor is offline; local fallback is disabled."); + throw new Error("remote workspace executor is offline"); + } + const connectionCapabilities = connection.capabilities(); + if (session.capabilities.some(capability => !connectionCapabilities.includes(capability))) { + throw new Error("remote workspace executor capabilities changed; start a new session for this computer"); + } + this.status(session, "starting", `Reconnecting ${session.deviceName}/${session.rootLabel}`); + const transport = await connection.openSession({ + sessionId: session.id, + rootId: session.rootId, + profile: session.profile, + capabilities: session.capabilities, + }); + if (session.stopOperation) { + await connection.closeSession(session.id).catch(() => {}); + throw new Error("remote workspace session is stopping"); + } + await session.closeTransport?.().catch(() => {}); + if (session.remoteTransport) session.remoteTransport.replace(transport); + else session.remoteTransport = new SwitchableRemoteWorkspaceTransport(transport); + session.closeTransport = () => connection.closeSession(session.id); + this.status(session, "ready", `${session.profile} reconnected to ${session.deviceName}/${session.rootLabel}`); + } + + private async ensureRuntime(session: LiveSession): Promise { + if (session.handle) return; + if (!session.threadId || !session.resumable || !session.remoteTransport) { + throw new Error("remote workspace session cannot be resumed"); + } + const factory = this.runtimes.get(session.profile); + if (!factory) throw new Error(`remote workspace ${session.profile} runtime is not installed on the hub`); + const available = await factory.available(); + if (!available.available) throw new Error(available.reason ?? `remote workspace ${session.profile} runtime is unavailable`); + const coordinator = new RemoteWorkspaceCoordinator(session.remoteTransport); + const handle = await factory.start({ + sessionId: session.id, + deviceId: session.deviceId, + deviceName: session.deviceName, + rootId: session.rootId, + rootLabel: session.rootLabel, + capabilities: [...session.capabilities], + tools: [...session.tools], + resumeThreadId: session.threadId, + coordinator, + emit: (type, text) => this.emit(session, type, text), + }); + try { + session.unregister = coordinator.register({ + sessionId: session.id, + threadId: handle.threadId, + executorDeviceId: session.deviceId, + executorName: session.deviceName, + rootId: session.rootId, + capabilities: [...session.capabilities], + tools: [...session.tools], + }); + } catch (error) { + await handle.stop().catch(() => {}); + throw error; + } + session.threadId = handle.threadId; + session.handle = handle; + this.status(session, "ready", `${session.profile} resumed on ${session.deviceName}/${session.rootLabel}`); + } + + private refreshOfflineStates(): void { + for (const session of this.sessions.values()) { + if (session.status !== "ready" || this.hub.connection(session.deviceId)) continue; + this.status(session, "waiting_for_executor", "Remote executor is offline; local fallback is disabled."); + } + } + + private pruneRetainedSessions(): void { + if (this.sessions.size < MAX_RETAINED_SESSIONS) return; + for (const [id, session] of this.sessions) { + if (session.status !== "stopped" && !(session.status === "failed" && session.handle === null)) continue; + this.sessions.delete(id); + if (this.sessions.size < MAX_RETAINED_SESSIONS) return; + } + if (this.sessions.size >= MAX_RETAINED_SESSIONS) { + throw new Error("remote workspace retained session limit reached; stop an active session first"); + } + } + + private persist(): void { + if (!this.store) return; + const sessions = [...this.sessions.values()].map(session => { + const summary = this.publicSession(session); + return { + ...summary, + events: summary.events.slice(-MAX_PERSISTED_EVENTS_PER_SESSION).map(event => ({ + ...event, + text: boundedPersistedEventText(event.text), + })), + }; + }); + this.store.save({ version: REMOTE_WORKSPACE_SESSION_STATE_VERSION, sessions }); + } +} diff --git a/src/remote-control/workspace-tool-bridge.ts b/src/remote-control/workspace-tool-bridge.ts new file mode 100644 index 0000000000..7a3b6306a3 --- /dev/null +++ b/src/remote-control/workspace-tool-bridge.ts @@ -0,0 +1,192 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import type { RemoteWorkspaceCoordinator } from "./workspace-coordinator"; +import { + REMOTE_WORKSPACE_DYNAMIC_TOOLS, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + isRemoteWorkspaceToolName, + type RemoteWorkspaceToolName, +} from "./workspace-tools"; + +const MAX_BRIDGE_BODY_BYTES = 512 * 1024; +const MAX_BRIDGE_ACTIVE_REQUESTS = 8; +function json(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: { "cache-control": "no-store" } }); +} + +function errorText(value: unknown): string { + return (value instanceof Error ? value.message : "Remote Workspace tool failed") + .replace(/[^\x20-\x7e\n\t]/g, " ") + .slice(0, 4_096); +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +async function readBoundedJson(req: Request): Promise { + if (!req.body) throw new Error("invalid JSON"); + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_BRIDGE_BODY_BYTES) { + await reader.cancel("request too large").catch(() => {}); + throw new Error("request too large"); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); +} + +export interface RemoteWorkspaceToolBridge { + url: string; + token: string; + stop(): Promise; +} + +/** + * Loopback-only bridge used by Hub-owned CLIs whose extension boundary is HTTP. + * The random bearer is passed only to the child process. The model sees tool schemas, + * never this endpoint or token, and every invocation still goes through the E2EE coordinator. + */ +export function startRemoteWorkspaceToolBridge(options: { + coordinator: RemoteWorkspaceCoordinator; + threadId: string | (() => string); + tools: readonly RemoteWorkspaceToolName[]; + onTool?: (tool: RemoteWorkspaceToolName) => void; +}): RemoteWorkspaceToolBridge { + const token = randomBytes(32).toString("base64url"); + const toolNames = new Set(options.tools); + const definitions = REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.filter(tool => toolNames.has(tool.name)); + if (definitions.length < 1) throw new Error("Remote Workspace bridge needs at least one tool"); + const invoke = async (tool: unknown, args: unknown): Promise<{ success: boolean; text: string }> => { + if (!isRemoteWorkspaceToolName(tool) || !toolNames.has(tool)) { + return { success: false, text: JSON.stringify({ ok: false, error: "unknown Remote Workspace tool" }) }; + } + options.onTool?.(tool); + const threadId = typeof options.threadId === "function" ? options.threadId() : options.threadId; + if (!threadId) return { success: false, text: JSON.stringify({ ok: false, error: "remote workspace thread is not ready" }) }; + const result = await options.coordinator.handle({ + method: "item/tool/call", + id: randomUUID(), + params: { + threadId, + turnId: randomUUID(), + callId: randomUUID(), + namespace: REMOTE_WORKSPACE_TOOL_NAMESPACE, + tool, + arguments: args, + }, + }); + return { success: result.result.success, text: result.result.contentItems[0]!.text }; + }; + let activeRequests = 0; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (req.headers.get("origin")) return json({ error: "browser origins are not allowed" }, 403); + if (req.headers.get("authorization") !== `Bearer ${token}`) return json({ error: "unauthorized" }, 401); + if (req.method !== "POST" || (url.pathname !== "/invoke" && url.pathname !== "/mcp")) { + return json({ error: "not found" }, 404); + } + if (activeRequests >= MAX_BRIDGE_ACTIVE_REQUESTS) return json({ error: "Remote Workspace bridge is busy" }, 429); + activeRequests += 1; + try { + const length = Number(req.headers.get("content-length") ?? "0"); + if (!Number.isFinite(length) || length > MAX_BRIDGE_BODY_BYTES) return json({ error: "request too large" }, 413); + let parsed: unknown; + try { parsed = await readBoundedJson(req); } + catch (error) { + return json({ error: error instanceof Error && error.message === "request too large" ? error.message : "invalid JSON" }, + error instanceof Error && error.message === "request too large" ? 413 : 400); + } + const body = record(parsed); + if (!body) return json({ error: "invalid request" }, 400); + + if (url.pathname === "/invoke") { + try { + return json(await invoke(body.tool, body.arguments)); + } catch (error) { + return json({ success: false, text: JSON.stringify({ ok: false, error: errorText(error) }) }, 502); + } + } + + const id = body.id; + const method = body.method; + const params = record(body.params) ?? {}; + if (typeof method !== "string") return json({ jsonrpc: "2.0", id: id ?? null, error: { code: -32_600, message: "invalid MCP request" } }); + if (method === "notifications/initialized") return new Response(null, { status: 202 }); + if (method === "initialize") { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { + protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : "2025-06-18", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "opencodex-remote-workspace", version: "1" }, + }, + }); + } + if (method === "ping") return json({ jsonrpc: "2.0", id: id ?? null, result: {} }); + if (method === "tools/list") { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { + tools: definitions.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }, + }); + } + if (method === "tools/call") { + try { + const called = await invoke(params.name, params.arguments); + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { content: [{ type: "text", text: called.text }], isError: !called.success }, + }); + } catch (error) { + return json({ + jsonrpc: "2.0", + id: id ?? null, + result: { content: [{ type: "text", text: errorText(error) }], isError: true }, + }); + } + } + return json({ jsonrpc: "2.0", id: id ?? null, error: { code: -32_601, message: "MCP method not found" } }); + } finally { + activeRequests -= 1; + } + }, + }); + let stopping: Promise | null = null; + return { + url: new URL("/", server.url).toString().replace(/\/$/, ""), + token, + stop() { + stopping ??= server.stop(true); + return stopping; + }, + }; +} diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f8e3691f38..643eb77ee6 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -75,3 +75,5 @@ away from. Resolution stays a pure function of (env, platform, home) so the Wind testable on any host: stubbing `process.platform` does not propagate to `os.platform()` under Bun. > Decision record: [ADR-0046](../decisions/ADR-0046-claude-desktop-config-library-resolution.md) + +The unregistered executor CLI module stores Remote Workspace state separately from client configuration; see [Remote Workspace](../remote-workspace.md). diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index ae71389ad7..9c9f2bd786 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -168,3 +168,5 @@ pin one legacy root owner before changing it. Sibling stores remain independent. precede coordinated writes under one scoped flight, and actual file state/refusals remain separate. Restore reconciles target intent from validated snapshot ownership without changing sibling policy. Profile journal views retain source-store provenance for older legacy entries. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/config.md b/structure/config.md index 48a29a7817..dfcb13f15e 100644 --- a/structure/config.md +++ b/structure/config.md @@ -195,3 +195,5 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The unregistered executor CLI module stores Remote Workspace state separately from client configuration; see [Remote Workspace](remote-workspace.md). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 40e55b1f1f..fb638ee220 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -511,3 +511,5 @@ converge the Codex catalog once and return its disposition. The Models UI owns a picker data resource so failure cannot erase the ordinary model inventory; Apply publishes through the resource's generation fence, and Most used reads usage only on explicit Apply. Stored mode survives availability drift, while complete/native custom orders await explicit replacement. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index a7ef656162..b408487d18 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -303,3 +303,5 @@ The Remote Hub guide and affected CLI, server-config, management-API, and dashbo Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](../providers/openai-tiers.md#quota-cache-and-short-window-history). + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/overview.md b/structure/overview.md index 1802d31b72..be1af80293 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -103,3 +103,5 @@ would pass while the rule was violated. - **INV-HOME-01** — `CODEX_HOME` wins over `~/.codex` when present and valid. - **INV-SLUG-01** — Routed model slugs use `provider/model`. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/remote-workspace.md b/structure/remote-workspace.md index 534c649471..cb18d70589 100644 --- a/structure/remote-workspace.md +++ b/structure/remote-workspace.md @@ -1,11 +1,17 @@ -# Remote Workspace protocol +# Remote Workspace -`src/remote-control/` is an inactive protocol library. Importing it registers no HTTP route, opens no connection and starts no process or timer. Existing Remote Hub provider routing remains in `src/remote/` and is a separate capability. +`src/remote-control/` owns Remote Workspace contracts, explicit executor construction and Hub session adapters. No module is registered with server startup in this layer. Existing Remote Hub provider routing remains in `src/remote/` and is a separate capability. -`src/remote-control/protocol.ts` owns versioned frame, identity and capability contracts. `src/remote-control/crypto.ts` uses Ed25519 signatures, P-256 ephemeral agreement and directional AES-GCM counters. `src/remote-control/workspace-agent-protocol.ts` bounds and parses control envelopes. `src/remote-control/workspace-tools.ts` describes the remote tool namespace and capability mapping. +`src/remote-control/protocol.ts` owns frame and identity contracts. `src/remote-control/crypto.ts` implements signed handshakes and directional encryption. `src/remote-control/workspace-agent-protocol.ts` parses bounded control messages; `src/remote-control/workspace-rpc-framing.ts` bounds reassembly allocation, count and expiry. Importing these modules starts no process or timer; incomplete reassembly owns expiry timers after an explicit call. -`src/remote-control/workspace-rpc-framing.ts` fragments logical messages and bounds reassembly size, count and expiry. Expiry timers exist only after explicit incomplete-fragment acceptance. `src/remote-control/workspace-utf8.ts` bounds text without splitting surrogate pairs. +`src/remote-control/workspace-agent-connection.ts` intersects presence with enrollment authority and negotiates explicit session grants. `src/remote-control/workspace-rpc.ts` snapshots session/device/root/capabilities and rejects mismatches before invoking the executor. The paired Hub is trusted to select an approved root over authenticated WSS; workspace control traffic is not an untrusted opaque relay protocol. -`src/remote-control/host.ts` accepts an explicitly supplied terminal factory. Authenticated application traffic can invoke that factory; no production factory is supplied here. `src/remote-control/relay.ts` forwards opaque envelopes after its caller authorizes the peer. Neither adapter is wired into server startup. +`src/remote-control/workspace-executor.ts` checks approved root identity, relative paths, file size and write preconditions. Its optional command runner lives in `src/remote-control/workspace-command-runner.ts`. Linux uses bubblewrap outside writable workspace roots and checks executable/parent permissions before invocation. The official Windows and macOS native helpers refuse commands; file tools remain independent of command availability. -The public exports in `src/remote-control/index.ts` expose only this foundation. Device enrollment, executor operations and UI activation are not part of this layer. Tests in `tests/clients/remote-control-prototype.test.ts`, `tests/clients/remote-workspace-rpc-framing.test.ts` and `tests/clients/remote-workspace-protocol.test.ts` cover the protocol contracts; they do not prove platform command confinement. +`src/remote-control/workspace-hub.ts`, `src/remote-control/workspace-device.ts` and `src/remote-control/workspace-sessions.ts` own separate persisted state. `src/remote-control/workspace-secret-store.ts` requires private permissions and rejects access failures rather than treating them as first-run absence. Publication reuses `src/config/atomic-write.ts`; workspace file publication uses the remote-workspace publisher in `src/lib/windows-atomic-replace.ts`. + +`src/remote-control/workspace-runtime.ts` is the lazy composition owner for Hub services. Codex, Claude and Pi adapters keep model processes on the Hub and expose selected remote tools. Their source configuration is not evidence of live CLI confinement. `src/cli/remote-workspace.ts` contains explicit executor pair/agent/status handling; it is not yet registered by this layer. + +The optional terminal prototype in `src/remote-control/host.ts` invokes only a caller-supplied factory after authenticated traffic. `src/remote-control/relay.ts` routes opaque prototype envelopes after caller authorization. Neither is a production terminal service. + +Regression coverage lives in `tests/clients/remote-workspace-session-binding.test.ts`, `tests/clients/remote-workspace-secret-store.test.ts` and the adjacent protocol, agent-wire, device, hub, sessions and command-runner tests. Real CLI and native confinement tests require their explicit environments; generic suite success does not certify those paths. Windows command support remains unavailable pending a verified lifecycle owner. diff --git a/structure/runtime.md b/structure/runtime.md index 49a5fb6483..e71dad34f0 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -188,3 +188,5 @@ not an authentication or entitlement decision. Codex display-cache expiry, retained main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history). + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](remote-workspace.md). diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 5acafbf63b..839cd4a82a 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -57,3 +57,5 @@ does not cover ordinary requests, streaming, retries, or per-hop redirect review Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and redirect blocking, but cannot inherit DNS classification or peer pinning without a verified-peer executor contract. Main-request migration must not treat that branch as fixed-transport equivalent. + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 2d7bd85db6..850b276cda 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -507,3 +507,5 @@ deprecated, sunset, decommissioned, or no longer available). An unrelated applic not retried. > Decision record: [ADR-0071](../decisions/ADR-0071-combo-streaming-commit-boundary.md) + +The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). diff --git a/tests/clients/remote-workspace-agent-wire.test.ts b/tests/clients/remote-workspace-agent-wire.test.ts new file mode 100644 index 0000000000..0b4f2c9f95 --- /dev/null +++ b/tests/clients/remote-workspace-agent-wire.test.ts @@ -0,0 +1,324 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + RemoteWorkspaceExecutor, + RemoteWorkspaceExecutorAgentConnection, + RemoteWorkspaceHubAgentConnection, + RemoteControlClientHandshake, + generateRemoteControlIdentityKeyPair, + parseRemoteWorkspaceAgentMessage, + parseRemoteWorkspaceHubMessage, + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + serializeRemoteWorkspaceAgentMessage, + serializeRemoteWorkspaceHubMessage, + type RemoteWorkspaceControlSocket, + type RemoteWorkspaceCommandRunner, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture(commandRunner?: RemoteWorkspaceCommandRunner) { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-agent-wire-")); + roots.push(root); + const workspace = join(root, "computer-2"); + mkdirSync(workspace, { recursive: true }); + writeFileSync(join(workspace, "marker.txt"), "computer-2-only"); + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "workspace", path: workspace }], + commandRunner, + }); + let hub: RemoteWorkspaceHubAgentConnection; + let agent: RemoteWorkspaceExecutorAgentConnection; + const hubSocket: RemoteWorkspaceControlSocket = { + send(value) { void agent.receive(value); }, + close: () => agent.close(), + }; + const agentSocket: RemoteWorkspaceControlSocket = { + send: value => hub.receive(value), + close: () => hub.close(), + }; + hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + socket: hubSocket, + sessionOpenTimeoutMs: 1_000, + }); + agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId, + deviceIdentity, + hubPublicKey: hubIdentity.publicKey, + executor, + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + socket: agentSocket, + }); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: commandRunner + ? ["workspace.read", "workspace.write", "workspace.exec"] + : ["workspace.read", "workspace.write"], + })); + return { hub, agent, workspace, deviceId }; +} + +describe("remote workspace agent wire", () => { + test("does not become online or accept session traffic before capability presence", async () => { + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + socket: { send: () => {}, close: () => {} }, + }); + expect(hub.isOnline()).toBe(false); + await expect(hub.openSession({ sessionId: randomUUID(), rootId: "workspace", profile: "codex", capabilities: hub.capabilities() })) + .rejects.toThrow("offline"); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + expect(hub.isOnline()).toBe(true); + expect(() => hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + }))).toThrow("duplicate presence"); + hub.close(); + }); + + test("cancels a session handshake immediately instead of waiting for its timeout", async () => { + const deviceId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const sent: string[] = []; + const hub = new RemoteWorkspaceHubAgentConnection({ + deviceId, + devicePublicKey: deviceIdentity.publicKey, + hubIdentity, + socket: { send: value => { sent.push(value); }, close: () => {} }, + sessionOpenTimeoutMs: 30_000, + }); + hub.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + const sessionId = randomUUID(); + const opening = hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: hub.capabilities() }); + await hub.closeSession(sessionId, "cancelled by user"); + await expect(opening).rejects.toThrow("cancelled by user"); + expect(sent.map(message => parseRemoteWorkspaceHubMessage(message).type)) + .toEqual(["presence_ack", "session_open", "session_close"]); + hub.close(); + }); + + test("opens an authenticated encrypted session and executes on the OCX-only device", async () => { + const state = fixture(); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ + sessionId, + rootId: "workspace", + profile: "codex", capabilities: state.hub.capabilities() }); + const result = await transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "read_file", + arguments: { path: "marker.txt" }, + }); + expect(result).toMatchObject({ ok: true, value: { content: "computer-2-only" } }); + await state.hub.closeSession(sessionId); + expect(transport.isOnline(state.deviceId)).toBe(false); + }); + + test("discards an endpoint when sending session acceptance fails", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-agent-accept-failure-")); + roots.push(root); + const deviceId = randomUUID(); + const sessionId = randomUUID(); + const hubIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "workspace", path: root }], + }); + const handshake = RemoteControlClientHandshake.create({ + sessionId, + deviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write"], + accountPrivateKey: hubIdentity.privateKey, + }); + const sent: string[] = []; + let failAcceptance = true; + const agent = new RemoteWorkspaceExecutorAgentConnection({ + deviceId, + deviceIdentity, + hubPublicKey: hubIdentity.publicKey, + executor, + capabilities: ["workspace.read", "workspace.write"], + socket: { + send(value) { + const message = parseRemoteWorkspaceAgentMessage(value); + if (message.type === "session_accept" && failAcceptance) { + failAcceptance = false; + throw new Error("socket send failed"); + } + sent.push(value); + }, + close() {}, + }, + }); + const open = serializeRemoteWorkspaceHubMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "session_open", + rootId: "workspace", + clientHello: handshake.hello, + }); + await agent.receive(open); + await agent.receive(open); + expect(sent.map(value => parseRemoteWorkspaceAgentMessage(value).type)) + .toEqual(["session_reject", "session_accept"]); + agent.close(); + }); + + test("fails pending and active work closed when the executor disconnects", async () => { + const state = fixture(); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "pi", capabilities: state.hub.capabilities() }); + state.hub.close("executor disconnected"); + expect(transport.isOnline(state.deviceId)).toBe(false); + await expect(transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "read_file", + arguments: { path: "marker.txt" }, + })).rejects.toThrow("offline"); + }); + + test("session close aborts an active command on the executor", async () => { + let started!: () => void; + const active = new Promise(resolve => { started = resolve; }); + let cancelled = false; + const state = fixture({ + async run(request) { + started(); + return await new Promise((_resolve, reject) => { + const abort = () => { + cancelled = true; + reject(new Error("cancelled")); + }; + request.signal?.addEventListener("abort", abort, { once: true }); + if (request.signal?.aborted) abort(); + }); + }, + }); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: state.hub.capabilities() }); + const invocation = transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["sleep", "60"] }, + }); + await active; + await state.hub.closeSession(sessionId); + await expect(invocation).rejects.toThrow("closed"); + await Bun.sleep(5); + expect(cancelled).toBe(true); + }); + + test("bounds concurrent Hub requests while serializing operations on one executor", async () => { + let started = 0; + const state = fixture({ + async run(request) { + started += 1; + return await new Promise((_resolve, reject) => { + const abort = () => reject(new Error("cancelled")); + request.signal?.addEventListener("abort", abort, { once: true }); + if (request.signal?.aborted) abort(); + }); + }, + }); + const sessionId = randomUUID(); + const transport = await state.hub.openSession({ sessionId, rootId: "workspace", profile: "codex", capabilities: state.hub.capabilities() }); + const pending = Array.from({ length: 8 }, () => transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["wait"] }, + }).catch(error => error)); + for (let count = 0; count < 100 && started < 1; count += 1) await Bun.sleep(1); + expect(started).toBe(1); + await expect(transport.invoke({ + requestId: randomUUID(), + sessionId, + executorDeviceId: state.deviceId, + rootId: "workspace", + tool: "exec", + arguments: { command: ["overflow"] }, + })).rejects.toThrow("request limit"); + await state.hub.closeSession(sessionId); + await Promise.all(pending); + }); + + test("rejects malformed, oversized, and non-workspace control messages", () => { + expect(() => parseRemoteWorkspaceHubMessage("{}")) + .toThrow("unsupported remote workspace agent protocol"); + expect(() => parseRemoteWorkspaceAgentMessage(JSON.stringify({ + version: 1, + type: "heartbeat", + nonce: "ok", + extra: true, + }))).toThrow("fields"); + expect(() => parseRemoteWorkspaceAgentMessage("x".repeat(100 * 1024))) + .toThrow("length"); + }); +}); + +test("a read-only negotiated session rejects writes before touching its approved root", async () => { + const state = fixture(); + const sessionId = randomUUID(); + try { + const transport = await state.hub.openSession({ + sessionId, rootId: "workspace", profile: "codex", capabilities: ["workspace.read"], + }); + const result = await transport.invoke({ + requestId: randomUUID(), sessionId, executorDeviceId: state.deviceId, + rootId: "workspace", tool: "read_file", arguments: { path: "marker.txt" }, + }); + expect(result.ok).toBe(true); + await expect(transport.invoke({ + requestId: randomUUID(), sessionId, executorDeviceId: state.deviceId, + rootId: "workspace", tool: "write_file", arguments: { path: "new.txt", content: "denied", expectedSha256: null }, + })).rejects.toThrow(); + } finally { state.hub.close(); state.agent.close(); } +}); diff --git a/tests/clients/remote-workspace-app-server.integration.test.ts b/tests/clients/remote-workspace-app-server.integration.test.ts new file mode 100644 index 0000000000..54f7115452 --- /dev/null +++ b/tests/clients/remote-workspace-app-server.integration.test.ts @@ -0,0 +1,426 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + REMOTE_WORKSPACE_TOOL_NAMESPACE, + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, + RemoteControlClientHandshake, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, + acceptRemoteControlClientHello, + generateRemoteControlIdentityKeyPair, + remoteWorkspaceThreadStartParams, + startRemoteWorkspaceToolBridge, + type RemoteWorkspaceTransport, + type RemoteWorkspaceCommandRunner, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +interface JsonMessage { + id?: string | number; + method?: string; + params?: Record; + result?: Record; + error?: Record; +} + +interface CapturedResponsesRequest { + input?: Array>; + tools?: Array>; +} + +function sse(events: unknown[]): string { + return events.map(event => { + const type = (event as { type: string }).type; + return `event: ${type}\ndata: ${JSON.stringify(event)}\n\n`; + }).join(""); +} + +function completed(id: string): unknown { + return { + type: "response.completed", + response: { + id, + usage: { + input_tokens: 0, + input_tokens_details: null, + output_tokens: 0, + output_tokens_details: null, + total_tokens: 0, + }, + }, + }; +} + +function responseCreated(id: string): unknown { + return { type: "response.created", response: { id } }; +} + +class JsonLinePeer { + private readonly reader: ReadableStreamDefaultReader; + private buffer = ""; + + constructor( + stdout: ReadableStream, + private readonly stdin: FileSink, + ) { + this.reader = stdout.getReader(); + } + + send(message: unknown): void { + this.stdin.write(`${JSON.stringify(message)}\n`); + this.stdin.flush(); + } + + async next(timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (true) { + const newline = this.buffer.indexOf("\n"); + if (newline >= 0) { + const line = this.buffer.slice(0, newline).replace(/\r$/, ""); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + return JSON.parse(line) as JsonMessage; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("timed out waiting for Codex App Server JSON-RPC"); + const next = await Promise.race([ + this.reader.read(), + new Promise((_, reject) => setTimeout( + () => reject(new Error("timed out waiting for Codex App Server output")), + remaining, + )), + ]); + if (next.done) throw new Error("Codex App Server closed its output"); + this.buffer += new TextDecoder().decode(next.value, { stream: true }); + } + } + + async waitFor(predicate: (message: JsonMessage) => boolean): Promise { + for (let count = 0; count < 200; count += 1) { + const message = await this.next(); + if (predicate(message)) return message; + } + throw new Error("Codex App Server did not emit the expected message"); + } +} + +const codexBin = process.env.OCX_CODEX_BIN; +const appServerTest = codexBin ? test : test.skip; + +const localIntegrationCommandRunner: RemoteWorkspaceCommandRunner = { + async run(request) { + const child = Bun.spawn(request.command, { + cwd: request.cwd, + env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", HOME: request.cwd }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, request.timeoutMs); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (timedOut) throw new Error("local integration command timed out"); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > request.maxOutputBytes) { + throw new Error("local integration command output limit exceeded"); + } + return { stdout, stderr, exitCode }; + } finally { + clearTimeout(timer); + } + }, +}; + +appServerTest("real Codex App Server delegates a dynamic workspace tool to Computer 2", async () => { + if (!codexBin || !existsSync(codexBin)) throw new Error("OCX_CODEX_BIN must identify a real Codex executable"); + const root = mkdtempSync(join(tmpdir(), "ocx-remote-app-server-")); + const mainHome = join(root, "main-home"); + const mainCodexHome = join(root, "main-codex"); + const mainOcxHome = join(root, "main-ocx"); + const sandboxBin = join(root, "sandbox-bin"); + const coordinatorIsolation = join(root, "coordinator-isolation"); + const executorRoot = join(root, "computer-2-workspace"); + const hubSecret = join(root, "hub-secret.txt"); + for (const path of [mainHome, mainCodexHome, mainOcxHome, coordinatorIsolation, executorRoot, sandboxBin]) { + mkdirSync(path, { recursive: true }); + } + linkSync(codexBin, join(sandboxBin, "codex-linux-sandbox")); + writeFileSync(join(coordinatorIsolation, "integration-marker.txt"), "main-unchanged"); + writeFileSync(hubSecret, "HUB-SECRET-MUST-NOT-LEAK"); + + const requestBodies: unknown[] = []; + let responseIndex = 0; + const modelServer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (request.method !== "POST" || !url.pathname.endsWith("/responses")) { + return Response.json({ error: "not_found" }, { status: 404 }); + } + const requestBody = await request.json() as CapturedResponsesRequest; + requestBodies.push(requestBody); + responseIndex += 1; + if (responseIndex === 1) { + const hasCodeMode = JSON.stringify(requestBody.input).includes('"name":"functions"') + && JSON.stringify(requestBody.input).includes('"name":"exec"'); + if (!hasCodeMode) { + return new Response(sse([ + responseCreated("resp-remote-no-tool"), + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-no-remote-tool", + content: [{ type: "output_text", text: "Remote tool unavailable" }], + }, + }, + completed("resp-remote-no-tool"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + return new Response(sse([ + responseCreated("resp-remote-1"), + { + type: "response.output_item.done", + item: { + type: "custom_tool_call", + call_id: "remote-exec-call", + namespace: "functions", + name: "exec", + input: [ + "const result = await tools.mcp__ocx_remote_workspace__exec({", + " command: ['/bin/sh', '-lc', \"printf 'computer-2' > integration-marker.txt; printf 'executor-cwd:'; pwd\"],", + " cwd: '.',", + " timeoutMs: 5000,", + "});", + "let localProbe;", + `try { localProbe = await tools.exec_command({ cmd: ${JSON.stringify(`cat -- ${JSON.stringify(hubSecret)}`)} }); }`, + "catch (error) { localProbe = String(error); }", + "text(JSON.stringify({ result, localProbe }));", + ].join("\n"), + }, + }, + completed("resp-remote-1"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + if (responseIndex === 2) { + return new Response(sse([ + responseCreated("resp-remote-2"), + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-remote-done", + content: [{ type: "output_text", text: "Remote workspace complete" }], + }, + }, + completed("resp-remote-2"), + ]), { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ error: "unexpected_request" }, { status: 500 }); + }, + }); + + const config = [ + 'model = "gpt-5.6-sol"', + 'model_provider = "ocx_remote_spike"', + 'approval_policy = "never"', + '', + '[model_providers.ocx_remote_spike]', + 'name = "OCX Remote Spike"', + `base_url = "${new URL("/v1", modelServer.url).toString().replace(/\/$/, "")}"`, + 'env_key = "OCX_REMOTE_SPIKE_API_KEY"', + 'wire_api = "responses"', + 'supports_websockets = false', + '', + ].join("\n"); + writeFileSync(join(mainCodexHome, "config.toml"), config, { mode: 0o600 }); + + const deviceId = randomUUID(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "selected-folder", path: executorRoot }], + commandRunner: localIntegrationCommandRunner, + }); + const accountIdentity = generateRemoteControlIdentityKeyPair(); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const transportSessionId = randomUUID(); + const handshake = RemoteControlClientHandshake.create({ + sessionId: transportSessionId, + deviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + accountPrivateKey: accountIdentity.privateKey, + }); + const accepted = acceptRemoteControlClientHello(handshake.hello, { + expectedSessionId: transportSessionId, + expectedDeviceId: deviceId, + accountPublicKey: accountIdentity.publicKey, + devicePrivateKey: deviceIdentity.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write", "workspace.exec"], + }); + let encryptedTransport: EncryptedRemoteWorkspaceTransport; + let executorEndpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + encryptedTransport = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: deviceId, + cipher: handshake.complete(accepted.hello, deviceIdentity.publicKey), + sendCiphertext: value => executorEndpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + executorEndpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: deviceId, + sessionId: transportSessionId, + rootId: "selected-folder", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + cipher: accepted.cipher, + executor, + sendCiphertext: value => encryptedTransport.receiveCiphertext(value), + }); + const transport: RemoteWorkspaceTransport = encryptedTransport; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const threadRef = { id: "" }; + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: () => threadRef.id, + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const mcpTokenEnv = "OCX_REMOTE_WORKSPACE_MCP_TOKEN"; + const mcpPrefix = `mcp_servers.${REMOTE_WORKSPACE_TOOL_NAMESPACE}`; + + const appServer = Bun.spawn([ + codexBin, + "-c", `${mcpPrefix}.url=${JSON.stringify(`${bridge.url}/mcp`)}`, + "-c", `${mcpPrefix}.bearer_token_env_var=${JSON.stringify(mcpTokenEnv)}`, + "-c", `${mcpPrefix}.required=true`, + "-c", `${mcpPrefix}.enabled_tools=["list_directory","read_file","write_file","exec"]`, + "-c", `${mcpPrefix}.default_tools_approval_mode="approve"`, + "app-server", "--listen", "stdio://", + ], { + cwd: coordinatorIsolation, + env: { + PATH: `${sandboxBin}:${process.env.PATH ?? "/usr/bin:/bin"}`, + HOME: mainHome, + CODEX_HOME: mainCodexHome, + OPENCODEX_HOME: mainOcxHome, + OCX_REMOTE_SPIKE_API_KEY: "test-only-not-a-real-key", + [mcpTokenEnv]: bridge.token, + }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + const stderrPromise = new Response(appServer.stderr).text(); + const peer = new JsonLinePeer(appServer.stdout, appServer.stdin); + + try { + peer.send({ + method: "initialize", + id: 0, + params: { + clientInfo: { name: "ocx_remote_workspace_test", title: "OCX Remote Workspace Test", version: "0.1.0" }, + capabilities: { experimentalApi: true }, + }, + }); + const initialized = await peer.waitFor(message => message.id === 0); + expect(initialized.error).toBeUndefined(); + peer.send({ method: "initialized", params: {} }); + + peer.send({ + method: "thread/start", + id: 1, + params: { + ...remoteWorkspaceThreadStartParams({ + executorName: "Computer 2", + coordinatorIsolationPath: coordinatorIsolation, + tools: ["list_directory", "read_file", "write_file", "exec"], + mcp: { + url: `${bridge.url}/mcp`, + bearerTokenEnvVar: mcpTokenEnv, + hubRuntimeReadPaths: [dirname(realpathSync(codexBin)), sandboxBin], + }, + }), + model: "gpt-5.6-sol", + modelProvider: "ocx_remote_spike", + ephemeral: true, + }, + }); + const threadResponse = await peer.waitFor(message => message.id === 1); + expect(threadResponse.error).toBeUndefined(); + const thread = threadResponse.result?.thread as { id?: string } | undefined; + if (!thread?.id) throw new Error("Codex App Server did not return a thread ID"); + threadRef.id = thread.id; + coordinator.register({ + sessionId: transportSessionId, + threadId: thread.id, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "selected-folder", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + + peer.send({ + method: "turn/start", + id: 2, + params: { + threadId: thread.id, + input: [{ type: "text", text: "Create the marker in the selected remote workspace." }], + approvalPolicy: "never", + }, + }); + + let turnCompleted = false; + for (let count = 0; count < 200 && !turnCompleted; count += 1) { + const message = await peer.next(); + if (message.method === "item/tool/call" && message.id !== undefined) { + const response = await coordinator.handle({ + method: "item/tool/call", + id: message.id, + params: message.params, + }); + peer.send(response); + } + if (message.method === "turn/completed") turnCompleted = true; + if (message.id === 2 && message.error) throw new Error(`turn/start failed: ${JSON.stringify(message.error)}`); + } + + expect(turnCompleted).toBe(true); + expect(existsSync(join(executorRoot, "integration-marker.txt"))).toBe(true); + expect(readFileSync(join(executorRoot, "integration-marker.txt"), "utf8")).toBe("computer-2"); + expect(readFileSync(join(coordinatorIsolation, "integration-marker.txt"), "utf8")).toBe("main-unchanged"); + expect(requestBodies).toHaveLength(2); + expect(JSON.stringify(requestBodies[0])).toContain(REMOTE_WORKSPACE_TOOL_NAMESPACE); + // Current Codex consolidates MCP into the sandboxed functions.exec code-mode tool. + // Executing the nested remote helper above proves the registered MCP server is callable. + expect(JSON.stringify((requestBodies[0] as CapturedResponsesRequest).input)).toContain('"name":"functions"'); + const followUp = requestBodies[1] as CapturedResponsesRequest; + const toolOutput = followUp.input?.find(item => item.type === "custom_tool_call_output"); + expect(toolOutput).toBeDefined(); + const serializedToolOutput = JSON.stringify(toolOutput); + expect(serializedToolOutput).toContain("executor-cwd:"); + expect(serializedToolOutput).toContain(executorRoot); + expect(serializedToolOutput).not.toContain(coordinatorIsolation); + expect(serializedToolOutput).not.toContain("HUB-SECRET-MUST-NOT-LEAK"); + } finally { + encryptedTransport.close(); + appServer.kill(); + await appServer.exited; + await stderrPromise; + await modelServer.stop(true); + await bridge.stop(); + removeTreeWithRetry(root); + } +}, 30_000); diff --git a/tests/clients/remote-workspace-claude.integration.test.ts b/tests/clients/remote-workspace-claude.integration.test.ts new file mode 100644 index 0000000000..c29cda1e20 --- /dev/null +++ b/tests/clients/remote-workspace-claude.integration.test.ts @@ -0,0 +1,166 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ClaudeRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function sse(events: Array<{ event: string; data: unknown }>): Response { + return new Response(events.map(item => `event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream", "cache-control": "no-store" }, + }); +} + +function messageStart(id: string): { event: string; data: unknown } { + return { + event: "message_start", + data: { + type: "message_start", + message: { + id, + type: "message", + role: "assistant", + model: "claude-test", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 8, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 1 }, + }, + }, + }; +} + +const claudePath = process.env.OCX_CLAUDE_BIN; +const claudeTest = claudePath ? test : test.skip; + +claudeTest("real Claude Code uses only the selected remote executor MCP tools", async () => { + if (!claudePath) return; + const root = mkdtempSync(join(tmpdir(), "ocx-remote-claude-real-")); + roots.push(root); + const workspace = join(root, "executor"); + const home = join(root, "home"); + mkdirSync(workspace); + mkdirSync(home); + writeFileSync(join(workspace, "marker.txt"), "only-on-computer-2"); + const requestBodies: Array> = []; + const model = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith("/count_tokens")) return Response.json({ input_tokens: 8 }); + if (!url.pathname.endsWith("/messages")) return Response.json({ error: { message: "not found" } }, { status: 404 }); + const body = await req.json() as Record; + requestBodies.push(body); + if (requestBodies.length === 1) { + return sse([ + messageStart("msg_remote_tool"), + { event: "content_block_start", data: { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_remote_read", name: "mcp__ocx_remote_workspace__read_file", input: {} } } }, + { event: "content_block_delta", data: { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "{\"path\":\"marker.txt\"}" } } }, + { event: "content_block_stop", data: { type: "content_block_stop", index: 0 } }, + { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: 8 } } }, + { event: "message_stop", data: { type: "message_stop" } }, + ]); + } + return sse([ + messageStart("msg_remote_answer"), + { event: "content_block_start", data: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } }, + { event: "content_block_delta", data: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Read only-on-computer-2 from the executor." } } }, + { event: "content_block_stop", data: { type: "content_block_stop", index: 0 } }, + { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 12 } } }, + { event: "message_stop", data: { type: "message_stop" } }, + ]); + }, + }); + const deviceId = crypto.randomUUID(); + const executor = new RemoteWorkspaceExecutor({ deviceId, roots: [{ id: "root", path: workspace }] }); + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: candidate => candidate === deviceId, + invoke: request => executor.invoke(request), + }); + const events: string[] = []; + const factory = new ClaudeRemoteWorkspaceRuntimeFactory({ + command: [claudePath], + version: "real-smoke", + env: { + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + CLAUDE_CONFIG_DIR: join(home, ".claude"), + ANTHROPIC_BASE_URL: model.url.toString().replace(/\/$/, ""), + ANTHROPIC_AUTH_TOKEN: "test-only-token", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }, + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId, + deviceName: "Computer 2", + rootId: "root", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + coordinator, + emit: (type, text) => events.push(`${type}:${text}`), + }); + const unregister = coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "root", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + }); + try { + await handle.prompt("Read marker.txt from the remote workspace."); + expect(events.some(event => event.includes("Read only-on-computer-2 from the executor."))).toBe(true); + expect(JSON.stringify(requestBodies.at(-1))).toContain("only-on-computer-2"); + expect(JSON.stringify(requestBodies)).not.toContain("remote_exec"); + const persistedThreadId = handle.threadId; + unregister(); + await handle.stop(); + const resumed = await factory.start({ + sessionId: "session-1", + deviceId, + deviceName: "Computer 2", + rootId: "root", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + resumeThreadId: persistedThreadId, + coordinator, + emit: (type, text) => events.push(`${type}:${text}`), + }); + const unregisterResumed = coordinator.register({ + sessionId: "session-1", + threadId: resumed.threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "root", + capabilities: ["workspace.read", "workspace.write"], + tools: ["list_directory", "read_file", "write_file"], + }); + try { + await resumed.prompt("Continue the same remote session."); + expect(resumed.threadId).toBe(persistedThreadId); + expect(JSON.stringify(requestBodies.at(-1))).toContain("Continue the same remote session."); + } finally { + unregisterResumed(); + await resumed.stop(); + } + } finally { + unregister(); + await handle.stop(); + await model.stop(true); + } +}, 30_000); diff --git a/tests/clients/remote-workspace-cli-runtimes.test.ts b/tests/clients/remote-workspace-cli-runtimes.test.ts new file mode 100644 index 0000000000..33785b64e7 --- /dev/null +++ b/tests/clients/remote-workspace-cli-runtimes.test.ts @@ -0,0 +1,67 @@ +import { fixturePath } from "../helpers/repo-root"; +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + ClaudeRemoteWorkspaceRuntimeFactory, + PiRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + type RemoteWorkspaceSessionEvent, +} from "../../src/remote-control"; + +function coordinator(): RemoteWorkspaceCoordinator { + return new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true, value: null }; }, + }); +} + +test("Claude runtime keeps the CLI on the Hub and emits its answer", async () => { + const events: Array<{ type: RemoteWorkspaceSessionEvent["type"]; text: string }> = []; + const factory = new ClaudeRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, fixturePath("fake-claude-stream.ts")], + version: "test", + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator: coordinator(), + emit: (type, text) => events.push({ type, text }), + }); + try { + await handle.prompt("hello remote"); + expect(events).toEqual([{ type: "assistant", text: "Hub answer: hello remote" }]); + } finally { + await handle.stop(); + } +}); + +const piPath = process.env.OCX_PI_BIN; +const piTest = piPath ? test : test.skip; + +piTest("real Pi RPC starts with only the explicit Remote Workspace extension", async () => { + if (!piPath) return; + const factory = new PiRemoteWorkspaceRuntimeFactory({ command: [piPath], version: "test" }); + const startOptions = { + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator: coordinator(), + emit: () => {}, + } as const; + const handle = await factory.start(startOptions); + expect(handle.threadId).toMatch(/^[0-9a-f-]{36}$/); + const threadId = handle.threadId; + await handle.stop(); + const resumed = await factory.start({ ...startOptions, resumeThreadId: threadId }); + expect(resumed.threadId).toBe(threadId); + await resumed.stop(); +}); diff --git a/tests/clients/remote-workspace-cli.test.ts b/tests/clients/remote-workspace-cli.test.ts new file mode 100644 index 0000000000..2358d59133 --- /dev/null +++ b/tests/clients/remote-workspace-cli.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { Readable } from "node:stream"; +import { runRemoteWorkspaceCommand } from "../../src/cli/remote-workspace"; +import { + generateRemoteControlIdentityKeyPair, + type RemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceStateStore, +} from "../../src/remote-control"; + +class MemoryStore implements RemoteWorkspaceDeviceStateStore { + constructor(public state: RemoteWorkspaceDeviceState | null = null) {} + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceDeviceState) { this.state = structuredClone(state); } +} + +function state(): RemoteWorkspaceDeviceState { + return { + version: 1, + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), + deviceName: "Computer 2", + devicePlatform: "linux-x64", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: generateRemoteControlIdentityKeyPair(), + hubPublicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project", path: "/work/project" }], + toolchainRoots: [], + }; +} + +describe("ocx remote-workspace", () => { + test("reads the one-time pairing code from stdin and never requires it in argv", async () => { + const store = new MemoryStore(); + const expected = state(); + let received: Record | null = null; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const code = await runRemoteWorkspaceCommand([ + "pair", + "https://hub.example.test", + "--root", "/work/project", + "--root", "/work/other", + "--executor-helper", "/opt/opencodex/remote-workspace-helper", + "--name", "Computer 2", + "--pairing-code-stdin", + "--json", + ], { + store, + stdinImpl: Readable.from(["ABCD-EFGH-JKLM\n"]), + pair: async options => { + received = options as unknown as Record; + return expected; + }, + }); + expect(code).toBe(0); + expect(received).toMatchObject({ + hubUrl: "https://hub.example.test", + pairingCode: "ABCD-EFGH-JKLM", + name: "Computer 2", + roots: [{ path: "/work/project" }, { path: "/work/other" }], + nativeHelperPath: "/opt/opencodex/remote-workspace-helper", + }); + expect(JSON.stringify(log.mock.calls)).not.toContain(expected.deviceToken); + expect(JSON.stringify(log.mock.calls)).not.toContain(expected.deviceIdentity.privateKey); + } finally { + log.mockRestore(); + } + }); + + test("status reports local executor identity without secret material", async () => { + const saved = state(); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runRemoteWorkspaceCommand(["status", "--json"], { store: new MemoryStore(saved) })).toBe(0); + const output = JSON.stringify(log.mock.calls); + expect(output).toContain("Computer 2"); + expect(output).toContain("/work/project"); + expect(output).not.toContain(saved.deviceToken); + expect(output).not.toContain(saved.deviceIdentity.privateKey); + } finally { + log.mockRestore(); + } + }); + + test("agent hands the paired state to the reconnecting runner", async () => { + const saved = state(); + const controller = new AbortController(); + let received: RemoteWorkspaceDeviceState | null = null; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + const code = await runRemoteWorkspaceCommand(["agent"], { + store: new MemoryStore(saved), + signal: controller.signal, + runAgent: async options => { received = options.state; }, + }); + expect(code).toBe(0); + expect(received?.deviceId).toBe(saved.deviceId); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/tests/clients/remote-workspace-codex-runtime.test.ts b/tests/clients/remote-workspace-codex-runtime.test.ts new file mode 100644 index 0000000000..b9d259c2eb --- /dev/null +++ b/tests/clients/remote-workspace-codex-runtime.test.ts @@ -0,0 +1,120 @@ +import { repoPath } from "../helpers/repo-root"; +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + CodexRemoteWorkspaceRuntimeFactory, + RemoteWorkspaceCoordinator, + type RemoteWorkspaceSessionEvent, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; + +test("Codex Remote Workspace runtime owns the model process on the Hub", async () => { + const events: Array<{ type: RemoteWorkspaceSessionEvent["type"]; text: string }> = []; + const transport: RemoteWorkspaceTransport = { + isOnline: () => true, + async invoke() { return { ok: true, value: null }; }, + }; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + version: "0.146.0-test", + env: { + FAKE_CODEX_SCRIPT: JSON.stringify({ + turns: [{ + notifications: [{ + method: "item/completed", + params: { item: { id: "answer-1", type: "agentMessage", text: "Done from Computer 1" } }, + }], + }], + }), + }, + }); + + expect(await factory.available()).toEqual({ available: true, version: "0.146.0-test" }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator, + emit: (type, text) => events.push({ type, text }), + }); + const unregister = coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + try { + await handle.prompt("Inspect the remote project"); + expect(events).toContainEqual({ type: "assistant", text: "Done from Computer 1" }); + } finally { + unregister(); + await handle.stop(); + } +}); + +test("Codex Remote Workspace stop interrupts a held turn", async () => { + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }); + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + env: { FAKE_CODEX_SCRIPT: JSON.stringify({ turns: [{ heldUntilInterrupt: true }] }) }, + }); + const handle = await factory.start({ + sessionId: "session-1", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + coordinator, + emit: () => {}, + }); + coordinator.register({ + sessionId: "session-1", + threadId: handle.threadId, + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const turn = handle.prompt("Hold this turn").then(() => "resolved", () => "rejected"); + await new Promise(resolvePromise => setTimeout(resolvePromise, 30)); + await handle.stop(); + expect(await turn).toBe("rejected"); +}); + +test("Codex Remote Workspace resumes the persisted App Server thread ID", async () => { + const factory = new CodexRemoteWorkspaceRuntimeFactory({ + command: [process.execPath, repoPath("tests", "fake-codex-server.ts")], + }); + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { return { ok: true }; }, + }); + const handle = await factory.start({ + sessionId: "session-resume", + deviceId: "device-2", + deviceName: "Computer 2", + rootId: "root-2", + rootLabel: "Project", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + resumeThreadId: "thread-persisted", + coordinator, + emit: () => {}, + }); + expect(handle.threadId).toBe("thread-persisted"); + await handle.stop(); +}); diff --git a/tests/clients/remote-workspace-command-runner.test.ts b/tests/clients/remote-workspace-command-runner.test.ts new file mode 100644 index 0000000000..9595b2b4f3 --- /dev/null +++ b/tests/clients/remote-workspace-command-runner.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + RemoteWorkspaceExecutor, + createLinuxRemoteWorkspaceCommandRunner, + createNativeRemoteWorkspaceCommandRunner, + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandArgv, + linuxRemoteWorkspaceCommandRunnerAvailable, + nativeRemoteWorkspaceCommandRunnerAvailable, + pinRemoteWorkspaceNativeHelper, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-bwrap-")); + roots.push(root); + const workspace = join(root, "workspace"); + const outside = join(root, "outside-secret.txt"); + mkdirSync(join(workspace, "project"), { recursive: true }); + writeFileSync(outside, "must-not-be-visible"); + return { root, workspace, outside }; +} + +function fakeNativeHelper(root: string, response: Record, requestPath?: string) { + const path = join(root, "ocx-remote-helper-test"); + const encodedResponse = JSON.stringify(response).replaceAll("'", "'\\''"); + const requestCapture = requestPath + ? `input=$(cat); printf '%s' "$input" > '${requestPath.replaceAll("'", "'\\''")}'` + : "cat >/dev/null"; + writeFileSync(path, `#!/bin/sh\nset -eu\n${requestCapture}\nprintf '%s\\n' '${encodedResponse}'\n`, { mode: 0o700 }); + chmodSync(path, 0o700); + return pinRemoteWorkspaceNativeHelper(path); +} + +describe("remote workspace Linux command sandbox", () => { + test("rejects a sandbox executable inside a writable workspace before probing", () => { + const state = fixture(); + const path = join(state.workspace, "bwrap"); + writeFileSync(path, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + let probes = 0; + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: path, writableRoots: [state.workspace], + probe() { probes += 1; return true; }, + })).toBe(false); + expect(probes).toBe(0); + expect(() => linuxRemoteWorkspaceCommandArgv({ + command: ["true"], root: state.workspace, cwd: state.workspace, + timeoutMs: 1_000, maxOutputBytes: 4096, + }, { bubblewrapPath: path })).toThrow("outside every writable"); + }); + + test("Windows command capability stays unavailable even with a positive probe seam", () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { version: 1, ok: true, probe: true }); + let probes = 0; + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + platform: "win32", helper, + writableRoots: [state.workspace], probe() { probes += 1; return { version: 1, ok: true, probe: true }; }, + })).toBe(false); + expect(probes).toBe(0); + }); + + test("builds a minimal bubblewrap argv with one writable workspace", () => { + const state = fixture(); + const argv = linuxRemoteWorkspaceCommandArgv({ + command: ["/bin/sh", "-lc", "pwd"], + root: state.workspace, + cwd: join(state.workspace, "project"), + timeoutMs: 1_000, + maxOutputBytes: 4_096, + }, { bubblewrapPath: process.execPath }); + expect(argv[0]).toBe(process.execPath); + expect(argv).toContain("--unshare-net"); + expect(argv).toContain("--clearenv"); + expect(argv).toContain("--bind"); + expect(argv).toContain(state.workspace); + expect(argv).toContain("/workspace/project"); + expect(argv).not.toContain(state.outside); + }); + + test("runs inside the selected root and cannot see an adjacent host file", async () => { + if (!linuxRemoteWorkspaceCommandRunnerAvailable()) return; + const state = fixture(); + const deviceId = randomUUID(); + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "root", path: state.workspace }], + commandRunner: createLinuxRemoteWorkspaceCommandRunner(), + }); + const result = await executor.invoke({ + requestId: randomUUID(), + sessionId: randomUUID(), + executorDeviceId: deviceId, + rootId: "root", + tool: "exec", + arguments: { + command: [ + "/bin/sh", + "-lc", + `test ! -e ${JSON.stringify(state.outside)} && printf sandboxed > marker.txt && pwd`, + ], + cwd: "project", + timeoutMs: 5_000, + }, + }); + expect(result.ok).toBe(true); + expect(result.value).toMatchObject({ exitCode: 0, cwd: "project" }); + expect(JSON.stringify(result.value)).toContain("/workspace/project"); + expect(readFileSync(join(state.workspace, "project", "marker.txt"), "utf8")).toBe("sandboxed"); + }); + + test("keeps exec disabled where an equivalent platform sandbox is unavailable", () => { + expect(createPlatformRemoteWorkspaceCommandRunner({ platform: "win32" })).toBeUndefined(); + expect(createPlatformRemoteWorkspaceCommandRunner({ platform: "darwin" })).toBeUndefined(); + }); + + test("advertises native exec only after a digest-pinned confinement probe", () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { version: 1, ok: true, probe: true }); + let probeRequest: unknown; + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe(request) { + probeRequest = request; + return { version: 1, ok: true, probe: true }; + }, + })).toBe(true); + expect(probeRequest).toEqual({ version: 1, operation: "probe" }); + expect(createPlatformRemoteWorkspaceCommandRunner({ + platform: "win32", + native: { + helper, + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: false, error: "not confined" }), + }, + })).toBeUndefined(); + writeFileSync(helper.path, "replaced", { mode: 0o700 }); + expect(nativeRemoteWorkspaceCommandRunnerAvailable({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + })).toBe(false); + }); + + test("sends native command authority over bounded stdin and decodes one strict result", async () => { + const state = fixture(); + const requestPath = join(state.root, "request.json"); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 7, + stdoutBase64: Buffer.from("native stdout").toString("base64"), + stderrBase64: Buffer.from("native stderr").toString("base64"), + }, requestPath); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + const result = await runner.run({ + command: ["/usr/bin/printf", "hello world"], + root: state.workspace, + cwd: join(state.workspace, "project"), + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }); + expect(result).toEqual({ exitCode: 7, stdout: "native stdout", stderr: "native stderr" }); + const request = JSON.parse(readFileSync(requestPath, "utf8")); + expect(request).toEqual({ + version: 1, + operation: "run", + root: state.workspace, + cwd: join(state.workspace, "project"), + command: ["/usr/bin/printf", "hello world"], + toolchainRoots: [], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + networkAccess: false, + }); + expect(JSON.stringify(request)).not.toContain(process.env.OPENAI_API_KEY ?? "__no_api_key__"); + }); + + test("rejects widened or malformed native helper responses", async () => { + const state = fixture(); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "@@not-base64@@", + stderrBase64: "", + }); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + await expect(runner.run({ + command: ["cmd.exe"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("invalid stdout"); + }); + + test("never advertises or invokes a native helper from inside a writable workspace", async () => { + const state = fixture(); + const helper = fakeNativeHelper(state.workspace, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "", + stderrBase64: "", + }); + expect(createPlatformRemoteWorkspaceCommandRunner({ + platform: "darwin", + native: { + helper, + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }, + })).toBeUndefined(); + + }); + + test("binds every native command to the runner's construction-time writable roots", async () => { + const state = fixture(); + const other = join(state.root, "other-workspace"); + mkdirSync(other); + const helper = fakeNativeHelper(state.root, { + version: 1, + ok: true, + exitCode: 0, + stdoutBase64: "", + stderrBase64: "", + }); + const runner = createNativeRemoteWorkspaceCommandRunner({ + helper, + platform: "darwin", + writableRoots: [state.workspace], + probe: () => ({ version: 1, ok: true, probe: true }), + }); + await expect(runner.run({ + command: ["/usr/bin/true"], + root: other, + cwd: other, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("outside the native runner grant"); + }); + + test("revalidates approved toolchain roots and rejects a later symlink substitution", () => { + const state = fixture(); + const realToolchain = join(state.root, "real-toolchain"); + const substituted = join(state.root, "toolchain"); + mkdirSync(realToolchain); + symlinkSync(realToolchain, substituted, process.platform === "win32" ? "junction" : "dir"); + expect(() => linuxRemoteWorkspaceCommandArgv({ + command: ["true"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 1_000, + maxOutputBytes: 4_096, + }, { + bubblewrapPath: process.execPath, + toolchainRoots: [substituted], + })).toThrow("remain a real directory"); + }); + + test("rejects a pre-existing hardlink before starting a workspace command", async () => { + const state = fixture(); + linkSync(state.outside, join(state.workspace, "outside-alias")); + const runner = createLinuxRemoteWorkspaceCommandRunner({ + bubblewrapPath: process.execPath, + spawn: (() => { throw new Error("sandbox spawn must not be reached"); }) as typeof Bun.spawn, + }); + await expect(runner.run({ + command: ["/bin/true"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 1_000, + maxOutputBytes: 4_096, + })).rejects.toThrow("hard-linked file"); + expect(readFileSync(state.outside, "utf8")).toBe("must-not-be-visible"); + }); + + test("the production Linux runner exposes only the current OCX Bun file, not its host directory", async () => { + if (process.platform !== "linux" || !linuxRemoteWorkspaceCommandRunnerAvailable()) return; + const state = fixture(); + const argv = linuxRemoteWorkspaceCommandArgv({ + command: ["bun", "--version"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }, { runtimeExecutablePath: process.execPath }); + expect(argv).toContain("/ocx-runtime/bin/bun"); + expect(argv).toContain(realpathSync(process.execPath)); + expect(argv).not.toContain(dirname(realpathSync(process.execPath))); + expect(argv).not.toContain(process.env.HOME ?? "__missing_home__"); + const runner = createPlatformRemoteWorkspaceCommandRunner(); + if (!runner) throw new Error("Linux Remote Workspace runner was not detected"); + const result = await runner.run({ + command: ["bun", "--version"], + root: state.workspace, + cwd: state.workspace, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe(Bun.version); + }); +}); diff --git a/tests/clients/remote-workspace-device.test.ts b/tests/clients/remote-workspace-device.test.ts new file mode 100644 index 0000000000..386f2ddfbd --- /dev/null +++ b/tests/clients/remote-workspace-device.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + RemoteWorkspaceHub, + connectRemoteWorkspaceAgent, + generateRemoteControlIdentityKeyPair, + pairRemoteWorkspaceDevice, + parseRemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceState, + type RemoteWorkspaceDeviceStateStore, + type RemoteWorkspaceHubState, + type RemoteWorkspaceHubStateStore, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +class HubStore implements RemoteWorkspaceHubStateStore { + state: RemoteWorkspaceHubState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceHubState) { this.state = structuredClone(state); } +} + +class DeviceStore implements RemoteWorkspaceDeviceStateStore { + state: RemoteWorkspaceDeviceState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceDeviceState) { this.state = structuredClone(state); } +} + +describe("remote workspace device enrollment", () => { + test("pairs through one HTTPS request while keeping the real root path on Computer 2", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-")); + roots.push(root); + const workspace = join(root, "private-project"); + const toolchain = join(root, "private-toolchain"); + const nativeHelper = join(root, "private-native-helper"); + mkdirSync(workspace); + mkdirSync(toolchain); + writeFileSync(nativeHelper, "test helper", { mode: 0o700 }); + chmodSync(nativeHelper, 0o700); + const hubStore = new HubStore(); + const hub = new RemoteWorkspaceHub(hubStore); + const grant = hub.createPairingGrant(); + const deviceStore = new DeviceStore(); + let requestBody = ""; + const state = await pairRemoteWorkspaceDevice({ + hubUrl: "https://hub.example.test", + pairingCode: grant.code, + name: "Computer 2", + devicePlatform: "linux-x64", + roots: [{ path: workspace, label: "Main project" }], + toolchainRoots: [toolchain], + nativeHelperPath: nativeHelper, + store: deviceStore, + fetchImpl: async (input, init) => { + expect(String(input)).toBe("https://hub.example.test/remote-workspace/pair"); + requestBody = String(init?.body); + const paired = hub.pairDevice(JSON.parse(requestBody)); + return Response.json(paired, { status: 201 }); + }, + }); + expect(requestBody).not.toContain(workspace); + expect(requestBody).not.toContain(toolchain); + expect(requestBody).not.toContain(nativeHelper); + expect(requestBody).not.toContain(state.deviceIdentity.privateKey); + expect(state).toMatchObject({ + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceName: "Computer 2", + devicePlatform: "linux-x64", + roots: [{ label: "Main project", path: realpathSync(workspace) }], + toolchainRoots: [realpathSync(toolchain)], + }); + expect(state.nativeHelper?.path).toBe(realpathSync(nativeHelper)); + expect(state.nativeHelper?.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(deviceStore.state).toEqual(state); + expect(hub.authenticateDeviceToken(state.deviceToken)?.id).toBe(state.deviceId); + expect(JSON.stringify(hubStore.state)).not.toContain(state.deviceToken); + expect(JSON.stringify(hubStore.state)).not.toContain(workspace); + }); + + test("requires HTTPS except for explicit loopback development", async () => { + expect(() => parseRemoteWorkspaceDeviceState({ version: 1, hubUrl: "http://example.test" })) + .toThrow("must use HTTPS"); + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-local-")); + roots.push(root); + const store = new DeviceStore(); + await expect(pairRemoteWorkspaceDevice({ + hubUrl: "http://127.0.0.1:7075", + pairingCode: "AAAA-BBBB-CCCC", + roots: [{ path: root }], + store, + fetchImpl: async () => Response.json({ error: "invalid or expired" }, { status: 401 }), + })).rejects.toThrow("invalid or expired"); + expect(store.state).toBeNull(); + }); + + test("cancels a chunked Hub response before it can grow beyond the pairing limit", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-bounded-response-")); + roots.push(root); + const store = new DeviceStore(); + let cancelled = false; + await expect(pairRemoteWorkspaceDevice({ + hubUrl: "https://hub.example.test", + pairingCode: "ABCD-EFGH-JKLM", + roots: [{ path: root }], + store, + fetchImpl: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(64 * 1024)); + controller.enqueue(new Uint8Array([1])); + }, + cancel() { cancelled = true; }, + }), { status: 200 }), + })).rejects.toThrow("response is too large"); + expect(cancelled).toBe(true); + expect(store.state).toBeNull(); + }); + + test("stops cleanly even when the platform WebSocket rejects close while connecting", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-device-stop-")); + roots.push(root); + const state: RemoteWorkspaceDeviceState = { + version: 1, + hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), + deviceName: "Computer 2", + devicePlatform: "darwin-arm64", + capabilities: ["workspace.read", "workspace.write"], + deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: generateRemoteControlIdentityKeyPair(), + hubPublicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project", path: root }], + toolchainRoots: [], + }; + const handle = connectRemoteWorkspaceAgent({ + state, + commandRunner: null, + webSocketFactory: () => ({ + readyState: 0, + send() {}, + close() { throw new Error("CONNECTING close is not supported"); }, + addEventListener() {}, + }), + }); + handle.stop(); + await expect(handle.connected).rejects.toThrow("stopped"); + await handle.closed; + }); +}); diff --git a/tests/clients/remote-workspace-hub.test.ts b/tests/clients/remote-workspace-hub.test.ts new file mode 100644 index 0000000000..b6ce600238 --- /dev/null +++ b/tests/clients/remote-workspace-hub.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + RemoteWorkspaceHub, + RemoteWorkspaceHubAgentConnection, + RemoteWorkspacePairingRateLimitError, + REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + generateRemoteControlIdentityKeyPair, + parseRemoteWorkspaceHubState, + serializeRemoteWorkspaceAgentMessage, + type RemoteWorkspaceHubState, + type RemoteWorkspaceHubStateStore, +} from "../../src/remote-control"; + +class MemoryStore implements RemoteWorkspaceHubStateStore { + state: RemoteWorkspaceHubState | null = null; + writes = 0; + + load(): RemoteWorkspaceHubState | null { + return this.state ? structuredClone(this.state) : null; + } + + save(state: RemoteWorkspaceHubState): void { + this.state = structuredClone(state); + this.writes += 1; + } +} + +function pairedHub(now = Date.parse("2026-09-03T12:00:00.000Z")) { + const store = new MemoryStore(); + const hub = new RemoteWorkspaceHub(store, () => now); + const deviceIdentity = generateRemoteControlIdentityKeyPair(); + const grant = hub.createPairingGrant(); + const paired = hub.pairDevice({ + code: grant.code.replaceAll("-", " ").toLowerCase(), + name: "Computer 2", + platform: "linux-x64", + publicKey: deviceIdentity.publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + }); + return { hub, store, deviceIdentity, paired, now }; +} + +describe("remote workspace hub registry", () => { + test("pairs one named OCX-only device without persisting its bearer token", () => { + const state = pairedHub(); + expect(state.paired.device).toMatchObject({ + name: "Computer 2", + platform: "linux-x64", + online: false, + roots: [{ label: "Project" }], + }); + expect(state.paired.deviceToken).toStartWith("ocxrw_"); + expect(state.paired.hubPublicKey).toBe(state.hub.identity().publicKey); + expect(state.hub.authenticateDeviceToken(state.paired.deviceToken)?.id).toBe(state.paired.device.id); + expect(JSON.stringify(state.store.state)).not.toContain(state.paired.deviceToken); + expect(JSON.stringify(state.hub.listDevices())).not.toContain("publicKey"); + expect(JSON.stringify(state.hub.listDevices())).not.toContain("tokenHash"); + }); + + test("consumes pairing codes once and enforces unique device names", () => { + const state = pairedHub(); + expect(() => state.hub.pairDevice({ + code: "not-a-code", + name: "Computer 3", + platform: "linux-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + })).toThrow("invalid or expired"); + + const grant = state.hub.createPairingGrant(); + expect(() => state.hub.pairDevice({ + code: grant.code, + name: "computer 2", + platform: "windows-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + })).toThrow("already in use"); + expect(() => state.hub.pairDevice({ + code: grant.code, + name: "Computer 3", + platform: "windows-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + })).toThrow("invalid or expired"); + }); + + test("bounds invalid pairing attempts by hashed source, expiry, and map capacity", () => { + let now = Date.parse("2026-09-03T12:00:00.000Z"); + const hub = new RemoteWorkspaceHub(new MemoryStore(), () => now); + const invalid = (source: string) => hub.pairDevice({ code: "AAAA-BBBB-CCCC" }, source); + for (let attempt = 1; attempt < 10; attempt += 1) { + expect(() => invalid("peer:192.0.2.10")).toThrow("invalid or expired"); + } + let limited: unknown; + try { invalid("peer:192.0.2.10"); } catch (error) { limited = error; } + expect(limited).toBeInstanceOf(RemoteWorkspacePairingRateLimitError); + expect(limited).toMatchObject({ reason: "source", retryAfterSeconds: 600 }); + + for (let attempt = 1; attempt < 10; attempt += 1) { + expect(() => invalid("peer:192.0.2.11")).toThrow("invalid or expired"); + } + const identity = generateRemoteControlIdentityKeyPair(); + const grant = hub.createPairingGrant(); + expect(hub.pairDevice({ + code: grant.code, + name: "Computer 2", + platform: "linux-x64", + publicKey: identity.publicKey, + roots: [{ id: randomUUID(), label: "Project" }], + }, "peer:192.0.2.11").device.name).toBe("Computer 2"); + expect(() => invalid("peer:192.0.2.11")).toThrow("invalid or expired"); + + now += 10 * 60_000 + 1; + const afterExpiry = hub.createPairingGrant(); + expect(hub.pairDevice({ + code: afterExpiry.code, + name: "Computer 3", + platform: "linux-x64", + publicKey: generateRemoteControlIdentityKeyPair().publicKey, + roots: [{ id: randomUUID(), label: "Other" }], + }, "peer:192.0.2.10").device.name).toBe("Computer 3"); + + const capped = new RemoteWorkspaceHub(new MemoryStore(), () => now); + for (let source = 0; source < 1_024; source += 1) { + expect(() => capped.pairDevice({ code: "AAAA-BBBB-CCCC" }, `peer:${source}`)) + .toThrow("invalid or expired"); + } + let capacity: unknown; + try { capped.pairDevice({ code: "AAAA-BBBB-CCCC" }, "peer:overflow"); } + catch (error) { capacity = error; } + expect(capacity).toBeInstanceOf(RemoteWorkspacePairingRateLimitError); + expect(capacity).toMatchObject({ reason: "capacity", retryAfterSeconds: 1 }); + }); + + test("tracks online presence, replaces reconnects, and revokes the device", () => { + const state = pairedHub(); + const closes: string[] = []; + const connection = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + socket: { + send: () => {}, + close: (_code, reason) => closes.push(reason), + }, + }); + state.hub.attachConnection(state.paired.device.id, connection); + expect(state.hub.listDevices()[0]).toMatchObject({ online: false }); + connection.receive(serializeRemoteWorkspaceAgentMessage({ + version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION, + type: "presence", + capabilities: ["workspace.read", "workspace.write"], + })); + expect(state.hub.listDevices()[0]).toMatchObject({ online: true, lastSeenAt: "2026-09-03T12:00:00.000Z" }); + expect(state.hub.connection(state.paired.device.id)).toBe(connection); + expect(state.hub.revokeDevice(state.paired.device.id)).toBe(true); + expect(state.hub.listDevices()).toEqual([]); + expect(connection.isOnline()).toBe(false); + expect(state.store.state?.devices).toEqual([]); + expect(state.hub.authenticateDeviceToken(state.paired.deviceToken)).toBeNull(); + expect(closes).toEqual(["remote workspace device was revoked"]); + }); + + test("refuses mismatched persisted hub identity keys", () => { + const first = generateRemoteControlIdentityKeyPair(); + const second = generateRemoteControlIdentityKeyPair(); + expect(() => parseRemoteWorkspaceHubState({ + version: 1, + identity: { publicKey: first.publicKey, privateKey: second.privateKey }, + devices: [], + })).toThrow("does not match"); + }); +}); + +test("presence reduces availability without changing the durable enrollment grant", () => { + const state = pairedHub(); + const advertised: unknown[] = []; + const connection = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + capabilities: ["workspace.read", "workspace.write"], + onCapabilities: capabilities => state.hub.updateDeviceCapabilities(state.paired.device.id, capabilities), + socket: { send: value => { advertised.push(JSON.parse(value)); }, close() {} }, + }); + state.hub.attachConnection(state.paired.device.id, connection); + connection.receive(serializeRemoteWorkspaceAgentMessage({ + version: 1, type: "presence", capabilities: ["workspace.read"], + })); + expect(state.hub.listDevices()[0]?.capabilities).toEqual(["workspace.read"]); + expect(state.store.state?.devices[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + expect(advertised[0]).toMatchObject({ capabilities: ["workspace.read"] }); + state.hub.detachConnection(state.paired.device.id, connection); + + const reconnect = new RemoteWorkspaceHubAgentConnection({ + deviceId: state.paired.device.id, + devicePublicKey: state.deviceIdentity.publicKey, + hubIdentity: state.hub.identity(), + capabilities: ["workspace.read", "workspace.write"], + socket: { send() {}, close() {} }, + }); + state.hub.attachConnection(state.paired.device.id, reconnect); + reconnect.receive(serializeRemoteWorkspaceAgentMessage({ + version: 1, type: "presence", capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + })); + expect(reconnect.capabilities()).toEqual(["workspace.read", "workspace.write"]); + expect(state.hub.listDevices()[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + expect(state.store.state?.devices[0]?.capabilities).toEqual(["workspace.read", "workspace.write"]); + state.hub.closeAllConnections(); +}); diff --git a/tests/clients/remote-workspace-linux-confinement.test.ts b/tests/clients/remote-workspace-linux-confinement.test.ts new file mode 100644 index 0000000000..4d88d29096 --- /dev/null +++ b/tests/clients/remote-workspace-linux-confinement.test.ts @@ -0,0 +1,114 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createPlatformRemoteWorkspaceCommandRunner, + linuxRemoteWorkspaceCommandRunnerAvailable, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +test("hosted Linux proves workspace write and denies adjacent access, loopback, and detached survival", async () => { + const required = process.env.OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT === "1"; + const available = process.platform === "linux" + && existsSync("/usr/bin/bwrap") + && linuxRemoteWorkspaceCommandRunnerAvailable(); + if (!required && !available) return; + expect(process.platform).toBe("linux"); + expect(existsSync("/usr/bin/bwrap")).toBe(true); + expect(available).toBe(true); + + const parent = mkdtempSync(join(tmpdir(), "ocx-remote-linux-confinement-")); + roots.push(parent); + const workspace = join(parent, "workspace"); + const marker = join(workspace, "probe-marker"); + const outsideRead = join(parent, "outside-secret"); + const outsideWrite = join(parent, "outside-write"); + mkdirSync(workspace); + writeFileSync(join(workspace, ".keep"), "workspace"); + writeFileSync(outsideRead, "must-not-be-visible"); + + let acceptedConnections = 0; + const listener = createServer(socket => { + acceptedConnections += 1; + socket.destroy(); + }); + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen(0, "127.0.0.1", resolve); + }); + const address = listener.address(); + if (!address || typeof address === "string") throw new Error("loopback probe did not bind TCP"); + const runner = createPlatformRemoteWorkspaceCommandRunner({ + linux: { writableRoots: [workspace] }, + }); + if (!runner) throw new Error("production Linux Remote Workspace runner was not created"); + + try { + const result = await runner.run({ + root: workspace, + cwd: workspace, + command: [ + "bun", + "-e", + [ + 'import { readFileSync, writeFileSync } from "node:fs";', + 'const [outsideRead, outsideWrite, port] = process.argv.slice(1);', + 'if (!outsideRead || !outsideWrite || !port) process.exit(31);', + 'if (process.execPath !== "/ocx-runtime/bin/bun") process.exit(29);', + 'writeFileSync("probe-marker", "sandboxed");', + 'try { readFileSync(outsideRead); process.exit(26); } catch (error) { void error; }', + 'try { writeFileSync(outsideWrite, "escaped"); process.exit(27); } catch (error) { void error; }', + 'try { await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) }); process.exit(28); } catch (error) { void error; }', + ].join("\n"), + "--", + outsideRead, + outsideWrite, + String(address.port), + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }); + expect(result.exitCode).toBe(0); + expect(readFileSync(marker, "utf8")).toBe("sandboxed"); + expect(existsSync(outsideWrite)).toBe(false); + expect(acceptedConnections).toBe(0); + } finally { + await new Promise(resolve => listener.close(() => resolve())); + } + + const lateMarker = join(workspace, "late-marker"); + const controller = new AbortController(); + const pending = runner.run({ + root: workspace, + cwd: workspace, + command: [ + "/bin/bash", + "-c", + "setsid /bin/bash -c 'sleep 0.5; printf escaped > late-marker' >/dev/null 2>&1 & sleep 30", + ], + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + signal: controller.signal, + }); + await Bun.sleep(100); + controller.abort(); + await expect(pending).rejects.toThrow("cancelled"); + await Bun.sleep(750); + expect(existsSync(lateMarker)).toBe(false); + + const unsafeWorkspace = join(parent, "unsafe-workspace"); + mkdirSync(unsafeWorkspace); + linkSync(outsideRead, join(unsafeWorkspace, "outside-alias")); + expect(createPlatformRemoteWorkspaceCommandRunner({ + linux: { writableRoots: [unsafeWorkspace] }, + })).toBeUndefined(); + expect(readFileSync(outsideRead, "utf8")).toBe("must-not-be-visible"); +}); diff --git a/tests/clients/remote-workspace-platform.test.ts b/tests/clients/remote-workspace-platform.test.ts new file mode 100644 index 0000000000..ec48362055 --- /dev/null +++ b/tests/clients/remote-workspace-platform.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { + findExecutableOnPath, +} from "../../src/remote-control/workspace-executable"; +import { + remoteWorkspaceProcessInvocation, + remoteWorkspaceThreadStartParams, + linuxRemoteWorkspaceCommandRunnerAvailable, + remoteWorkspaceCapabilitiesForCommandRunner, + runRemoteWorkspaceCleanupSteps, + stopRemoteWorkspaceProcess, + truncateRemoteWorkspaceUtf8, + validateRemoteWorkspaceRelativePath, +} from "../../src/remote-control"; + +describe("Remote Workspace cross-platform boundaries", () => { + test("resolves Windows PATH and PATHEXT with Windows grammar on every test host", () => { + const visited: string[] = []; + const resolved = findExecutableOnPath("claude", { + platform: "win32", + path: "C:\\first;D:\\npm", + pathExt: ".PS1;.EXE;.CMD", + probe(candidate) { + visited.push(candidate); + return candidate.toLowerCase() === "d:\\npm\\claude.cmd"; + }, + }); + expect(resolved).toBe("D:\\npm\\claude.cmd"); + expect(visited).toEqual([ + "C:\\first\\claude.exe", + "C:\\first\\claude.cmd", + "D:\\npm\\claude.exe", + "D:\\npm\\claude.cmd", + ]); + }); + + test("launches Windows npm shims through escaped ComSpec and leaves Unix argv direct", () => { + const windows = remoteWorkspaceProcessInvocation( + ["C:\\Users\\u\\AppData\\Roaming\\npm\\claude.cmd", "--system-prompt", "a&b"], + { platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" } }, + ); + expect(windows.file).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(windows.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(windows.args[3]).toContain("a^&b"); + expect(windows.options.windowsVerbatimArguments).toBe(true); + + expect(remoteWorkspaceProcessInvocation(["/usr/bin/claude", "--version"], { platform: "linux" })) + .toEqual({ file: "/usr/bin/claude", args: ["--version"], options: {} }); + expect(remoteWorkspaceProcessInvocation(["/opt/homebrew/bin/pi", "--version"], { platform: "darwin" })) + .toEqual({ file: "/opt/homebrew/bin/pi", args: ["--version"], options: {} }); + }); + + test("stops the exact Windows wrapper tree through trusted taskkill semantics", async () => { + let settle!: (code: number) => void; + const exited = new Promise(resolve => { settle = resolve; }); + const calls: Array<{ file: string; args: readonly string[] }> = []; + let fallbackKills = 0; + await stopRemoteWorkspaceProcess({ + pid: 4242, + exitCode: null, + exited, + kill() { fallbackKills += 1; settle(0); }, + }, { + platform: "win32", + taskkillPath: "C:\\Windows\\System32\\taskkill.exe", + execFile(file, args) { calls.push({ file, args }); settle(0); }, + waitMs: 10, + }); + expect(calls).toEqual([{ + file: "C:\\Windows\\System32\\taskkill.exe", + args: ["/PID", "4242", "/T", "/F"], + }]); + expect(fallbackKills).toBe(0); + }); + + test("escalates a Unix child that ignores SIGTERM without killing unrelated processes", async () => { + let settle!: (code: number) => void; + const exited = new Promise(resolve => { settle = resolve; }); + const signals: Array = []; + await stopRemoteWorkspaceProcess({ + pid: 4243, + exitCode: null, + exited, + kill(signal) { + signals.push(signal); + if (signal === "SIGKILL") settle(137); + }, + }, { platform: "darwin", waitMs: 1 }); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); + }); + + test("runs every cleanup owner even when an earlier resource fails", async () => { + const completed: string[] = []; + await expect(runRemoteWorkspaceCleanupSteps([ + () => { completed.push("process"); throw new Error("process cleanup failed"); }, + async () => { completed.push("bridge"); }, + () => { completed.push("isolation"); }, + ])).rejects.toThrow("process cleanup failed"); + expect(completed).toEqual(["process", "bridge", "isolation"]); + }); + + test("reports an owned child that remains alive after forced termination", async () => { + const exited = new Promise(() => {}); + await expect(stopRemoteWorkspaceProcess({ + pid: 4244, + exitCode: null, + exited, + kill() {}, + }, { platform: "linux", waitMs: 1 })).rejects.toThrow("did not exit after SIGKILL"); + }); + + test("reconnection cannot widen the capability grant recorded at pairing", () => { + const runner = { async run() { return { exitCode: 0, stdout: "", stderr: "" }; } }; + expect(remoteWorkspaceCapabilitiesForCommandRunner(runner, ["workspace.read"])) + .toEqual(["workspace.read"]); + expect(remoteWorkspaceCapabilitiesForCommandRunner(undefined, [ + "workspace.read", "workspace.write", "workspace.exec", + ])).toEqual(["workspace.read", "workspace.write"]); + }); + + test("bounds large UTF-8 text without quadratic trimming or split surrogate pairs", () => { + const value = `${"가".repeat(100_000)}😀tail`; + const truncated = truncateRemoteWorkspaceUtf8(value, 8_192); + expect(Buffer.byteLength(truncated, "utf8")).toBeLessThanOrEqual(8_192); + expect(truncated.endsWith("\ud83d")).toBe(false); + expect(truncated.includes("tail")).toBe(false); + }); + + test("uses platform-native deny-local shell environments", () => { + const windows = remoteWorkspaceThreadStartParams({ + executorName: "Windows executor", + coordinatorIsolationPath: "/test/coordinator", + tools: ["read_file"], + platform: "win32", + windowsSystemDirectory: "C:\\Windows\\System32", + mcp: { url: "http://127.0.0.1:1/mcp", bearerTokenEnvVar: "TOKEN" }, + }) as { config: { shell_environment_policy: { set: Record } } }; + expect(windows.config.shell_environment_policy.set).toMatchObject({ + USERPROFILE: "/test/coordinator", + TEMP: "/test/coordinator", + PATH: "C:\\Windows\\System32", + }); + expect(windows.config.shell_environment_policy.set.PATH).not.toContain("/usr/"); + + const mac = remoteWorkspaceThreadStartParams({ + executorName: "Mac executor", + coordinatorIsolationPath: "/test/coordinator", + tools: ["read_file"], + platform: "darwin", + mcp: { url: "http://127.0.0.1:1/mcp", bearerTokenEnvVar: "TOKEN" }, + }) as { config: { shell_environment_policy: { set: Record } } }; + expect(mac.config.shell_environment_policy.set.PATH).toBe("/usr/bin:/bin"); + }); + + test("advertises Linux exec only after the namespace probe succeeds", () => { + if (!existsSync("/usr/bin/bwrap")) return; + let sawNetworkIsolation = false; + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: "/usr/bin/bwrap", + probe(argv) { + sawNetworkIsolation = argv.includes("--unshare-net"); + return false; + }, + })).toBe(false); + expect(sawNetworkIsolation).toBe(true); + expect(linuxRemoteWorkspaceCommandRunnerAvailable({ + bubblewrapPath: "/usr/bin/bwrap", + probe: () => true, + })).toBe(true); + }); + + test("rejects Windows device names, ADS, and normalized aliases without blocking POSIX names", () => { + for (const path of ["NUL", "con.txt", "CONIN$", "CLOCK$.txt", "logs\\COM1.json", "file.txt:token", "name.", "name ", "bad\u0001name"]) { + expect(() => validateRemoteWorkspaceRelativePath(path, undefined, "win32")).toThrow("safe Windows"); + } + expect(validateRemoteWorkspaceRelativePath("normal\\file.txt", undefined, "win32")) + .toBe("normal\\file.txt"); + expect(validateRemoteWorkspaceRelativePath("NUL:valid-on-posix", undefined, "linux")) + .toBe("NUL:valid-on-posix"); + }); +}); diff --git a/tests/clients/remote-workspace-secret-store.test.ts b/tests/clients/remote-workspace-secret-store.test.ts new file mode 100644 index 0000000000..6913e11bf6 --- /dev/null +++ b/tests/clients/remote-workspace-secret-store.test.ts @@ -0,0 +1,105 @@ +import { afterEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generateRemoteControlIdentityKeyPair } from "../../src/remote-control/crypto"; +import { RemoteWorkspaceHubFileStore } from "../../src/remote-control/workspace-hub"; +import { RemoteWorkspaceDeviceFileStore } from "../../src/remote-control/workspace-device"; +import { RemoteWorkspaceSessionFileStore } from "../../src/remote-control/workspace-sessions"; +import { workspaceSecretPermissions, type WorkspaceSecretPermissions } from "../../src/remote-control/workspace-secret-store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const previousHome = process.env.OPENCODEX_HOME; +const roots: string[] = []; +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixtures() { + const root = mkdtempSync(join(tmpdir(), "ocx-workspace-secret-")); + roots.push(root); + process.env.OPENCODEX_HOME = root; + const identity = generateRemoteControlIdentityKeyPair(); + const hubState = { version: 1 as const, identity, devices: [] }; + const sessionState = { version: 1 as const, sessions: [] }; + const deviceState = { + version: 1 as const, hubUrl: "https://hub.example.test", + agentUrl: "wss://hub.example.test/remote-workspace/agent", + deviceId: randomUUID(), deviceName: "Executor", devicePlatform: "test", + capabilities: ["workspace.read" as const], deviceToken: `ocxrw_${"A".repeat(43)}`, + deviceIdentity: identity, hubPublicKey: identity.publicKey, + roots: [{ id: randomUUID(), label: "Project", path: root }], toolchainRoots: [], + }; + return [ + { path: join(root, "hub.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceHubFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(hubState) }; + } }, + { path: join(root, "device.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceDeviceFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(deviceState) }; + } }, + { path: join(root, "sessions.json"), create: (p: string, permissions?: WorkspaceSecretPermissions) => { + const store = new RemoteWorkspaceSessionFileStore(p, permissions); + return { load: () => store.load(), save: () => store.save(sessionState) }; + } }, + ]; +} + +test("all workspace stores distinguish absent state from permission failure", () => { + for (const fixture of fixtures()) { + const store = fixture.create(fixture.path); + expect(store.load()).toBeNull(); + store.save(); + expect(store.load()).not.toBeNull(); + if (process.platform !== "win32") expect(statSync(fixture.path).mode & 0o777).toBe(0o600); + } +}); + +test("all stores propagate hardening failures before decoding or publishing secret bytes", () => { + for (const fixture of fixtures()) { + for (const failedStep of ["prepareDirectory", "hardenFile"] as const) { + // Invalid JSON would fail if read reached decoding instead of the permission boundary. + writeFileSync(fixture.path, "private-sentinel-not-json", { mode: 0o600 }); + const calls: string[] = []; + const permissions: WorkspaceSecretPermissions = { + prepareDirectory() { calls.push("directory"); if (failedStep === "prepareDirectory") throw new Error("denied hardening"); }, + hardenFile() { calls.push("file"); throw new Error("denied hardening"); }, + }; + const store = fixture.create(fixture.path, permissions); + expect(() => store.load()).toThrow("denied hardening"); + expect(() => store.save()).toThrow("denied hardening"); + expect(readFileSync(fixture.path, "utf8")).toBe("private-sentinel-not-json"); + expect(calls).toEqual(failedStep === "prepareDirectory" + ? ["directory", "directory"] : ["directory", "file", "directory", "file"]); + } + } +}); + +test("secret files refuse symbolic-link targets", () => { + if (process.platform === "win32") return; // Windows link creation requires separate privileges. + const fixture = fixtures()[0]!; + const target = `${fixture.path}.target`; + writeFileSync(target, "private", { mode: 0o600 }); + symlinkSync(target, fixture.path); + expect(() => workspaceSecretPermissions.hardenFile(fixture.path)).toThrow("regular file"); + expect(readFileSync(target, "utf8")).toBe("private"); +}); + + +test("an inaccessible existing store is never reported as first-run absence", () => { + if (process.platform === "win32" || process.getuid?.() === 0) return; + for (const fixture of fixtures()) { + const store = fixture.create(fixture.path); + store.save(); + const before = readFileSync(fixture.path, "utf8"); + const directory = fixture.path.slice(0, fixture.path.lastIndexOf("/")); + chmodSync(directory, 0); + try { expect(() => store.load()).toThrow(); } + finally { chmodSync(directory, 0o700); } + expect(readFileSync(fixture.path, "utf8")).toBe(before); + } +}); diff --git a/tests/clients/remote-workspace-session-binding.test.ts b/tests/clients/remote-workspace-session-binding.test.ts new file mode 100644 index 0000000000..ac1ab7c7c3 --- /dev/null +++ b/tests/clients/remote-workspace-session-binding.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + EncryptedRemoteWorkspaceExecutorEndpoint, + RemoteControlClientHandshake, + acceptRemoteControlClientHello, + frameRemoteWorkspaceRpcMessage, + generateRemoteControlIdentityKeyPair, + type RemoteWorkspaceExecutionRequest, +} from "../../src/remote-control"; + +function fixture() { + const hub = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const sessionId = randomUUID(); + const deviceId = randomUUID(); + const handshake = RemoteControlClientHandshake.create({ + sessionId, deviceId, commandProfile: "codex", capabilities: ["workspace.read"], + accountPrivateKey: hub.privateKey, + }); + const accepted = acceptRemoteControlClientHello(handshake.hello, { + expectedSessionId: sessionId, expectedDeviceId: deviceId, + accountPublicKey: hub.publicKey, devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write"], + }); + const client = handshake.complete(accepted.hello, device.publicKey); + const invocations: RemoteWorkspaceExecutionRequest[] = []; + const endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: deviceId, sessionId, rootId: "first-approved-root", + capabilities: ["workspace.read"], cipher: accepted.cipher, + executor: { async invoke(request) { invocations.push(request); return { ok: true }; } }, + sendCiphertext() {}, + }); + const request: RemoteWorkspaceExecutionRequest = { + requestId: randomUUID(), sessionId, executorDeviceId: deviceId, + rootId: "first-approved-root", tool: "read_file", arguments: { path: "marker" }, + }; + return { + invocations, + async send(overrides: Partial = {}) { + const message = new TextEncoder().encode(JSON.stringify({ + version: 1, kind: "request", request: { ...request, ...overrides }, + })); + for (const frame of frameRemoteWorkspaceRpcMessage(message)) { + await endpoint.receiveCiphertext(client.encrypt(frame)); + } + }, + close() { endpoint.close(); client.destroy(); }, + }; +} + +test("encrypted requests cannot leave their session grant before executor invocation", async () => { + const mismatches: Partial[] = [ + { sessionId: randomUUID() }, + { executorDeviceId: randomUUID() }, + { rootId: "second-approved-root" }, + { tool: "write_file", arguments: { path: "marker", content: "changed", expectedSha256: null } }, + ]; + for (const mismatch of mismatches) { + const state = fixture(); + try { + await expect(state.send(mismatch)).rejects.toThrow(); + expect(state.invocations).toEqual([]); + } finally { state.close(); } + } +}); + +test("a matching encrypted read reaches the selected executor once", async () => { + const state = fixture(); + try { + await state.send(); + expect(state.invocations).toHaveLength(1); + expect(state.invocations[0]).toMatchObject({ rootId: "first-approved-root", tool: "read_file" }); + } finally { state.close(); } +}); diff --git a/tests/clients/remote-workspace-sessions.test.ts b/tests/clients/remote-workspace-sessions.test.ts new file mode 100644 index 0000000000..dc0be8ed16 --- /dev/null +++ b/tests/clients/remote-workspace-sessions.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, test } from "bun:test"; +import type { RemoteWorkspaceHub } from "../../src/remote-control/workspace-hub"; +import { + RemoteWorkspaceSessionService, + type RemoteWorkspaceRuntimeFactory, + type RemoteWorkspaceRuntimeHandle, + type RemoteWorkspaceSessionEvent, + type RemoteWorkspaceSessionState, + type RemoteWorkspaceSessionStateStore, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; + +const DEVICE_ID = "11111111-1111-4111-8111-111111111111"; +const ROOT_ID = "22222222-2222-4222-8222-222222222222"; + +interface Harness { + service: RemoteWorkspaceSessionService; + setOnline(value: boolean): void; + invocations: Array<{ tool: string; rootId: string }>; + closedSessions: string[]; + stopCalls(): number; + sessionOpens(): number; + sessionGrants: string[][]; + runtimeStarts(): Array; +} + +class MemorySessionStore implements RemoteWorkspaceSessionStateStore { + state: RemoteWorkspaceSessionState | null = null; + load() { return this.state ? structuredClone(this.state) : null; } + save(state: RemoteWorkspaceSessionState) { this.state = structuredClone(state); } +} + +function deferred(): { + promise: Promise; + resolve(): void; + reject(error: Error): void; +} { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createHarness(options: { + promptGate?: ReturnType; + startGate?: ReturnType; + onStart?: () => void; + lazyResumable?: boolean; + eventsAtStart?: number; + sessionStore?: RemoteWorkspaceSessionStateStore; + stopError?: Error; + closeError?: Error; +} = {}): Harness { + let online = true; + let stops = 0; + let opens = 0; + let promptStarted = false; + let runtimeResumable = options.lazyResumable !== true; + const invocations: Array<{ tool: string; rootId: string }> = []; + const closedSessions: string[] = []; + const sessionGrants: string[][] = []; + const transportStates: Array<{ online: boolean }> = []; + const runtimeStarts: Array = []; + const newTransport = (): RemoteWorkspaceTransport => { + const state = { online: true }; + transportStates.push(state); + return { + isOnline: deviceId => state.online && deviceId === DEVICE_ID, + async invoke(request) { + if (!state.online) throw new Error("transport offline"); + invocations.push({ tool: request.tool, rootId: request.rootId }); + return { ok: true, value: { entries: ["src"] } }; + }, + }; + }; + const connection = { + capabilities: () => ["workspace.read", "workspace.write", "workspace.exec"], + async openSession(input: { capabilities: string[] }) { sessionGrants.push([...input.capabilities]); opens += 1; return newTransport(); }, + async closeSession(sessionId: string) { + closedSessions.push(sessionId); + if (options.closeError) throw options.closeError; + }, + }; + const hub = { + listDevices: () => [{ + id: DEVICE_ID, + name: "Build box", + platform: "linux", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + roots: [{ id: ROOT_ID, label: "Project" }], + online, + createdAt: "2026-01-01T00:00:00.000Z", + lastSeenAt: null, + }], + connection: (deviceId: string) => online && deviceId === DEVICE_ID ? connection : null, + } as unknown as RemoteWorkspaceHub; + + const factory: RemoteWorkspaceRuntimeFactory = { + profile: "codex", + async available() { return { available: true, version: "test" }; }, + async start({ coordinator, emit, resumeThreadId }) { + options.onStart?.(); + if (options.startGate) await options.startGate.promise; + runtimeStarts.push(resumeThreadId); + for (let index = 0; index < (options.eventsAtStart ?? 0); index += 1) { + emit("assistant", `event-${index}`); + } + const handle: RemoteWorkspaceRuntimeHandle = { + threadId: resumeThreadId ?? "thread-remote-1", + canResume: () => runtimeResumable, + async prompt() { + promptStarted = true; + if (options.promptGate) await options.promptGate.promise; + else { + const response = await coordinator.handle({ + method: "item/tool/call", + id: "tool-1", + params: { + threadId: "thread-remote-1", + turnId: "turn-1", + callId: "call-1", + namespace: "ocx_remote_workspace", + tool: "list_directory", + arguments: { path: "." }, + }, + }); + emit("tool", response.result.contentItems[0]!.text); + } + runtimeResumable = true; + }, + async stop() { + stops += 1; + if (promptStarted) options.promptGate?.reject(new Error("turn cancelled")); + if (options.stopError) throw options.stopError; + }, + }; + return handle; + }, + }; + return { + service: new RemoteWorkspaceSessionService(hub, [factory], Date.now, options.sessionStore), + setOnline(value) { + online = value; + if (!value) for (const state of transportStates) state.online = false; + }, + invocations, + closedSessions, + stopCalls: () => stops, + sessionOpens: () => opens, + sessionGrants, + runtimeStarts: () => [...runtimeStarts], + }; +} + +describe("Remote Workspace session service", () => { + test("binds one model session to the selected executor root", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.status).toBe("ready"); + expect(created.deviceName).toBe("Build box"); + expect(created.rootLabel).toBe("Project"); + expect(created).toMatchObject({ + accessMode: "read-only", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + + const completed = await harness.service.prompt(created.id, "Inspect this project"); + expect(completed.status).toBe("ready"); + expect(harness.invocations).toEqual([{ tool: "list_directory", rootId: ROOT_ID }]); + expect(completed.events.some(event => event.type === "tool" && event.text.includes("src"))).toBe(true); + }); + + test("exposes write and exec tools only after an explicit workspace access grant", async () => { + const harness = createHarness(); + const created = await harness.service.create({ + profile: "codex", + deviceId: DEVICE_ID, + rootId: ROOT_ID, + accessMode: "workspace", + }); + expect(created).toMatchObject({ + accessMode: "workspace", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + }); + + test("fails closed when the selected executor disconnects", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + harness.setOnline(false); + await expect(harness.service.prompt(created.id, "Do not run locally")).rejects.toThrow("executor is offline"); + expect(harness.invocations).toHaveLength(0); + expect(harness.service.get(created.id)?.status).toBe("waiting_for_executor"); + }); + + test("reopens only the encrypted executor channel after the device reconnects", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(harness.sessionOpens()).toBe(1); + harness.setOnline(false); + expect(harness.service.get(created.id)?.status).toBe("waiting_for_executor"); + harness.setOnline(true); + const completed = await harness.service.prompt(created.id, "Continue remotely"); + expect(completed.status).toBe("ready"); + expect(harness.sessionOpens()).toBe(2); + expect(harness.invocations).toEqual([{ tool: "list_directory", rootId: ROOT_ID }]); + }); + + test("rejects a second prompt while a turn is active", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const first = harness.service.prompt(created.id, "First"); + await Promise.resolve(); + await expect(harness.service.prompt(created.id, "Second")).rejects.toThrow("active turn"); + gate.resolve(); + await first; + }); + + test("a turn that finishes after disconnect stays waiting instead of reporting ready", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const running = harness.service.prompt(created.id, "Keep the target binding"); + await Promise.resolve(); + harness.setOnline(false); + gate.resolve(); + const completed = await running; + expect(completed.status).toBe("waiting_for_executor"); + }); + + test("stop cancels an active turn before waiting for it", async () => { + const gate = deferred(); + const harness = createHarness({ promptGate: gate }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + const promptOutcome = harness.service.prompt(created.id, "Long turn").then( + () => "resolved", + () => "rejected", + ); + await Promise.resolve(); + + expect(await harness.service.stop(created.id)).toBe(true); + expect(await promptOutcome).toBe("rejected"); + expect(harness.stopCalls()).toBe(1); + expect(harness.closedSessions).toEqual([created.id]); + expect(harness.service.get(created.id)?.status).toBe("stopped"); + }); + + test("stop cannot be overwritten by a session that finishes starting late", async () => { + const startGate = deferred(); + const startEntered = deferred(); + const harness = createHarness({ startGate, onStart: startEntered.resolve }); + const creating = harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await startEntered.promise; + const starting = harness.service.list()[0]; + if (!starting) throw new Error("starting session was not visible"); + + expect(await harness.service.stop(starting.id)).toBe(true); + startGate.resolve(); + await expect(creating).rejects.toThrow("stopped while starting"); + expect(harness.service.get(starting.id)?.status).toBe("stopped"); + expect(harness.stopCalls()).toBe(1); + }); + + test("attempts every session cleanup owner and reports incomplete teardown", async () => { + const harness = createHarness({ + stopError: new Error("runtime refused to stop"), + closeError: new Error("transport refused to close"), + }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await expect(harness.service.stop(created.id)).rejects.toThrow("runtime refused to stop"); + expect(harness.stopCalls()).toBe(1); + expect(harness.closedSessions).toEqual([created.id]); + expect(harness.service.get(created.id)?.status).toBe("failed"); + }); + + test("keeps only a bounded event history", async () => { + const harness = createHarness({ eventsAtStart: 510 }); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.events).toHaveLength(100); + expect(created.events[0]!.sequence).toBeGreaterThan(1); + const types: RemoteWorkspaceSessionEvent["type"][] = created.events.map(event => event.type); + expect(types.at(-1)).toBe("status"); + }); + + test("restores a persisted Hub session and resumes its original model thread", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(store.state?.sessions[0]?.threadId).toBe("thread-remote-1"); + + const restarted = createHarness({ sessionStore: store }); + expect(restarted.service.get(created.id)?.status).toBe("waiting_for_executor"); + const completed = await restarted.service.prompt(created.id, "Continue after Hub restart"); + expect(completed.status).toBe("ready"); + expect(restarted.runtimeStarts()).toEqual(["thread-remote-1"]); + }); + + test("persists a lazy runtime as resumable only after its first completed turn", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store, lazyResumable: true }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + expect(created.resumable).toBe(false); + expect(store.state?.sessions[0]?.resumable).toBe(false); + + const completed = await first.service.prompt(created.id, "Create durable history"); + expect(completed.resumable).toBe(true); + const restarted = createHarness({ sessionStore: store, lazyResumable: true }); + expect(restarted.service.get(created.id)?.status).toBe("waiting_for_executor"); + }); + + test("graceful Hub shutdown cleans runtimes without marking resumable sessions stopped", async () => { + const store = new MemorySessionStore(); + const first = createHarness({ sessionStore: store }); + const created = await first.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await first.service.shutdown(); + expect(first.stopCalls()).toBe(1); + expect(store.state?.sessions[0]?.status).toBe("waiting_for_executor"); + + const restarted = createHarness({ sessionStore: store }); + const completed = await restarted.service.prompt(created.id, "Resume after graceful restart"); + expect(completed.status).toBe("ready"); + expect(restarted.runtimeStarts()).toEqual(["thread-remote-1"]); + }); + + test("stops every retained runtime during Hub shutdown", async () => { + const harness = createHarness(); + await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID }); + await harness.service.stopAll(); + expect(harness.stopCalls()).toBe(2); + expect(harness.service.list().every(session => session.status === "stopped")).toBe(true); + }); +}); + + +test("read-only capability grant is forwarded on initial open and reconnect", async () => { + const harness = createHarness(); + const created = await harness.service.create({ profile: "codex", deviceId: DEVICE_ID, rootId: ROOT_ID, accessMode: "read-only" }); + expect(harness.sessionGrants).toEqual([["workspace.read"]]); + harness.setOnline(false); + harness.service.list(); + harness.setOnline(true); + await harness.service.prompt(created.id, "Read after reconnect"); + expect(harness.sessionGrants).toEqual([["workspace.read"], ["workspace.read"]]); + await harness.service.stop(created.id); +}); diff --git a/tests/clients/remote-workspace-tool-bridge.test.ts b/tests/clients/remote-workspace-tool-bridge.test.ts new file mode 100644 index 0000000000..48c24634b7 --- /dev/null +++ b/tests/clients/remote-workspace-tool-bridge.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { RemoteWorkspaceCoordinator, startRemoteWorkspaceToolBridge } from "../../src/remote-control"; + +test("loopback CLI bridge accepts only its bearer and delegates to the E2EE coordinator", async () => { + const invocations: string[] = []; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke(request) { + invocations.push(request.tool); + return { ok: true, value: { entries: ["src"] } }; + }, + }); + coordinator.register({ + sessionId: "session-1", + threadId: "thread-1", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: "thread-1", + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + try { + const denied = await fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + expect(denied.status).toBe(401); + const allowed = await fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${bridge.token}` }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + expect(allowed.status).toBe(200); + const body = await allowed.json() as { success: boolean; text: string }; + expect(body.success).toBe(true); + expect(body.text).toContain("src"); + expect(invocations).toEqual(["list_directory"]); + } finally { + await bridge.stop(); + } +}); + +test("loopback CLI bridge rejects excess work before buffering another request", async () => { + const releases: Array<() => void> = []; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + invoke: async () => await new Promise<{ ok: true; value: null }>(resolve => { + releases.push(() => resolve({ ok: true, value: null })); + }), + }); + coordinator.register({ + sessionId: "session-1", + threadId: "thread-1", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + const bridge = startRemoteWorkspaceToolBridge({ + coordinator, + threadId: "thread-1", + tools: ["list_directory"], + }); + const request = () => fetch(`${bridge.url}/invoke`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${bridge.token}` }, + body: JSON.stringify({ tool: "list_directory", arguments: { path: "." } }), + }); + try { + const active = Array.from({ length: 8 }, request); + for (let count = 0; count < 100 && releases.length < 8; count += 1) await Bun.sleep(1); + expect(releases).toHaveLength(8); + expect((await request()).status).toBe(429); + for (const release of releases) release(); + expect((await Promise.all(active)).every(response => response.status === 200)).toBe(true); + } finally { + for (const release of releases) release(); + await bridge.stop(); + } +}); diff --git a/tests/clients/remote-workspace.test.ts b/tests/clients/remote-workspace.test.ts new file mode 100644 index 0000000000..6754d5b8ff --- /dev/null +++ b/tests/clients/remote-workspace.test.ts @@ -0,0 +1,464 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash, randomUUID } from "node:crypto"; +import { + mkdirSync, + linkSync, + mkdtempSync, + readFileSync, + renameSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + REMOTE_WORKSPACE_DYNAMIC_TOOLS, + REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES, + REMOTE_WORKSPACE_TOOL_NAMESPACE, + EncryptedRemoteWorkspaceExecutorEndpoint, + EncryptedRemoteWorkspaceTransport, + RemoteControlClientHandshake, + RemoteWorkspaceCoordinator, + RemoteWorkspaceExecutor, + acceptRemoteControlClientHello, + generateRemoteControlIdentityKeyPair, + remoteWorkspaceThreadStartParams, + type AppServerDynamicToolRequest, + type RemoteWorkspaceCommandRunner, + type RemoteWorkspaceExecutionRequest, + type RemoteWorkspaceToolResult, + type RemoteWorkspaceTransport, +} from "../../src/remote-control"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const roots: string[] = []; + +const localTestCommandRunner: RemoteWorkspaceCommandRunner = { + async run(request) { + const child = Bun.spawn(request.command, { + cwd: request.cwd, + env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8", HOME: request.cwd }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, request.timeoutMs); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (timedOut) throw new Error("local test command timed out"); + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > request.maxOutputBytes) { + throw new Error("local test command output limit exceeded"); + } + return { stdout, stderr, exitCode }; + } finally { + clearTimeout(timer); + } + }, +}; + +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-remote-workspace-")); + roots.push(root); + const main = join(root, "main"); + const executorRoot = join(root, "executor"); + mkdirSync(join(main, "project"), { recursive: true }); + mkdirSync(join(executorRoot, "project"), { recursive: true }); + writeFileSync(join(main, "project", "marker.txt"), "main-only"); + writeFileSync(join(executorRoot, "project", "marker.txt"), "executor-before"); + const deviceId = `device-${randomUUID()}`; + const executor = new RemoteWorkspaceExecutor({ + deviceId, + roots: [{ id: "project-root", path: executorRoot }], + commandRunner: localTestCommandRunner, + }); + let online = true; + let invokeCount = 0; + const transport: RemoteWorkspaceTransport = { + isOnline: candidate => online && candidate === deviceId, + async invoke(request: RemoteWorkspaceExecutionRequest): Promise { + invokeCount += 1; + return await executor.invoke(request); + }, + }; + const coordinator = new RemoteWorkspaceCoordinator(transport); + const threadId = `thread-${randomUUID()}`; + coordinator.register({ + sessionId: `session-${randomUUID()}`, + threadId, + executorDeviceId: deviceId, + executorName: "Computer 2", + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + const request = (tool: string, args: unknown, id: number = 1): AppServerDynamicToolRequest => ({ + method: "item/tool/call", + id, + params: { + threadId, + turnId: `turn-${randomUUID()}`, + callId: `call-${randomUUID()}`, + namespace: REMOTE_WORKSPACE_TOOL_NAMESPACE, + tool, + arguments: args, + }, + }); + return { + root, + main, + executorRoot, + executor, + coordinator, + request, + setOnline(value: boolean) { online = value; }, + invokeCount: () => invokeCount, + }; +} + +function responseValue(response: Awaited>): RemoteWorkspaceToolResult { + return JSON.parse(response.result.contentItems[0]!.text) as RemoteWorkspaceToolResult; +} + +describe("remote workspace coordinator and executor", () => { + test("publishes only the namespaced client-executed tools and isolates the coordinator cwd", () => { + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS).toHaveLength(1); + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].name).toBe(REMOTE_WORKSPACE_TOOL_NAMESPACE); + expect(REMOTE_WORKSPACE_DYNAMIC_TOOLS[0].tools.map(tool => tool.name)).toEqual([ + "list_directory", "read_file", "write_file", "exec", + ]); + const coordinatorIsolation = resolve("isolated-coordinator-session"); + const params = remoteWorkspaceThreadStartParams({ + executorName: "Computer 2", + coordinatorIsolationPath: coordinatorIsolation, + tools: ["list_directory", "read_file", "write_file", "exec"], + }); + expect(params).toMatchObject({ + cwd: coordinatorIsolation, + runtimeWorkspaceRoots: [coordinatorIsolation], + approvalPolicy: "never", + serviceName: "opencodex_remote_workspace", + }); + expect(String(params.developerInstructions)).toContain("never fall back locally"); + }); + + test("rejects write and exec calls that are outside the session access grant", async () => { + let invoked = false; + const coordinator = new RemoteWorkspaceCoordinator({ + isOnline: () => true, + async invoke() { invoked = true; return { ok: true }; }, + }); + coordinator.register({ + sessionId: "session-read-only", + threadId: "thread-read-only", + executorDeviceId: "device-2", + executorName: "Computer 2", + rootId: "root-2", + capabilities: ["workspace.read"], + tools: ["list_directory", "read_file"], + }); + const result = await coordinator.handle({ + method: "item/tool/call", + id: "request-1", + params: { + threadId: "thread-read-only", + turnId: "turn-1", + callId: "call-1", + namespace: "ocx_remote_workspace", + tool: "exec", + arguments: { command: ["true"] }, + }, + }); + expect(responseValue(result).error).toContain("not supported"); + expect(invoked).toBe(false); + }); + + test("writes and executes only inside Computer 2 while the same Computer 1 path stays unchanged", async () => { + const state = fixture(); + const write = await state.coordinator.handle(state.request("write_file", { + path: "project/marker.txt", + content: "executor-after", + expectedSha256: sha256("executor-before"), + })); + expect(write.result.success).toBe(true); + expect(responseValue(write).ok).toBe(true); + expect(readFileSync(join(state.executorRoot, "project", "marker.txt"), "utf8")).toBe("executor-after"); + expect(readFileSync(join(state.main, "project", "marker.txt"), "utf8")).toBe("main-only"); + + const command = process.platform === "win32" + ? ["powershell.exe", "-NoProfile", "-Command", "Write-Output -NoNewline 'executor-process:'; (Get-Location).Path"] + : ["/bin/sh", "-lc", "printf 'executor-process:'; pwd"]; + const exec = await state.coordinator.handle(state.request("exec", { + command, + cwd: "project", + timeoutMs: 5_000, + }, 2)); + const result = responseValue(exec); + expect(exec.result.success).toBe(true); + expect(result.ok).toBe(true); + expect(JSON.stringify(result.value)).toContain("executor-process:"); + expect(JSON.stringify(result.value)).toContain(join(state.executorRoot, "project")); + expect(JSON.stringify(result.value)).not.toContain(state.main); + }); + + test("lists and reads bounded workspace data through the selected root", async () => { + const state = fixture(); + const list = responseValue(await state.coordinator.handle(state.request("list_directory", { path: "project" }))); + expect(list).toMatchObject({ ok: true, value: { path: "project" } }); + expect(JSON.stringify(list.value)).toContain("marker.txt"); + + const read = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/marker.txt", + maxBytes: 1024, + }))); + expect(read).toMatchObject({ ok: true, value: { content: "executor-before", bytes: 15 } }); + expect((read.value as { sha256: string }).sha256).toBe(sha256("executor-before")); + }); + + test("does not read an unbounded existing file while checking a write precondition", async () => { + const state = fixture(); + writeFileSync( + join(state.executorRoot, "project", "oversized.txt"), + Buffer.alloc(REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES + 1), + ); + const write = responseValue(await state.coordinator.handle(state.request("write_file", { + path: "project/oversized.txt", + content: "replacement", + expectedSha256: "0".repeat(64), + }))); + expect(write.ok).toBe(false); + expect(write.error).toContain("read limit"); + }); + + test("rejects traversal and symlink escapes on the executor", async () => { + const state = fixture(); + const traversal = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "../main/project/marker.txt", + }))); + expect(traversal.ok).toBe(false); + expect(traversal.error).toContain("escapes"); + + symlinkSync( + join(state.main, "project"), + join(state.executorRoot, "outside-link"), + process.platform === "win32" ? "junction" : "dir", + ); + const symlink = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "outside-link/marker.txt", + }))); + expect(symlink.ok).toBe(false); + expect(symlink.error).toContain("symlink"); + expect(readFileSync(join(state.main, "project", "marker.txt"), "utf8")).toBe("main-only"); + }); + + test("rejects hardlink aliases for both file reads and writes", async () => { + const state = fixture(); + const outside = join(state.main, "project", "marker.txt"); + linkSync(outside, join(state.executorRoot, "project", "outside-alias.txt")); + const read = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/outside-alias.txt", + }))); + expect(read.ok).toBe(false); + expect(read.error).toContain("hard-linked"); + + const write = responseValue(await state.coordinator.handle(state.request("write_file", { + path: "project/outside-alias.txt", + content: "escaped", + expectedSha256: sha256("main-only"), + }))); + expect(write.ok).toBe(false); + expect(write.error).toContain("hard-linked"); + expect(readFileSync(outside, "utf8")).toBe("main-only"); + }); + + test("rejects a workspace root replaced after local approval", async () => { + const state = fixture(); + renameSync(state.executorRoot, `${state.executorRoot}-approved`); + mkdirSync(join(state.executorRoot, "project"), { recursive: true }); + writeFileSync(join(state.executorRoot, "project", "marker.txt"), "replacement-root"); + const result = responseValue(await state.coordinator.handle(state.request("read_file", { + path: "project/marker.txt", + }))); + expect(result.ok).toBe(false); + expect(result.error).toContain("root identity changed"); + }); + + test("fails closed while the selected executor is offline and never invokes another path", async () => { + const state = fixture(); + state.setOnline(false); + const response = await state.coordinator.handle(state.request("exec", { + command: ["/bin/true"], + })); + expect(response.result.success).toBe(false); + expect(responseValue(response).error).toContain("local fallback is disabled"); + expect(state.invokeCount()).toBe(0); + }); + + test("keeps command execution disabled by default until an OS sandbox is supplied", async () => { + const state = fixture(); + const locked = new RemoteWorkspaceExecutor({ + deviceId: "locked-device", + roots: [{ id: "project-root", path: state.executorRoot }], + }); + const result = await locked.invoke({ + requestId: randomUUID(), + sessionId: randomUUID(), + executorDeviceId: "locked-device", + rootId: "project-root", + tool: "exec", + arguments: { command: ["/bin/true"] }, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("OS sandbox"); + }); + + test("rejects unbound threads and non-remote namespaces before transport", async () => { + const state = fixture(); + const unbound = state.request("read_file", { path: "project/marker.txt" }); + (unbound.params as Record).threadId = `other-${randomUUID()}`; + expect(responseValue(await state.coordinator.handle(unbound)).error).toContain("not bound"); + + const wrongNamespace = state.request("read_file", { path: "project/marker.txt" }); + (wrongNamespace.params as Record).namespace = "local_workspace"; + expect(responseValue(await state.coordinator.handle(wrongNamespace)).error).toContain("identity"); + expect(state.invokeCount()).toBe(0); + }); + + test("carries coordinator requests and executor results over the authenticated E2EE channel", async () => { + const state = fixture(); + const account = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const cryptoDeviceId = randomUUID(); + const cryptoSessionId = randomUUID(); + const clientHandshake = RemoteControlClientHandshake.create({ + sessionId: cryptoSessionId, + deviceId: cryptoDeviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write", "workspace.exec"], + accountPrivateKey: account.privateKey, + }); + const accepted = acceptRemoteControlClientHello(clientHandshake.hello, { + expectedSessionId: cryptoSessionId, + expectedDeviceId: cryptoDeviceId, + accountPublicKey: account.publicKey, + devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write", "workspace.exec"], + }); + const clientCipher = clientHandshake.complete(accepted.hello, device.publicKey); + + let client: EncryptedRemoteWorkspaceTransport; + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + client = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: `device-${cryptoDeviceId}`, + cipher: clientCipher, + sendCiphertext: value => endpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + const encryptedExecutor = new RemoteWorkspaceExecutor({ + deviceId: `device-${cryptoDeviceId}`, + roots: [{ id: "project-root", path: state.executorRoot }], + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: `device-${cryptoDeviceId}`, + sessionId: cryptoSessionId, + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write"], + cipher: accepted.cipher, + executor: encryptedExecutor, + sendCiphertext: value => client.receiveCiphertext(value), + }); + + const result = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "read_file", + arguments: { path: "project/marker.txt" }, + }); + expect(result).toMatchObject({ ok: true, value: { content: "executor-before" } }); + expect(JSON.stringify(result)).not.toContain(state.main); + client.close(); + }); + + test("fragments large writes and reads without raising the relay frame memory limit", async () => { + const state = fixture(); + const account = generateRemoteControlIdentityKeyPair(); + const device = generateRemoteControlIdentityKeyPair(); + const cryptoDeviceId = randomUUID(); + const cryptoSessionId = randomUUID(); + const clientHandshake = RemoteControlClientHandshake.create({ + sessionId: cryptoSessionId, + deviceId: cryptoDeviceId, + commandProfile: "codex", + capabilities: ["workspace.read", "workspace.write"], + accountPrivateKey: account.privateKey, + }); + const accepted = acceptRemoteControlClientHello(clientHandshake.hello, { + expectedSessionId: cryptoSessionId, + expectedDeviceId: cryptoDeviceId, + accountPublicKey: account.publicKey, + devicePrivateKey: device.privateKey, + allowedCapabilities: ["workspace.read", "workspace.write"], + }); + const clientCipher = clientHandshake.complete(accepted.hello, device.publicKey); + const content = "remote-fragment\n".repeat(10_000); + + let client: EncryptedRemoteWorkspaceTransport; + let endpoint: EncryptedRemoteWorkspaceExecutorEndpoint; + client = new EncryptedRemoteWorkspaceTransport({ + executorDeviceId: `device-${cryptoDeviceId}`, + cipher: clientCipher, + sendCiphertext: value => endpoint.receiveCiphertext(value), + timeoutMs: 5_000, + }); + endpoint = new EncryptedRemoteWorkspaceExecutorEndpoint({ + executorDeviceId: `device-${cryptoDeviceId}`, + sessionId: cryptoSessionId, + rootId: "project-root", + capabilities: ["workspace.read", "workspace.write"], + cipher: accepted.cipher, + executor: new RemoteWorkspaceExecutor({ + deviceId: `device-${cryptoDeviceId}`, + roots: [{ id: "project-root", path: state.executorRoot }], + }), + sendCiphertext: value => client.receiveCiphertext(value), + }); + + const write = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "write_file", + arguments: { path: "project/large.txt", content, expectedSha256: null }, + }); + expect(write).toMatchObject({ ok: true, value: { bytes: Buffer.byteLength(content) } }); + const read = await client.invoke({ + requestId: randomUUID(), + sessionId: cryptoSessionId, + executorDeviceId: `device-${cryptoDeviceId}`, + rootId: "project-root", + tool: "read_file", + arguments: { path: "project/large.txt", maxBytes: REMOTE_WORKSPACE_MAX_TOOL_RESULT_BYTES }, + }); + expect(read).toMatchObject({ ok: true, value: { content } }); + client.close(); + endpoint.close(); + }); +}); diff --git a/tests/fake-codex-server.ts b/tests/fake-codex-server.ts index dc71863a1b..e77a763d45 100644 --- a/tests/fake-codex-server.ts +++ b/tests/fake-codex-server.ts @@ -177,6 +177,10 @@ async function handleMessage(msg: Record): Promise { return; } switch (method) { + case "config/read": { + respond(id, { config: {}, origins: {}, layers: null }); + return; + } case "thread/start": { if (script.rejectThreadStart) { respondError(id, script.rejectThreadStart); diff --git a/tests/fixtures/fake-claude-stream.ts b/tests/fixtures/fake-claude-stream.ts new file mode 100644 index 0000000000..3f1c7bb9f3 --- /dev/null +++ b/tests/fixtures/fake-claude-stream.ts @@ -0,0 +1,8 @@ +let input = ""; +for await (const chunk of Bun.stdin.stream()) input += new TextDecoder().decode(chunk); +const text = input.trim() ? `Hub answer: ${input.trim()}` : "Hub answer"; +process.stdout.write(`${JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text }] }, +})}\n`); +process.stdout.write(`${JSON.stringify({ type: "result", is_error: false, result: text })}\n`); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6e98e236f0..efaa7923a8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -886,6 +886,22 @@ "release-notes.test.ts": "ci-workflows", "release-version-line.test.ts": "ci-workflows", "remote-catalog.test.ts": "clients", + "remote-workspace-secret-store.test.ts": "clients", + "remote-workspace-session-binding.test.ts": "clients", + "remote-workspace-agent-wire.test.ts": "clients", + "remote-workspace-app-server.integration.test.ts": "clients", + "remote-workspace-claude.integration.test.ts": "clients", + "remote-workspace-cli-runtimes.test.ts": "clients", + "remote-workspace-cli.test.ts": "clients", + "remote-workspace-codex-runtime.test.ts": "clients", + "remote-workspace-command-runner.test.ts": "clients", + "remote-workspace-device.test.ts": "clients", + "remote-workspace-hub.test.ts": "clients", + "remote-workspace-linux-confinement.test.ts": "clients", + "remote-workspace-platform.test.ts": "clients", + "remote-workspace-sessions.test.ts": "clients", + "remote-workspace-tool-bridge.test.ts": "clients", + "remote-workspace.test.ts": "clients", "remote-control-prototype.test.ts": "clients", "remote-workspace-protocol.test.ts": "clients", "remote-workspace-rpc-framing.test.ts": "clients", From a3182185f0e089504d72e5729e4674cf0dc07ea1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:01:09 +0900 Subject: [PATCH 04/53] style(remote): remove trailing blank line in runner --- src/remote-control/workspace-command-runner.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/remote-control/workspace-command-runner.ts b/src/remote-control/workspace-command-runner.ts index f9a3625caa..1b2fe74628 100644 --- a/src/remote-control/workspace-command-runner.ts +++ b/src/remote-control/workspace-command-runner.ts @@ -746,4 +746,3 @@ export function linuxRemoteWorkspaceCommandRunnerAvailable( availabilityCache.set(cacheKey, available); return available; } - From 718a7ccf00e56e29da5ebf2bc573b4a7dddeed58 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:09:00 +0900 Subject: [PATCH 05/53] feat(codex): add reset-first pool ordering through canonical settings Adapt #4080 for the shared pool kernel, canonical settings API, cache-affinity policy, reset unit normalization and independent quota scopes. Co-authored-by: Terry Tan --- devlog/_plan/260912_accounts/030_reset.md | 6 + .../260912_accounts/031_reset_delivery.md | 9 ++ .../fr/reference/configuration/providers.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- gui/src/account-pool-strategy.ts | 3 +- .../AccountPoolStrategyControls.tsx | 6 +- gui/src/components/CodexAccountPool.tsx | 2 +- gui/src/components/CodexAutoSwitchSetting.tsx | 4 + .../components/CodexPoolStrategySetting.tsx | 1 + gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/tests/account-pool-strategy.test.tsx | 27 ++++- src/cli/account-extended.ts | 2 +- src/cli/account.ts | 2 +- src/codex/auth-api.ts | 14 +-- src/codex/routing.ts | 79 +++++++++++-- src/oauth/pool-kernel.ts | 9 ++ src/oauth/pool-settings-capability.ts | 4 +- src/server/management/oauth-account-routes.ts | 7 +- src/types/config.ts | 2 +- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/codex-home.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/design-methodology.md | 2 + structure/gui-and-management-api.md | 2 + structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/openai-tiers.md | 8 ++ structure/providers/xai-grok.md | 2 + structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + .../codex-pool-rotation.test.ts | 111 ++++++++++++++++++ .../account-pool-management-api.test.ts | 32 +++++ 54 files changed, 363 insertions(+), 35 deletions(-) create mode 100644 devlog/_plan/260912_accounts/031_reset_delivery.md diff --git a/devlog/_plan/260912_accounts/030_reset.md b/devlog/_plan/260912_accounts/030_reset.md index 875a6dccc5..c2347f8782 100644 --- a/devlog/_plan/260912_accounts/030_reset.md +++ b/devlog/_plan/260912_accounts/030_reset.md @@ -14,3 +14,9 @@ Additional MODIFY `src/oauth/pool-settings-capability.ts` and `src/server/manage Field chain: CLI/GUI strategy creation → canonical PUT parser → config.accountPoolStrategy write → config load + canonical GET parser → pool rotation/preview/failover, CLI and GUI display. Audit every existing strategy comparison/default, not just the union. No schema migration or new dependency. Exact contributor diff remains `.tmp/accounts-20260912/pr4080.diff` during planning; changes are adapted to current callers before B. Extend regression sources for canonical PUT/GET/save/reload, legacy endpoint, non-Codex rejection, tied/missing/elapsed resets, threshold zero, priorities, affinity and failover. Existing #4080 test cases are retained/adapted. Update all source ownership docs; screenshot of final rendered strategy control is included with PR. Local suites/build/typecheck/install NOT RUN; final head hosted CI supplies proof. #3376 remains partial until history/capacity; monthly/Anthropic/latest-first scope is reported separately. + +P revalidation: #4080 head unchanged. Current pool-rotation.ts is a compatibility facade, so Codex parser/normalizer live in existing src/oauth/pool-kernel.ts leaf and are reexported. Canonical GET DTO and PUT parser use Codex-specific parser only for kind=codex. Use existing resetAtToMs for both seconds/milliseconds before comparing future deadlines. Existing manualPreferenceBlocks remains at promotion; reset-first affinity calls mayRebindAffinityForQuota so pool.cacheAffinity retains a healthy bound account until genuine exhaustion. Current config parser preserves accountPoolStrategy through passthrough, so canonical save/reload regression is required. User limits unchanged; previous eligibility D delivered PR4361 with hosted/render pending, reset-first remains independent. + +A1 accepted: independent spark/reserve quota scopes use the existing quota strategy consistently for initial selection, preview, affinity and alternates; shared 5h/weekly reset timestamps are not their evidence. Add private `accountPoolStrategyForScope(config, quotaScope)` in routing.ts: normalize the configured Codex strategy, then return quota when reset-first and isIndependentCodexQuotaScope(scope), otherwise the normalized strategy. Use it in pickUnboundStrategyAccount, pickAlternateCodexAccount, previewReusableAffinityAccount and reevaluateAffinityQuota. Shared promotion remains scope-guarded and uses configured normalized strategy. Config remains reset-first, DTO shows configured value and docs explain effective independent-scope fallback. Tests oppose shared reset versus usage order, include scoped cooldown and unchanged shared cursor. + +Config decision: retain existing passthrough compatibility rather than add an unrelated disk-validation policy in this carry. Canonical/legacy management writes validate through Codex parser, and all runtime consumers normalize malformed direct config values to quota as before. Explicit invalid parser/API and save/reload tests verify this boundary; no whole-config reset is introduced. diff --git a/devlog/_plan/260912_accounts/031_reset_delivery.md b/devlog/_plan/260912_accounts/031_reset_delivery.md new file mode 100644 index 0000000000..06ea2777c2 --- /dev/null +++ b/devlog/_plan/260912_accounts/031_reset_delivery.md @@ -0,0 +1,9 @@ +# Reset-first carry follows the current pool contract + +Adapts #4080 ecf6b4e48a4c2992c296fada2caf6a8132313eaa by Terry Tan. The Codex parser now lives in the existing shared kernel leaf, canonical and legacy settings round-trip the configured strategy, and the GUI offers it only on Codex. Existing runtime priority, manual preference and cache-affinity behavior is preserved. Mixed reset units are normalized before ordering; independent model quota scopes retain existing quota selection. + +Regression sources include original reset-first cases plus mixed units, cacheAffinity on/off, scoped fallback/health/shared cursor, canonical and legacy persistence, non-Codex rejection and GUI empty-response normalization. UI hints reflect current cache-affinity and scope semantics. Local tests/build/typecheck/install: NOT RUN. git diff --check is whitespace evidence only; independent source review and hosted final-tip CI/render evidence follow. + +Source search: accountPoolStrategy, normalizeAccountPoolStrategy, resetAtToMs, pool/settings, mayRebindAffinityForQuota, manualPreferenceBlocks and all strategy consumers. Existing pool-kernel and routing owners extended; no new dependency or separate pool implementation. Config passthrough behavior preserved deliberately; write routes validate through the Codex-specific parser. + +Co-authored-by: Terry Tan diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 576b13c1bb..e642700298 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -39,7 +39,7 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de | `codexAccountPriorities?` | `Record` | — | Ordre de sélection par compte pour le pool Codex : identifiant de compte → entier de `-100` à `100`, **les valeurs élevées sont prioritaires**, une valeur absente équivaut à `0`. Cette limite porte sur le classement, et non sur l'admissibilité : la sélection retient, parmi les comptes déjà admissibles, le niveau prioritaire le plus élevé qui dispose encore d'une marge de quota, puis `accountPoolStrategy` choisit un compte dans ce niveau. Un niveau est ignoré uniquement lorsque chacun de ses membres dépasse `autoSwitchThreshold`, est en temporisation, est temporairement évité, est suspendu ou doit être réauthentifié ; un quota inconnu ne suffit jamais à considérer un niveau comme épuisé. L'ordre ne rend jamais admissible un compte qui ne l'est pas et ne réaffecte jamais une tâche déjà liée à un compte. Le compte principal `__main__` participe selon les mêmes règles ; la connexion Codex Desktop peut ainsi être configurée pour être utilisée en dernier. Sans entrée, le pool se comporte exactement comme auparavant. Un mappage mal formé est ignoré avec un avertissement dans la console : l'ordre est désactivé et la configuration n'est pas réparée. Ce champ est géré par `ocx account priority` et la page Codex Auth. | | `activeCodexAccountPinned?` | `string` | — | Identifiant du compte du dernier opérateur sélectionné manuellement. Lorsqu'il est défini, un niveau `codexAccountPriorities` supérieur ne peut pas le préempter jusqu'à ce que la broche soit libérée par drainage, exclusion, suppression ou un failover/promotion explicite. Un mouvement circulaire ordinaire à l’intérieur du niveau plafonné ne le libère pas. L'écriture d'une entrée `codexAccountPriorities` libère également le pin, donc un pin créé avant qu'un ordre n'existe ne peut pas surpasser un ensemble par la suite. `GET /api/codex-auth/active` indique à la fois si le compte effectif est épinglé (`pinned`) et le compte portant le plafond (`pinnedAccountId`). | | `autoSwitchThreshold?` | `number` | `80` | Seuil d'utilisation pour la commutation proactive. `quota` peut réévaluer les requêtes non liées lors de leur prochaine requête et, par défaut, réévalue aussi les tâches liées une fois ce seuil franchi. Avec `pool.cacheAffinity` activé, une tâche liée conserve son compte au-delà du seuil jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir. `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. `reset-first`: Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation. Les resets mensuels ne déterminent pas cet ordre. | | `pool.cacheAffinity?` | `boolean` | `false` | Ordre d'affinité de cache optionnel pour les threads Codex liés, indépendant de `pool.kernel`. Désactivé par défaut ; une valeur mal formée est lue comme désactivée. Une fois activé, une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a franchi `autoSwitchThreshold`. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, ou réellement épuisé (utilisation connue à 100 %) — l'affinité est donc un réordonnancement, pas un verrouillage. | | `accountPoolStickyLimit?` | `number` | `1` | Nombre d'affectations de tâches nouvelles ou non liées conservées sur une même sélection tournante avant de passer à la suivante ; le compteur avance lorsqu'une tâche est liée, et non après une réponse réussie en amont. Plage : 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Nombre d'échecs transitoires consécutifs avant le basculement des futures nouvelles sessions. Réglez `0` pour désactiver ce mécanisme. Pour les requêtes Responses ordinaires et les envois compacts natifs, les échecs avérés d'accessibilité DNS/TCP avant connexion sont suivis au niveau du couple fournisseur-hôte : ils n'affectent jamais l'état ni la temporisation du compte, l'affinité de tâche ou de session, la sélection du compte actif ou le routage du pool, et ne sont jamais comptabilisés dans ce seuil. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 5523430c80..31451fa18c 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -37,7 +37,7 @@ GUI で登録または OAuth ログインが完了すると、Models ページ | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | | `codexAccountPriorities?` | `Record` | — | Codex pool のアカウント別選択順。アカウント ID → `-100` から `100` の整数で、**大きいほど先に使われ**、未設定は `0` です。これは eligibility ではなく順序の境界です。選択は適格なアカウントを、まだ quota に余裕がある最上位 tier に絞り込み、その tier の中を `accountPoolStrategy` が選びます。tier が飛ばされるのは、そのメンバー全員が `autoSwitchThreshold` 超過、cooldown 中、soft-avoid、一時停止、または再認証待ちのときだけで、usage 不明が tier を drain させることはありません。順序付けが不適格なアカウントを選択可能にすることはなく、すでにアカウントが結び付いた thread を再 bind することもありません。メインの `__main__` も同じ条件で参加するため、Codex Desktop ログインを最後に使わせられます。エントリが 1 つもなければ挙動は従来どおりです。map が不正な場合は警告を出して順序付けを無効にします(config の修復処理は走りません)。`ocx account priority` と Codex Auth ページで管理します。 | | `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価でき、既定では使用量がこのしきい値を超えると紐付け済みタスクも再評価します。`pool.cacheAffinity` がオンなら、紐付け済みタスクはアカウントが使い切られるか処理できなくなるまでしきい値超過後も同じアカウントを維持します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 `reset-first`: 使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。 月次リセットはこの順序に使用しません。 | | `pool.cacheAffinity?` | `boolean` | `false` | 紐付け済み Codex スレッド向けのオプトイン cache-affinity 順序。`pool.kernel` とは独立で、既定はオフです。不正な値はオフとして読みます。オンにすると live な紐付けが quota 余裕より優先されます。`quota` は使用量が `autoSwitchThreshold` を超えたという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れるので、affinity は固定ではなく並べ替えです。 | | `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 | | `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index c63dfc9bc7..dae4836490 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -37,7 +37,7 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동 | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | | `codexAccountPriorities?` | `Record` | — | Codex pool의 계정별 선택 순서. 계정 ID → `-100`부터 `100`까지의 정수이며 **값이 클수록 먼저** 쓰이고, 항목이 없으면 `0`입니다. 이는 eligibility 경계가 아니라 순서 경계입니다. 선택은 이미 적격한 계정들을 quota 여유가 남은 최상위 tier로 좁히고, 그 tier 안에서 `accountPoolStrategy`가 계정을 고릅니다. tier를 건너뛰는 경우는 그 구성원 전부가 `autoSwitchThreshold` 초과, cooldown, soft-avoid, 일시 중지 또는 재인증 대기일 때뿐이며, usage를 알 수 없다고 해서 tier가 소진되지는 않습니다. 순서는 부적격 계정을 선택 가능하게 만들지 않고, 이미 계정에 묶인 thread를 다시 bind하지도 않습니다. 메인 `__main__` 계정도 동일한 조건으로 참여하므로 Codex Desktop 로그인을 마지막에 쓰도록 둘 수 있습니다. 항목이 하나도 없으면 동작은 이전과 같습니다. map이 잘못된 경우 경고를 출력하고 순서 지정을 끕니다(config 복구는 하지 않습니다). `ocx account priority`와 Codex Auth 페이지에서 관리합니다. | | `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있고, 기본값에서는 사용량이 이 임계값을 넘으면 바인딩된 작업도 재평가합니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 임계값을 넘어도 계정을 유지합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. `reset-first`: 사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다. 월간 초기화는 이 순서에 사용하지 않습니다. | | `pool.cacheAffinity?` | `boolean` | `false` | 바인딩된 Codex 스레드의 선택적 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 꺼짐입니다. 잘못된 값은 꺼진 것으로 읽습니다. 켜면 live 바인딩이 quota 여유보다 우선합니다. `quota`는 사용량이 `autoSwitchThreshold`를 넘었다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 여전히 떠나므로, affinity는 고정이 아니라 재정렬입니다. | | `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. | | `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 23b7e5e92d..c7b350be0e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -52,7 +52,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `codexAccountPriorities?` | `Record` | — | Per-account selection order for the Codex pool: account id → integer from `-100` to `100`, **higher is used earlier**, absent means `0`. This is an ordering boundary, not an eligibility one: selection narrows the already-eligible accounts to the highest tier that still has quota headroom, and `accountPoolStrategy` then picks within that tier. A tier is skipped only when every member is over `autoSwitchThreshold`, cooling down, soft-avoided, paused, or needs reauthentication — unknown quota never drains a tier. Ordering never makes an ineligible account selectable and never re-binds a thread that already has an account. The main `__main__` account participates on equal terms, which is how the Codex Desktop login can be set to drain last. With no entries the pool behaves exactly as before. A malformed map is ignored with a console warning (ordering off, no config repair). Managed by `ocx account priority` and the Codex Auth page. | | `activeCodexAccountPinned?` | `string` | — | Account id the operator last selected by hand. While set, a higher `codexAccountPriorities` tier cannot preempt it until the pin is released by drain, exclusion, deletion, or an explicit failover/promotion away. Ordinary round-robin movement inside the capped tier does not release it. Writing any `codexAccountPriorities` entry also releases the pin, so a pin made before an order existed cannot outrank one set afterward. `GET /api/codex-auth/active` reports both whether the effective account is pinned (`pinned`) and the account carrying the ceiling (`pinnedAccountId`). | | `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate unbound tasks on their next request, and by default also re-evaluates bound tasks once usage crosses this threshold. With `pool.cacheAffinity` on, a bound task keeps its account past the threshold until that account is exhausted or otherwise cannot serve. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. | | `pool.cacheAffinity?` | `boolean` | `false` | Opt-in cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. Off by default; a malformed value reads as off. With it on, a live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — so affinity is a reordering, not a pin. | | `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 9d132b0e94..d471f31c25 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -38,7 +38,7 @@ ocx models provider openrouter on | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | | `codexAccountPriorities?` | `Record` | — | Порядок выбора для каждого аккаунта пула Codex: id аккаунта → целое число от `-100` до `100`, **больше — используется раньше**, отсутствие означает `0`. Это граница порядка, а не пригодности: выбор сужает уже подходящие аккаунты до самого высокого уровня, у которого ещё есть запас квоты, а внутри этого уровня аккаунт выбирает `accountPoolStrategy`. Уровень пропускается, только когда все его аккаунты превысили `autoSwitchThreshold`, находятся в cooldown, под soft-avoid, на паузе или требуют повторной аутентификации; неизвестный usage никогда не исчерпывает уровень. Порядок не делает выбираемым непригодный аккаунт и не перепривязывает поток, у которого аккаунт уже есть. Основной аккаунт `__main__` участвует на равных — именно так логин Codex Desktop можно оставить на самый конец. Без записей поведение остаётся прежним. Некорректная map игнорируется с предупреждением в консоли (порядок отключается, восстановление config не запускается). Управляется через `ocx account priority` и страницу Codex Auth. | | `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос, а по умолчанию — и привязанную задачу, когда usage пересекает этот порог. При включённом `pool.cacheAffinity` привязанная задача сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. `reset-first`: Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию. Месячный сброс не определяет этот порядок. | | `pool.cacheAffinity?` | `boolean` | `false` | Опциональный порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию выключен; некорректное значение читается как выключенное. Когда флаг включён, живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage пересёк `autoSwitchThreshold`. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден или реально исчерпан (известный usage 100%). Affinity меняет порядок, а не закрепляет учётные данные. | | `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 3d72f129a7..05cb47c56e 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -39,7 +39,7 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b | `codexAccountPriorities?` | `Record` | — | Codex havuzu için hesap başına seçim sırası: hesap kimliği → `-100` ile `100` arası tam sayı, **daha yüksek olan daha önce kullanılır**, yoksa `0` anlamına gelir. Bu bir öncelik sırası sınırıdır, bir uygunluk sınırı değildir: seçim, zaten uygun olan hesapları hala kota payı bulunan en yüksek katmana daraltır ve `accountPoolStrategy` daha sonra bu katman içinde seçim yapar. Bir katman, yalnızca her üye `autoSwitchThreshold` üzerinde olduğunda, soğumada olduğunda, yumuşak kaçınıldığında, duraklatıldığında veya yeniden kimlik doğrulama gerektiğinde atlanır — bilinmeyen kota asla bir katmanı boşaltmaz. Sıralama asla uygun olmayan bir hesabı seçilebilir yapmaz ve zaten bir hesabı olan bir iş parçacığını asla yeniden bağlamaz. Ana `__main__` hesap eşit şartlarda katılır, bu sayede Codex Desktop girişi en son tükenecek şekilde ayarlanabilir. Hiçbir girdi olmadığında havuz tam olarak eskisi gibi davranır. Hatalı biçimlendirilmiş bir harita bir konsol uyarısıyla yok sayılır (sıralama kapalı, yapılandırma onarımı yok). `ocx account priority` ve Codex Auth sayfası tarafından yönetilir. | | `activeCodexAccountPinned?` | `string` | — | Operatörün en son elle seçtiği hesap kimliği. Ayarlandığı sürece, pin tükenme, hariç tutma, silme veya açık bir yük devretme/yükseltme ile serbest bırakılana kadar daha yüksek bir `codexAccountPriorities` katmanı onu öncelikleyemez. Sınırlı katman içindeki sıradan round-robin hareketi onu serbest bırakmaz. Herhangi bir `codexAccountPriorities` girdisi yazmak da pini serbest bırakır, böylece bir sıra var olmadan önce yapılan bir pin daha sonra ayarlanan bir pinin önüne geçemez. `GET /api/codex-auth/active`, hem geçerli hesabın sabitlenip sabitlenmediğini (`pinned`) hem de tavanı taşıyan hesabı (`pinnedAccountId`) bildirir. | | `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için kullanım eşiği. `quota`, bağımsız görevlerin bir sonraki isteğini yeniden değerlendirebilir ve varsayılan olarak kullanım bu eşiği geçince bağlı görevleri de yeniden değerlendirir. `pool.cacheAffinity` açıkken bağlı bir görev, hesap tükenene veya hizmet veremez hale gelene kadar eşiğin ötesinde hesabını korur. `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. `reset-first`: Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır. Aylık sıfırlamalar bu sıralamayı belirlemez. | | `pool.cacheAffinity?` | `boolean` | `false` | Bağlı Codex iş parçacıkları için isteğe bağlı önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak kapalıdır; hatalı bir değer kapalı okunur. Açıkken canlı bağlama kota payından öndedir: `quota`, kullanımın `autoSwitchThreshold`'u geçmesi nedeniyle iş parçacığını taşımaz. Hesap duraklatılmış, kullanılamaz veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır; bağlılık bir sabitleme değil yeniden sıralamadır. | | `accountPoolStickyLimit?` | `number` | `1` | İlerlemeden önce bir round-robin seçiminde tutulan yeni/bağımsız görev atamaları; sayaç yukarı akış başarısından sonra değil, bir görev bağlandığında ilerler. Aralık 1–100. | | `upstreamFailoverThreshold?` | `number` | `3` | Gelecekteki yeni oturumların yük devretmesinden önceki ardışık geçici arızalar. Devre dışı bırakmak için `0` ayarlayın. Düzenli Responses ve yerel sıkıştırma gönderimleri için kanıtlanmış bağlantı öncesi DNS/TCP erişilebilirlik arızaları sağlayıcı-ana bilgisayar düzeyinde izlenir: hesap sağlığını, hesap soğuma sürelerini, iş parçacığı/oturum bağlılığını, aktif hesap seçimini veya Havuz yönlendirmesini asla etkilemez ve bu eşiğe asla sayılmaz. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 9d0ed2dd76..62e8d4447c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -37,7 +37,7 @@ ocx models provider openrouter on | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | | `codexAccountPriorities?` | `Record` | — | Codex pool 各账号的选择顺序:账号 ID → `-100` 到 `100` 的整数,**数值越大越先使用**,未设置即为 `0`。这是顺序边界而非资格边界:选择会把已经合格的账号收窄到仍有 quota 余量的最高 tier,再由 `accountPoolStrategy` 在该 tier 内挑选。只有当某个 tier 的所有成员都超过 `autoSwitchThreshold`、处于 cooldown、被 soft-avoid、已暂停或需要重新认证时,该 tier 才会被跳过;usage 未知不会让 tier 耗尽。顺序不会让不合格的账号变得可选,也不会重新绑定已经绑定账号的 thread。主账号 `__main__` 同样参与排序,因此可以让 Codex Desktop 登录账号最后才被用到。没有任何条目时,行为与以往完全一致。映射格式非法时会打印警告并关闭排序(不会触发 config 修复)。可通过 `ocx account priority` 和 Codex Auth 页面管理。 | | `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务;默认在用量越过该阈值时也会重新评估已绑定任务。开启 `pool.cacheAffinity` 后,已绑定任务在越过阈值后仍会保留账号,直到该账号耗尽或无法继续服务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 `reset-first`: 在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。 此排序不使用月额度重置时间。 | | `pool.cacheAffinity?` | `boolean` | `false` | 已绑定 Codex 线程的可选 cache-affinity 排序,独立于 `pool.kernel`。默认关闭;非法值视为关闭。开启后,live 绑定优先于 quota 余量:`quota` 不会仅因用量越过 `autoSwitchThreshold` 就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,因此 affinity 是重排而非钉死。 | | `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index ff67793990..a5ca056c18 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -35,7 +35,7 @@ ocx models provider openrouter on | `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 | | `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 | | `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務,且預設在用量越過此閾值時也會重新評估綁定任務。開啟 `pool.cacheAffinity` 後,綁定任務在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 | -| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 | +| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 `reset-first`: 在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。 此排序不使用月額度重設時間。 | | `pool.cacheAffinity?` | `boolean` | `false` | 綁定 Codex 執行緒的選擇性 cache-affinity 排序,獨立於 `pool.kernel`。預設關閉;格式錯誤視為關閉。開啟後,即時綁定優先於配額餘裕:`quota` 不會只因用量越過 `autoSwitchThreshold` 就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,因此親和性是重排而非釘死。 | | `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 | | `upstreamFailoverThreshold?` | `number` | `3` | 未來新 session 容錯移轉前的連續暫時性失敗。設 `0` 停用。 | diff --git a/gui/src/account-pool-strategy.ts b/gui/src/account-pool-strategy.ts index b2532b0fc7..ef2de0a670 100644 --- a/gui/src/account-pool-strategy.ts +++ b/gui/src/account-pool-strategy.ts @@ -1,9 +1,10 @@ -export type AccountPoolStrategy = "quota" | "round-robin" | "fill-first"; +export type AccountPoolStrategy = "quota" | "round-robin" | "fill-first" | "reset-first"; export const ACCOUNT_POOL_STRATEGIES: readonly AccountPoolStrategy[] = [ "quota", "round-robin", "fill-first", + "reset-first", ] as const; /** Which cached usage bar the `quota` strategy scores. Mirrors `OcxAccountPoolQuotaWindow`. */ diff --git a/gui/src/components/AccountPoolStrategyControls.tsx b/gui/src/components/AccountPoolStrategyControls.tsx index d5023ca43f..2a813bcc4e 100644 --- a/gui/src/components/AccountPoolStrategyControls.tsx +++ b/gui/src/components/AccountPoolStrategyControls.tsx @@ -8,12 +8,14 @@ import { NumberStepper } from "./NumberStepper"; import { Select } from "../ui"; const STRATEGY_LABEL_KEYS = { + "reset-first": "accountPool.strategyResetFirst", quota: "accountPool.strategyQuota", "round-robin": "accountPool.strategyRoundRobin", "fill-first": "accountPool.strategyFillFirst", } as const; const STRATEGY_HINT_KEYS = { + "reset-first": "accountPool.strategyHintResetFirst", quota: "accountPool.strategyHintQuota", "round-robin": "accountPool.strategyHintRoundRobin", "fill-first": "accountPool.strategyHintFillFirst", @@ -21,6 +23,7 @@ const STRATEGY_HINT_KEYS = { export interface AccountPoolStrategyControlsProps { strategy: AccountPoolStrategy; + codex?: boolean; stickyDraft: string; disabled?: boolean; strategySelectId?: string; @@ -41,6 +44,7 @@ export interface AccountPoolStrategyControlsProps { */ export default function AccountPoolStrategyControls({ strategy, + codex = false, stickyDraft, disabled = false, strategySelectId = "account-pool-strategy", @@ -50,7 +54,7 @@ export default function AccountPoolStrategyControls({ onStickyCommit, }: AccountPoolStrategyControlsProps) { const t = useT(); - const strategyOptions = ACCOUNT_POOL_STRATEGIES.map((value) => ({ + const strategyOptions = ACCOUNT_POOL_STRATEGIES.filter(value => codex || value !== "reset-first").map((value) => ({ value, label: t(STRATEGY_LABEL_KEYS[value]), })); diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index f211f1c689..f2503c71c6 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -63,7 +63,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban invalid: t("codexAuth.autoSwitchThresholdInvalid"), }); const [poolStrategy, setPoolStrategy] = useState< - typeof DEFAULT_ACCOUNT_POOL_STRATEGY | "round-robin" | "fill-first" | null + typeof DEFAULT_ACCOUNT_POOL_STRATEGY | "round-robin" | "fill-first" | "reset-first" | null >(null); const { beginServerRead, acceptServerRead, rejectServerRead, hydrateServerValue } = autoSwitch; // A hook cannot be called conditionally, so the fallback instance is always created diff --git a/gui/src/components/CodexAutoSwitchSetting.tsx b/gui/src/components/CodexAutoSwitchSetting.tsx index 76d10825d5..cf1bcfcdf2 100644 --- a/gui/src/components/CodexAutoSwitchSetting.tsx +++ b/gui/src/components/CodexAutoSwitchSetting.tsx @@ -7,6 +7,10 @@ import { NumberStepper } from "./NumberStepper"; export type AutoSwitchFeedback = { tone: "ok" | "err"; message: string } | null; const AUTO_SWITCH_DESCRIPTION_KEYS = { + "reset-first": { + on: "accountPool.strategyHintResetFirst", + off: "codexAuth.autoSwitchQuotaOffDesc", + }, quota: { on: "codexAuth.autoSwitchQuotaDesc", off: "codexAuth.autoSwitchQuotaOffDesc", diff --git a/gui/src/components/CodexPoolStrategySetting.tsx b/gui/src/components/CodexPoolStrategySetting.tsx index e575baed0c..6e44d793ce 100644 --- a/gui/src/components/CodexPoolStrategySetting.tsx +++ b/gui/src/components/CodexPoolStrategySetting.tsx @@ -213,6 +213,7 @@ export default function CodexPoolStrategySetting({ )} {!loadError && ( = { "accountPool.strategy": "Rotationsstrategie", "accountPool.strategyDesc": "Wie OpenCodex einer neuen/ungebundenen Aufgabe ein Konto zuweist.", + "accountPool.strategyResetFirst": "Nächste Rücksetzung zuerst", + "accountPool.strategyHintResetFirst": "Unterhalb der Nutzungsschwelle wird die nächste 5-Stunden- oder Wochenrücksetzung bevorzugt. Gebundene Aufgaben folgen der konfigurierten Affinitätsregel. Unabhängige Modellkontingente werden nach Nutzung geordnet.", "accountPool.strategyQuota": "Kontingent", "accountPool.strategyRoundRobin": "Round-Robin", "accountPool.strategyFillFirst": "Fill-first", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1847a7af7e..d56980ec25 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1985,6 +1985,8 @@ export const en = { "accountPool.strategy": "Rotation strategy", "accountPool.strategyDesc": "How OpenCodex assigns an account to a new/unbound task.", + "accountPool.strategyResetFirst": "Soonest reset first", + "accountPool.strategyHintResetFirst": "Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering.", "accountPool.strategyQuota": "Quota", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "Fill-first", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e465adbb10..b7bf9bc613 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1915,6 +1915,8 @@ export const fr: Record = { "anthropicPool.off": "Désactivé", "accountPool.strategy": "Stratégie de rotation", "accountPool.strategyDesc": "Méthode utilisée par OpenCodex pour affecter un compte à une tâche nouvelle/non liée.", + "accountPool.strategyResetFirst": "Réinitialisation la plus proche", + "accountPool.strategyHintResetFirst": "Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation.", "accountPool.strategyQuota": "Quota", "accountPool.strategyRoundRobin": "Rotation", "accountPool.strategyFillFirst": "Remplissage prioritaire", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9a3d9f58b..37ce5fe420 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1842,6 +1842,8 @@ export const ja: Record = { "accountPool.strategy": "ローテーション戦略", "accountPool.strategyDesc": "OpenCodex が新規/未紐付けタスクへアカウントを割り当てる方法です。", + "accountPool.strategyResetFirst": "リセットが近い順", + "accountPool.strategyHintResetFirst": "使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。", "accountPool.strategyQuota": "クォータ", "accountPool.strategyRoundRobin": "ラウンドロビン", "accountPool.strategyFillFirst": "フィルファースト", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 67ee251970..456e7b7769 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1445,6 +1445,8 @@ export const ko: Record = { "accountPool.strategy": "로테이션 전략", "accountPool.strategyDesc": "OpenCodex가 새 작업/바인딩 없는 작업에 계정을 배정하는 방식입니다.", + "accountPool.strategyResetFirst": "가장 가까운 초기화 우선", + "accountPool.strategyHintResetFirst": "사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다.", "accountPool.strategyQuota": "할당량", "accountPool.strategyRoundRobin": "라운드로빈", "accountPool.strategyFillFirst": "필 퍼스트", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 56d43fc301..36d3751de2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1912,6 +1912,8 @@ export const ru: Record = { "accountPool.strategy": "Стратегия ротации", "accountPool.strategyDesc": "Как OpenCodex назначает аккаунт новой/непривязанной задаче.", + "accountPool.strategyResetFirst": "Ближайший сброс первым", + "accountPool.strategyHintResetFirst": "Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию.", "accountPool.strategyQuota": "Квота", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "Fill-first", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b627813bb9..e6576889d4 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1931,6 +1931,8 @@ export const tr: Record = { "accountPool.strategy": "Rotasyon stratejisi", "accountPool.strategyDesc": "OpenCodex'in yeni bir göreve nasıl hesap atayacağı.", + "accountPool.strategyResetFirst": "En yakın sıfırlama önce", + "accountPool.strategyHintResetFirst": "Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır.", "accountPool.strategyQuota": "Kota", "accountPool.strategyRoundRobin": "Round-robin", "accountPool.strategyFillFirst": "İlk doldurma", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ce06556fd4..622de701d1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1474,6 +1474,8 @@ export const zhTW: Record = { "anthropicPool.off": "關", "accountPool.strategy": "輪換策略", "accountPool.strategyDesc": "新會話如何從帳號池中選擇帳號。", + "accountPool.strategyResetFirst": "額度即將重設優先", + "accountPool.strategyHintResetFirst": "在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。", "accountPool.strategyQuota": "配額", "accountPool.strategyRoundRobin": "輪詢", "accountPool.strategyFillFirst": "填滿優先", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ae2fdfec92..be9db90ed2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1426,6 +1426,8 @@ export const zh: Record = { "accountPool.strategy": "轮换策略", "accountPool.strategyDesc": "OpenCodex 如何为新建/未绑定任务分配账号。", + "accountPool.strategyResetFirst": "额度即将刷新优先", + "accountPool.strategyHintResetFirst": "在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。", "accountPool.strategyQuota": "配额", "accountPool.strategyRoundRobin": "轮询", "accountPool.strategyFillFirst": "填满优先", diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx index 5f98969f99..1eb9ebcb5d 100644 --- a/gui/tests/account-pool-strategy.test.tsx +++ b/gui/tests/account-pool-strategy.test.tsx @@ -1,4 +1,4 @@ -import { putCodexPoolStrategy } from "../src/pool-settings"; +import { getPoolSettings, putPoolSettings, putCodexPoolStrategy } from "../src/pool-settings"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; @@ -91,6 +91,7 @@ describe("account pool strategy helpers", () => { expect(normalizeAccountPoolStrategy("quota")).toBe("quota"); expect(normalizeAccountPoolStrategy("round-robin")).toBe("round-robin"); expect(normalizeAccountPoolStrategy("fill-first")).toBe("fill-first"); + expect(normalizeAccountPoolStrategy("reset-first")).toBe("reset-first"); expect(normalizeAccountPoolStrategy("weighted")).toBe(DEFAULT_ACCOUNT_POOL_STRATEGY); expect(normalizeAccountPoolStrategy(undefined)).toBe("quota"); }); @@ -180,6 +181,19 @@ describe("AccountPoolStrategyControls", () => { expect(rr).toContain('value="2"'); }); + test("reset-first renders the dual-window threshold explanation", () => { + const markup = renderToStaticMarkup( + + {}} onStickyDraftChange={() => {}} onStickyCommit={() => {}} /> + , + ); + expect(markup).toContain("Soonest reset first"); + expect(markup).toContain("nearest future 5-hour or weekly reset"); + expect(markup).toContain("Bound tasks follow the configured affinity policy"); + expect(markup).not.toContain("New/unbound assignments before rotate"); + }); + test("renders a canonical setting row: visible name, control beside it, no sr-only label", () => { const markup = renderToStaticMarkup( @@ -518,3 +532,14 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => { expect(select?.getAttribute("aria-label")).toBe("Rotation strategy"); }); }); + + +test("canonical reset-first settings survive a read and an empty successful write", async () => { + const read = await getPoolSettings("", "openai", async () => Response.json({ provider: "openai", kind: "codex", strategy: "reset-first", stickyLimit: 1 })); + expect(read?.strategy).toBe("reset-first"); + const written = await putPoolSettings("", "openai", { strategy: "reset-first" }, async (_url, init) => { + expect(JSON.parse(String(init?.body))).toMatchObject({ provider: "openai", strategy: "reset-first" }); + return new Response(null, { status: 204 }); + }); + expect(written?.strategy).toBe("reset-first"); +}); diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 18fca00fb7..1f22e41283 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -44,7 +44,7 @@ const EXTENDED_USAGE = `Usage: ocx account pause [--json] ocx account resume [--json] ocx account pause-exhausted [--json] - ocx account strategy [] [--json] + ocx account strategy [] [--json] ocx account sticky [<1-100>] [--json] ocx account remove --yes [--json] ocx account clear-cooldown [--json] diff --git a/src/cli/account.ts b/src/cli/account.ts index 4a8c6a0427..9313a15147 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -50,7 +50,7 @@ const ACCOUNT_USAGE = `Usage: ocx account pause [--json] ocx account resume [--json] ocx account pause-exhausted [--json] - ocx account strategy [] [--json] + ocx account strategy [] [--json] ocx account sticky [<1-100>] [--json] ocx account remove --yes [--json] ocx account clear-cooldown [--json] diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 09becf51ea..a6c418c600 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -58,9 +58,9 @@ import { MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, - normalizeAccountPoolStrategy, + normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, - parseAccountPoolStrategy, + parseCodexAccountPoolStrategy, parseAccountPriority, } from "./pool-rotation"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; @@ -2457,7 +2457,7 @@ export async function handleCodexAuthAPI( pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null, autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80, upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3, - accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy), + accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy), accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), }); } @@ -2488,12 +2488,12 @@ export async function handleCodexAuthAPI( return jsonResponse({ error: "strategy or stickyLimit required" }, 400); } const runtimeConfig = getRuntimeConfig(config); - let nextStrategy: NonNullable> | undefined; + let nextStrategy: NonNullable> | undefined; let nextSticky: NonNullable> | undefined; if (body.strategy !== undefined) { - const parsed = parseAccountPoolStrategy(body.strategy); + const parsed = parseCodexAccountPoolStrategy(body.strategy); if (parsed === null) { - return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400); + return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first, reset-first' }, 400); } nextStrategy = parsed; } @@ -2509,7 +2509,7 @@ export async function handleCodexAuthAPI( saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, - accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy), + accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy), accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), }); } diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 04c5b8b1ab..1c2dd4c76c 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -10,7 +10,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { POOL_KEY_CODEX, normalizeAccountPoolStickyLimit, - normalizeAccountPoolStrategy, + normalizeCodexAccountPoolStrategy, notePoolRotationFailure, notePoolRotationSuccess, peekRoundRobinAccount, @@ -1363,6 +1363,12 @@ function listEligibleCodexAccountIds( return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions); } +/** Shared reset timestamps are not evidence for independent model-quota groups. */ +function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) { + const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy); + return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy; +} + function stickyLimitForConfig(config: OcxConfig): number { return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit); } @@ -1394,6 +1400,32 @@ function hasCodexQuotaHeadroom( return usage < threshold; } +/** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */ +function pickResetFirstCodexAccount( + config: OcxConfig, + ids: readonly string[], + now: number, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); + if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now); + let earliest = Number.POSITIVE_INFINITY; + let candidates: string[] = []; + for (const id of available) { + const quota = getAccountQuota(id); + const resets = [quota?.shortResetAt, quota?.weeklyResetAt] + .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset)) + .map(resetAtToMs) + .filter(reset => reset > now); + const next = Math.min(...resets); + if (next < earliest) { + earliest = next; + candidates = [id]; + } else if (next === earliest) candidates.push(id); + } + return pickLowestUsageAmong(config, candidates, selectionOptions, now); +} + /** * Fill-first: keep selectable active under threshold; otherwise advance to the next * eligible id in stable sorted order after the current active (wrapping). @@ -1484,7 +1516,7 @@ function pickUnboundStrategyAccount( commitSharedActive = commit, commitAffinity = commit, ): string | null { - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + const strategy = accountPoolStrategyForScope(config, quotaScope); if (strategy === "quota") return null; const poolKey = codexPoolKeyForScope(quotaScope); @@ -1508,8 +1540,10 @@ function pickUnboundStrategyAccount( return picked; } - if (strategy === "fill-first") { - picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); + if (strategy === "fill-first" || strategy === "reset-first") { + picked = strategy === "reset-first" + ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions) + : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions); if (!picked) return null; if (commitSharedActive) { if (!isIndependentCodexQuotaScope(quotaScope) @@ -1642,7 +1676,7 @@ export function pickAlternateCodexAccount( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + const strategy = accountPoolStrategyForScope(config, quotaScope); // The exclusion is passed into eligibility rather than post-filtered off its // result: when the excluded account is the only healthy member of the top // tier, the tier walk must be free to descend instead of selecting that tier @@ -1655,6 +1689,9 @@ export function pickAlternateCodexAccount( const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions); return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions); } + if (strategy === "reset-first") { + return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions); + } return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions); } @@ -1751,7 +1788,7 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void { /** Quota strategy persists; RR/fill-first keep a process-local cursor only. */ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") { setActiveCodexAccount(config, accountId); return; } @@ -1996,9 +2033,12 @@ function previewReusableAffinityAccount( ) { return null; } + if (accountPoolStrategyForScope(config, quotaScope) === "reset-first") { + return resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions) ?? entry.accountId; + } // Quota strategy only: non-quota strategies keep affinity for ongoing threads // (new-session-only rotation — docs / affinity policy A). - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + if (accountPoolStrategyForScope(config, quotaScope) === "quota") { const threshold = config.autoSwitchThreshold ?? 80; if (threshold > 0) { const usage = computeCodexUsageScore( @@ -2051,6 +2091,21 @@ function mayRebindAffinityForQuota( || (!isUnknownUsage(usage) && usage >= 100); } +/** Reset ordering may move a binding only under the existing cache-affinity release policy. */ +function resetFirstAffinityReplacement( + entry: ThreadAffinityEntry, + config: OcxConfig, + now: number, + quotaScope?: CodexQuotaScope, + selectionOptions?: CodexAccountUsabilityOptions, +): string | null { + const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now); + if (!mayRebindAffinityForQuota(config, entry.accountId, usage, config.autoSwitchThreshold ?? 80, selectionOptions)) return null; + const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true) + .filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); + return pickResetFirstCodexAccount(config, candidates, now, selectionOptions); +} + /** * Re-evaluate an affined account under the quota strategy. Returns a strictly * cooler replacement, or null when the current binding should remain. @@ -2062,7 +2117,13 @@ function reevaluateAffinityQuota( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota") return null; + const strategy = accountPoolStrategyForScope(config, quotaScope); + if (strategy === "reset-first") { + const replacement = resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions); + if (replacement || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) entry.lastReevalAt = now; + return replacement; + } + if (strategy !== "quota") return null; const threshold = config.autoSwitchThreshold ?? 80; const usage = threshold > 0 ? computeCodexUsageScore( @@ -2283,7 +2344,7 @@ export function resolveCodexAccountForThreadDetailed( const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions); if (cooler) { if (!isIndependentCodexQuotaScope(quotaScope)) { - setActiveCodexAccount(config, cooler); + promoteActiveCodexAccount(config, cooler); } bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks return { status: "selected", accountId: cooler }; diff --git a/src/oauth/pool-kernel.ts b/src/oauth/pool-kernel.ts index b36ac88e03..ffd535fe60 100644 --- a/src/oauth/pool-kernel.ts +++ b/src/oauth/pool-kernel.ts @@ -41,6 +41,15 @@ export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationSt return null; } +/** Codex alone supports ordering by the next shared quota reset. */ +export function parseCodexAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | "reset-first" | null { + return raw === "reset-first" ? raw : parseAccountPoolStrategy(raw); +} + +export function normalizeCodexAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | "reset-first" { + return parseCodexAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY; +} + /** Strict parse for management APIs — returns null instead of defaulting. */ export function parseAccountPoolStickyLimit(raw: unknown): number | null { if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) { diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts index 946a92d357..cf17309807 100644 --- a/src/oauth/pool-settings-capability.ts +++ b/src/oauth/pool-settings-capability.ts @@ -1,5 +1,5 @@ import { isGenericFailoverProvider } from "./generic-account-failover"; -import { parseAccountPoolStickyLimit, parseAccountPoolStrategy } from "./pool-kernel"; +import { parseAccountPoolStickyLimit, parseAccountPoolStrategy, parseCodexAccountPoolStrategy } from "./pool-kernel"; import type { OcxConfig, OcxProviderConfig } from "../types"; /** @@ -147,7 +147,7 @@ export function unifiedPoolSettingsDto( // honest answer is "not a field here" rather than a fabricated true. enabled: null, enabledEffective: true, - strategy: parseGenericPoolStrategy(config.accountPoolStrategy) ?? "quota", + strategy: parseCodexAccountPoolStrategy(config.accountPoolStrategy) ?? "quota", stickyLimit: parseGenericStickyLimit(config.accountPoolStickyLimit) ?? 1, autoSwitchThreshold: parseGenericAutoSwitchThreshold(config.autoSwitchThreshold) ?? 80, quotaWindow: null, diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 89f80e5f88..e795a3632d 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -39,6 +39,7 @@ import { normalizeAccountPoolStrategy, parseAccountPoolStickyLimit, parseAccountPoolStrategy, + parseCodexAccountPoolStrategy, } from "../../codex/pool-rotation"; import { normalizeAccountPoolQuotaWindow, parseAccountPoolQuotaWindow } from "../../oauth/anthropic-routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; @@ -383,8 +384,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // sticky limit is refused identically whichever pool is addressed. let strategy: string | undefined; if (fields.strategy !== undefined) { - const parsed = parseGenericPoolStrategy(fields.strategy); - if (parsed === null) return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400); + const parsed = kind === "codex" ? parseCodexAccountPoolStrategy(fields.strategy) : parseGenericPoolStrategy(fields.strategy); + if (parsed === null) return jsonResponse({ error: kind === "codex" + ? "strategy must be one of: quota, round-robin, fill-first, reset-first" + : "strategy must be one of: quota, round-robin, fill-first" }, 400); strategy = parsed; } let stickyLimit: number | undefined; diff --git a/src/types/config.ts b/src/types/config.ts index acfa35f868..39c09c5b42 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -860,7 +860,7 @@ export interface OcxConfig { /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ autoSwitchThreshold?: number; /** New-session account rotation strategy for the Codex pool. Default quota (today's behaviour). */ - accountPoolStrategy?: OcxAccountPoolRotationStrategy; + accountPoolStrategy?: OcxAccountPoolRotationStrategy | "reset-first"; /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ accountPoolStickyLimit?: number; /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */ diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index b0fab66633..bff454695e 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -63,3 +63,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/catalog.md b/structure/catalog.md index fbb356e2d3..b7b0f443e7 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -275,3 +275,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 36511b6f00..0b441e7e08 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -82,3 +82,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/codex-home.md b/structure/codex-home.md index b11ddd3f1a..2f57624bed 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -236,3 +236,5 @@ Injection preflights affected history using the normalized config candidate befo The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. Native restore preflight also checks manifest-owned targets whose rows already returned to `openai`, including interrupted restores. Preimage capture distinguishes absent files from unreadable artifacts and aborts before mutation when a complete snapshot cannot be read. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/config.md b/structure/config.md index 825811d9b3..37b888e2c8 100644 --- a/structure/config.md +++ b/structure/config.md @@ -199,3 +199,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 01d0cd4b0f..a697fa66a7 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -76,3 +76,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 45fe1c11e7..9de12f5b9e 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -96,3 +96,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/design-methodology.md b/structure/design-methodology.md index 51a2f158bc..8c73a17c4c 100644 --- a/structure/design-methodology.md +++ b/structure/design-methodology.md @@ -39,3 +39,5 @@ surfaces, run through all 3 stages in order. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a38b7970f0..5bf31db0e0 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -534,3 +534,5 @@ advances the observation clock, so a retained older row cannot defer evaluation ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 78d0e038ec..c78644da1b 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -312,3 +312,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 21bafe5b3b..3d42434fd5 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -139,3 +139,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/overview.md b/structure/overview.md index da3f2dc473..08a57d21d5 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -106,3 +106,5 @@ would pass while the rule was violated. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a44edff557..f88b50f51b 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -402,3 +402,11 @@ successful main usage refresh clears the runtime mark. ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +## Reset-first account ordering + +`src/codex/routing.ts` supports Codex-only `accountPoolStrategy: "reset-first"`. For new shared-quota assignments it chooses the earliest future short/weekly reset after existing eligibility, priority and usage-threshold filtering; ties and absent/elapsed deadlines use the existing usage order. Seconds and milliseconds are normalized with `resetAtToMs`. Threshold zero disables usage filtering while retaining reset ordering. Monthly deadlines do not order this strategy. + +Live bindings obey the existing cache-affinity release policy: with `pool.cacheAffinity`, threshold crossing alone retains a healthy account. Manual preference, scoped health and shared-cursor guards remain authoritative. Independent `spark`/`reserve` quota scopes resolve reset-first to existing quota selection because shared reset timestamps do not describe those windows. The configured value stays unchanged. + +The Codex parser in `src/oauth/pool-kernel.ts` is reexported by the compatibility facade and used by both `/api/pool/settings` and the legacy Codex settings route. Generic and Anthropic parsers reject reset-first. The dashboard offers it only for Codex; API, CLI and translated guides preserve the same contract. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5c497d4084..05c2512fc6 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -62,3 +62,5 @@ Account-scoped OAuth quota remains display evidence for provider-level Combo sel The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/runtime.md b/structure/runtime.md index 4763842283..8e70d7916d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -216,3 +216,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/subagents.md b/structure/subagents.md index 251d3ead81..f83ede0153 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -211,3 +211,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f0348c1cdd..28a1937c57 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -67,3 +67,5 @@ Quota publication distinguishes display reports from explicitly supplied inferen The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 5624a2e04e..912b6d757e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -520,3 +520,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index ae20c7c7ea..8383697d91 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -196,3 +196,5 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Codex pool settings and their consumers follow the [reset-first ordering contract](../providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index 1f7905a16f..a2005b7635 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -4,6 +4,8 @@ import { normalizeAccountPriority, notePoolRotationSuccess, parseAccountPriority, + parseAccountPoolStrategy, + parseCodexAccountPoolStrategy, peekRoundRobinAccount, pickRoundRobinAccount, selectPriorityTier, @@ -33,6 +35,7 @@ import { import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; import { clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { setAccountQuotaFromParsed } from "../../src/codex/quota"; import { getConfigPath } from "../../src/config"; import type { OcxConfig } from "../../src/types"; import { existsSync, mkdirSync, rmSync } from "node:fs"; @@ -358,6 +361,114 @@ describe("accountPoolStrategy new-session routing", () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); }); + test("reset-first is accepted only by the Codex strategy parser", () => { + expect(parseCodexAccountPoolStrategy("reset-first")).toBe("reset-first"); + expect(parseAccountPoolStrategy("reset-first")).toBeNull(); + expect(parseCodexAccountPoolStrategy("invalid")).toBeNull(); + }); + + test("reset-first compares both windows, previews without writes, and uses the same failover order", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + const seconds = now / 1000; + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: seconds + 600, shortPercent: 10, shortResetAt: seconds + 300 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 60, weeklyResetAt: seconds + 100, shortPercent: 20, shortResetAt: seconds + 500 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 20, weeklyResetAt: seconds + 900, shortPercent: 30, shortResetAt: seconds + 200 }); + expect(previewCodexAccountForRequest("reset-task", config, now)).toBe("b"); + expect(config.activeCodexAccountId).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(resolveCodexAccountForThread("reset-task", config, now)).toBe("b"); + expect(config.activeCodexAccountId).toBe("a"); + expect(pickAlternateCodexAccount(config, "b", now)).toBe("c"); + }); + + test("reset-first compares seconds and milliseconds in the same clock", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now + 30_000 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 60 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: now - 1 }); + expect(previewCodexAccountForRequest(null, config, now)).toBe("a"); + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + }); + + test("reset-first falls back to quota behavior for independent model windows", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 300 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 60, weeklyResetAt: now / 1000 + 10 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 200 }); + expect(previewCodexAccountForRequest("independent", config, now, "spark")).toBe("a"); + expect(resolveCodexAccountForThread("independent", config, now, "spark")).toBe("a"); + expect(resolveCodexAccountForThread(null, config, now, "shared")).toBe("b"); + expect(resolveCodexAccountForThread("independent", config, now, "spark")).toBe("a"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + recordCodexUpstreamOutcome(config, "a", 429, { now, resetAt: now / 1000 + 100, modelId: "gpt-5.3-codex-spark" }); + expect(pickAlternateCodexAccount(config, "a", now + 1, "spark")).toBe("c"); + expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); + expect(config.accountPoolStrategy).toBe("reset-first"); + }); + + test.each([false, true])("reset-first respects cacheAffinity=%s for bound tasks", cacheAffinity => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first", pool: { cacheAffinity } }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 30 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 60 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: now / 1000 + 90 }); + expect(resolveCodexAccountForThread("cached-reset", config, now)).toBe("a"); + setAccountQuotaFromParsed("a", { weeklyPercent: 90 }); + expect(previewCodexAccountForRequest("cached-reset", config, now + 1)).toBe(cacheAffinity ? "a" : "b"); + expect(resolveCodexAccountForThread("cached-reset", config, now + 1)).toBe(cacheAffinity ? "a" : "b"); + }); + + test("reset-first keeps affinity until either window reaches the threshold", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + const seconds = now / 1000; + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: seconds + 100 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: seconds + 200 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: seconds + 300 }); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("a"); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: seconds + 50 }); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("a"); + expect(resolveCodexAccountForThread("new", config, now)).toBe("b"); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, shortPercent: 80, shortResetAt: seconds + 10 }); + expect(previewCodexAccountForRequest("bound", config, now)).toBe("b"); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("b"); + setAccountQuotaFromParsed("b", { weeklyPercent: 80 }); + expect(resolveCodexAccountForThread("bound", config, now)).toBe("c"); + }); + + test("reset-first ignores past/missing resets and breaks ties by usage", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 - 1 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 30, weeklyResetAt: now / 1000 + 20 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 20, shortPercent: 10, shortResetAt: now / 1000 + 20 }); + expect(resolveCodexAccountForThread(null, config, now)).toBe("c"); + expect(resolveCodexAccountForThread(null, config, now + 20_000)).toBe("a"); + clearAccountQuota(); + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + }); + + test("reset-first preserves priority and availability and honors disabled thresholds", () => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 90, weeklyResetAt: now / 1000 + 10 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 20 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 30 }); + expect(resolveCodexAccountForThread(null, config, now)).toBe("b"); + config.autoSwitchThreshold = 0; + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + config.autoSwitchThreshold = 80; + setCodexAccountPriority(config, "c", 2); + expect(resolveCodexAccountForThread(null, config, now)).toBe("c"); + expect(pickAlternateCodexAccount(config, "c", now)).toBe("b"); + setAccountQuotaFromParsed("b", { weeklyPercent: 95 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 99 }); + expect(resolveCodexAccountForThread(null, config, now)).toBe("a"); + }); + test("round-robin strategy rotates unbound new sessions", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin" }); updateAccountQuota("a", 10); diff --git a/tests/server/account-pool-management-api.test.ts b/tests/server/account-pool-management-api.test.ts index feec9a8151..5c21a4c7fc 100644 --- a/tests/server/account-pool-management-api.test.ts +++ b/tests/server/account-pool-management-api.test.ts @@ -667,6 +667,38 @@ describe("unified pool-settings contract (#695 wp5c)", () => { if (dir) removeTreeWithRetry(dir); }); + test("reset-first round-trips through canonical and legacy Codex settings only", async () => { + const server = startServer(0); + try { + const write = async (provider: string, strategy: string) => fetch(new URL("/api/pool/settings", server.url), { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider, strategy }), + }); + const result = await write("openai", "reset-first"); + expect(result.status).toBe(200); + expect(await result.json()).toMatchObject({ kind: "codex", strategy: "reset-first" }); + expect(loadConfig().accountPoolStrategy).toBe("reset-first"); + const canonical = await fetch(new URL("/api/pool/settings?provider=openai", server.url)); + expect(await canonical.json()).toMatchObject({ strategy: "reset-first" }); + const legacy = new Request("http://localhost/api/codex-auth/active"); + const legacyRead = await handleCodexAuthAPI(legacy, new URL(legacy.url), loadConfig()); + expect(await legacyRead!.json()).toMatchObject({ accountPoolStrategy: "reset-first" }); + for (const provider of ["anthropic", "google-antigravity"]) { + const rejected = await write(provider, "reset-first"); + expect(rejected.status).toBe(400); + await rejected.text(); + } + const compatibility = new Request("http://localhost/api/codex-auth/pool-strategy", { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ strategy: "reset-first" }), + }); + const compatibilityWrite = await handleCodexAuthAPI(compatibility, new URL(compatibility.url), loadConfig()); + expect(compatibilityWrite!.status).toBe(200); + expect(await compatibilityWrite!.json()).toMatchObject({ accountPoolStrategy: "reset-first" }); + expect(loadConfig().accountPoolStrategy).toBe("reset-first"); + } finally { + await server.stop(true); + } + }); + test("every kind answers with the same keys and declares what it supports", async () => { const server = startServer(0); try { From 1c8457afc231a66a2b6b959394c33b81f73d4ee1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:11:11 +0900 Subject: [PATCH 06/53] fix(codex): retain reset-first affinity when threshold is disabled --- devlog/_plan/260912_accounts/030_reset.md | 2 ++ .../_plan/260912_accounts/031_reset_delivery.md | 2 ++ src/codex/routing.ts | 4 +++- .../codex-pool-rotation.test.ts | 16 ++++++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260912_accounts/030_reset.md b/devlog/_plan/260912_accounts/030_reset.md index c2347f8782..be34c48373 100644 --- a/devlog/_plan/260912_accounts/030_reset.md +++ b/devlog/_plan/260912_accounts/030_reset.md @@ -20,3 +20,5 @@ P revalidation: #4080 head unchanged. Current pool-rotation.ts is a compatibilit A1 accepted: independent spark/reserve quota scopes use the existing quota strategy consistently for initial selection, preview, affinity and alternates; shared 5h/weekly reset timestamps are not their evidence. Add private `accountPoolStrategyForScope(config, quotaScope)` in routing.ts: normalize the configured Codex strategy, then return quota when reset-first and isIndependentCodexQuotaScope(scope), otherwise the normalized strategy. Use it in pickUnboundStrategyAccount, pickAlternateCodexAccount, previewReusableAffinityAccount and reevaluateAffinityQuota. Shared promotion remains scope-guarded and uses configured normalized strategy. Config remains reset-first, DTO shows configured value and docs explain effective independent-scope fallback. Tests oppose shared reset versus usage order, include scoped cooldown and unchanged shared cursor. Config decision: retain existing passthrough compatibility rather than add an unrelated disk-validation policy in this carry. Canonical/legacy management writes validate through Codex parser, and all runtime consumers normalize malformed direct config values to quota as before. Explicit invalid parser/API and save/reload tests verify this boundary; no whole-config reset is introduced. + +C source audit found threshold=0/cacheAffinity=true could still rebind at100%. Accepted and fixed with early disabled-threshold return before reset-first affinity evaluation; new preview/resolve/all100 fixtures cover both cache settings. Failure recovery stays separate. Local suites NOT RUN; source re-audit and hosted CI pending. diff --git a/devlog/_plan/260912_accounts/031_reset_delivery.md b/devlog/_plan/260912_accounts/031_reset_delivery.md index 06ea2777c2..573f0b0f12 100644 --- a/devlog/_plan/260912_accounts/031_reset_delivery.md +++ b/devlog/_plan/260912_accounts/031_reset_delivery.md @@ -7,3 +7,5 @@ Regression sources include original reset-first cases plus mixed units, cacheAff Source search: accountPoolStrategy, normalizeAccountPoolStrategy, resetAtToMs, pool/settings, mayRebindAffinityForQuota, manualPreferenceBlocks and all strategy consumers. Existing pool-kernel and routing owners extended; no new dependency or separate pool implementation. Config passthrough behavior preserved deliberately; write routes validate through the Codex-specific parser. Co-authored-by: Terry Tan + +C source audit found threshold=0/cacheAffinity=true could still rebind at100%. Accepted and fixed with early disabled-threshold return before reset-first affinity evaluation; new preview/resolve/all100 fixtures cover both cache settings. Failure recovery stays separate. Local suites NOT RUN; source re-audit and hosted CI pending. diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 1c2dd4c76c..0c9fab2c56 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2099,8 +2099,10 @@ function resetFirstAffinityReplacement( quotaScope?: CodexQuotaScope, selectionOptions?: CodexAccountUsabilityOptions, ): string | null { + const threshold = config.autoSwitchThreshold ?? 80; + if (threshold <= 0) return null; const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now); - if (!mayRebindAffinityForQuota(config, entry.accountId, usage, config.autoSwitchThreshold ?? 80, selectionOptions)) return null; + if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null; const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true) .filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now)); return pickResetFirstCodexAccount(config, candidates, now, selectionOptions); diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index a2005b7635..d832cc5cdd 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -421,6 +421,22 @@ describe("accountPoolStrategy new-session routing", () => { expect(resolveCodexAccountForThread("cached-reset", config, now + 1)).toBe(cacheAffinity ? "a" : "b"); }); + test.each([false, true])("reset-first threshold zero retains a spent binding with cacheAffinity=%s", cacheAffinity => { + const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first", autoSwitchThreshold: 0, pool: { cacheAffinity } }); + const now = Date.now(); + setAccountQuotaFromParsed("a", { weeklyPercent: 10, weeklyResetAt: now / 1000 + 10 }); + setAccountQuotaFromParsed("b", { weeklyPercent: 20, weeklyResetAt: now / 1000 + 20 }); + setAccountQuotaFromParsed("c", { weeklyPercent: 30, weeklyResetAt: now / 1000 + 30 }); + expect(resolveCodexAccountForThread("zero-reset", config, now)).toBe("a"); + for (const id of ["a", "b", "c"]) setAccountQuotaFromParsed(id, { weeklyPercent: 100 }); + for (const later of [now + 1, now + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1]) { + expect(previewCodexAccountForRequest("zero-reset", config, later)).toBe("a"); + expect(resolveCodexAccountForThread("zero-reset", config, later)).toBe("a"); + } + recordCodexUpstreamOutcome(config, "a", 429, { now: now + 2, resetAt: now / 1000 + 300 }); + expect(pickAlternateCodexAccount(config, "a", now + 3)).not.toBe("a"); + }); + test("reset-first keeps affinity until either window reaches the threshold", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "reset-first" }); const now = Date.now(); From 5c648ea329ad81c390320b87883e71417c8c948c Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:12:46 +0900 Subject: [PATCH 07/53] test(codex): require a usable reset-first recovery alternate --- devlog/_plan/260912_accounts/031_reset_delivery.md | 2 ++ tests/codex-integration/codex-pool-rotation.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260912_accounts/031_reset_delivery.md b/devlog/_plan/260912_accounts/031_reset_delivery.md index 573f0b0f12..4b20ad2950 100644 --- a/devlog/_plan/260912_accounts/031_reset_delivery.md +++ b/devlog/_plan/260912_accounts/031_reset_delivery.md @@ -9,3 +9,5 @@ Source search: accountPoolStrategy, normalizeAccountPoolStrategy, resetAtToMs, p Co-authored-by: Terry Tan C source audit found threshold=0/cacheAffinity=true could still rebind at100%. Accepted and fixed with early disabled-threshold return before reset-first affinity evaluation; new preview/resolve/all100 fixtures cover both cache settings. Failure recovery stays separate. Local suites NOT RUN; source re-audit and hosted CI pending. + +Independent C re-audit PASS at eddc8c7b08; nonblocking oracle improvement accepted: assert actual alternate b, excluding null as a false recovery result. Hosted/runtime acceptance remains pending. diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index d832cc5cdd..89909b2391 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -434,7 +434,7 @@ describe("accountPoolStrategy new-session routing", () => { expect(resolveCodexAccountForThread("zero-reset", config, later)).toBe("a"); } recordCodexUpstreamOutcome(config, "a", 429, { now: now + 2, resetAt: now / 1000 + 300 }); - expect(pickAlternateCodexAccount(config, "a", now + 3)).not.toBe("a"); + expect(pickAlternateCodexAccount(config, "a", now + 3)).toBe("b"); }); test("reset-first keeps affinity until either window reaches the threshold", () => { From d3b3b6d525ec58b44ce5561fe6feebbdad38a4dc Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:15:57 +0900 Subject: [PATCH 08/53] feat(remote): integrate opt-in workspace dashboard and admission Carry #3458 dashboard and CLI wiring with explicit Hub opt-in, session-only mutations and awaited optional cleanup. Preserve current server and documentation owners; hosted preview and final cumulative CI remain pending. Co-authored-by: Ingwannu --- .../030_integration.md | 18 + docs-site/astro.config.mjs | 1 + .../src/content/docs/guides/remote-hub.md | 4 +- .../content/docs/guides/remote-workspace.md | 189 +++++++++ docs-site/src/content/docs/reference/cli.md | 13 + .../content/docs/reference/management-api.md | 28 ++ gui/src/App.tsx | 4 + gui/src/app-routing.ts | 2 + gui/src/i18n/de.ts | 60 +++ gui/src/i18n/en.ts | 60 +++ gui/src/i18n/fr.ts | 60 +++ gui/src/i18n/ja.ts | 60 +++ gui/src/i18n/ko.ts | 60 +++ gui/src/i18n/ru.ts | 60 +++ gui/src/i18n/tr.ts | 60 +++ gui/src/i18n/zh-TW.ts | 60 +++ gui/src/i18n/zh.ts | 60 +++ gui/src/pages/RemoteWorkspace.tsx | 381 ++++++++++++++++++ gui/src/remote-workspace-command.ts | 18 + gui/src/styles-remote-workspace.css | 75 ++++ gui/src/styles.css | 1 + gui/tests/fr-localization.test.ts | 4 + gui/tests/locale-parity.test.ts | 2 + gui/tests/remote-workspace.test.tsx | 180 +++++++++ gui/tests/sidebar-rows.test.ts | 4 +- scripts/test-layout/layout.json | 3 + .../ocx/references/01_management_surface.md | 47 ++- src/cli/capabilities.ts | 79 ++++ src/cli/dispatch.ts | 4 + src/cli/help.ts | 1 + src/cli/registry.ts | 11 + src/remote-control/workspace-activation.ts | 9 + src/remote-control/workspace-sessions.ts | 5 + src/server/index.ts | 182 ++++++++- src/server/management-api.ts | 16 + src/server/management/context.ts | 15 + .../management/remote-workspace-routes.ts | 140 +++++++ src/server/management/route-registry.ts | 9 + src/server/ws-bridge.ts | 5 +- structure/INDEX.md | 2 +- structure/adapters/registry.md | 2 + structure/catalog.md | 2 + structure/clients/claude-desktop.md | 2 + structure/config.md | 2 + structure/data-planes/images.md | 2 + structure/data-planes/inbound-compat.md | 2 + structure/design-methodology.md | 2 + structure/gui-and-management-api.md | 2 + structure/manifest.json | 2 +- structure/ops/docs-and-release.md | 2 + structure/ops/service-and-sidecars.md | 2 + structure/overview.md | 2 + structure/providers/xai-grok.md | 2 + structure/remote-workspace.md | 10 +- structure/runtime.md | 2 + structure/subagents.md | 2 + structure/transports/inventory.md | 2 + structure/transports/responses.md | 2 + structure/transports/streaming-health.md | 2 + tests/cli/cli-headless-parity.test.ts | 6 + .../remote-workspace-activation.test.ts | 44 ++ .../remote-workspace-management.test.ts | 195 +++++++++ tests/clients/remote-workspace-server.test.ts | 369 +++++++++++++++++ .../clients/remote-workspace-sessions.test.ts | 17 + tests/fixtures/test-layout-expected.json | 3 + .../loopback-listener-integration.test.ts | 2 +- 66 files changed, 2660 insertions(+), 14 deletions(-) create mode 100644 docs-site/src/content/docs/guides/remote-workspace.md create mode 100644 gui/src/pages/RemoteWorkspace.tsx create mode 100644 gui/src/remote-workspace-command.ts create mode 100644 gui/src/styles-remote-workspace.css create mode 100644 gui/tests/remote-workspace.test.tsx create mode 100644 src/remote-control/workspace-activation.ts create mode 100644 src/server/management/remote-workspace-routes.ts create mode 100644 tests/clients/remote-workspace-activation.test.ts create mode 100644 tests/clients/remote-workspace-management.test.ts create mode 100644 tests/clients/remote-workspace-server.test.ts diff --git a/devlog/_plan/260912_remote_workspace_carry/030_integration.md b/devlog/_plan/260912_remote_workspace_carry/030_integration.md index 5c126f5987..ad6fcc94d3 100644 --- a/devlog/_plan/260912_remote_workspace_carry/030_integration.md +++ b/devlog/_plan/260912_remote_workspace_carry/030_integration.md @@ -67,3 +67,21 @@ Local tests/build/typecheck/install NOT RUN by user instruction. Text comparison NEW src/remote-control/workspace-activation.ts exports a side-effect-free guard requiring runtimeRole=hub AND process.env.OCX_REMOTE_WORKSPACE_ENABLED === "1". This guard imports only the config type. Pair and agent branches call it before dynamic import; disabled requests return 404. Management namespace returns a disabled status before importing runtime. Shutdown uses already retained workspace references or initialized-only lazy import only when explicitly enabled; a disabled Hub never creates identity or probes model CLIs. CLI pairing remains explicit Executor-local authorization and never modifies server environment. Document the opt-in variable and require an explicit environment choice to enable the feature. Test disabled Hub, non-Hub with flag, and enabled Hub, with no ambient inheritance in fixtures. Existing-file conflicts observed by git apply --check: management-api.ts, management/context.ts and ws-bridge.ts. Port the namespace-dispatch addition into current management handler, append only type/dependency seam fields after current imports, and extend current WebSocket discriminator/handlers without replacing newer fields. The check was text applicability only, not a product test. + +## Phase-3 revalidation + +Previous D: runtime source cycle closed at a3182185f0 after corrected whitespace receipt. Final executable/native proof remains open; Windows commands unsupported. Continue integration from that exact parent. Carry current React resource/Select/Notice/icon conventions with no dependency additions. All locales inherit original translations with the unavailable-state opt-in message added consistently. + +Server adaptation: preserve current quota-reset and Grok coupon lazy dispatch. Add remote namespace handler before normal configuration routes. It answers disabled GET status with available:false and empty collections before loading workspace runtime; mutations when disabled refuse. Pair/agent paths require explicit guard before lazy imports and existing Origin/device-token validation. WebSocket data stores only structural receive/open/close callbacks; no concrete Hub class imports in ws-bridge. Upgrade closure owns hub/device association and close cleanup. Management dependency seams use structural Pick projections of only public Hub/session operations; all are import type and erased at runtime. Runtime modules use narrow config imports from phase 2, eliminating the prior broad runtime cycle. + +Shutdown: a promise-local initialized workspace module reference is set only on actual workspace route activation; shutdown calls initialized service getters only when that reference exists. It never dynamically imports remote runtime merely because runtimeRole is hub. Management-only activation also needs lifecycle-owned shutdown registration or a retained optional shutdown callback; resolve before B and test both paths. + +NEW tests/clients/remote-workspace-activation.test.ts covers hub+flag guard, disabled management status without store writes and unauthorized principal refusal before dependency construction. Existing server tests get explicit isolated flag setup/restore; no real devices. CLI capabilities list pair/agent/status, no Hub-status automation introduced. Regenerate skills/ocx reference surface through its existing generator (documentation only). Docs state OCX_REMOTE_WORKSPACE_ENABLED=1 opt-in, default read-only sessions, Linux conditional exec and both desktop native helpers refusing commands. + +Rendering: this worktree has no node_modules or gui/node_modules. Do not install or run a local build. Prefer final hosted package artifacts for a local static render with synthetic API responses; if no artifact exists, retain rendering as unmet acceptance and attach no historical screenshot as current evidence. + +### Awaited per-server cleanup decision + +The existing optional-shutdown registry is synchronous best-effort and cannot prove awaited Remote Workspace shutdown. Reuse server.stop's existing runListenerShutdown array instead. Add a per-server retained shutdown callback and a ManagementApiDeps onRemoteWorkspaceShutdown callback setter. Workspace management resolves its already-loaded services then registers an initialized-only cleanup closure through that setter; pair/agent loader registers the same kind of closure. server.stop calls the retained callback if present. No callback means no remote import/work. Keep registration idempotent and closure references scoped to the current config/server; tests cover management-only initialization and explicit stop. Do not change the global optional-shutdown API. + +In-flight initialization refinement: management checks per-server stopping before and after module import, creates Hub/session services synchronously in one turn, then registers initialized-only teardown. Pair/upgrade paths check stopping after lazy load. SessionService rejects create/resume after shutdown even when an availability promise completes later; a regression holds availability across shutdown. This prevents request initialization from creating resources after stop. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index b25586f7a6..b76a01cdd5 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -86,6 +86,7 @@ export default defineConfig({ translations: { fr: "Guides", ko: "가이드", "zh-CN": "指南", "zh-TW": "指南", ru: "Руководства", ja: "ガイド", tr: "Kılavuzlar" }, items: [ { label: "Remote Hub Deployment", translations: { fr: "Déploiement Remote Hub", ko: "Remote Hub 배포", "zh-CN": "Remote Hub 部署", "zh-TW": "Remote Hub 部署", ru: "Развёртывание Remote Hub", ja: "Remote Hub のデプロイ", tr: "Remote Hub Dağıtımı" }, slug: "guides/remote-hub" }, + { label: "Remote Workspace", translations: { fr: "Espace de travail distant", ko: "원격 워크스페이스", "zh-CN": "远程工作区", "zh-TW": "遠端工作區", ru: "Удалённая рабочая область", ja: "リモートワークスペース", tr: "Uzak Çalışma Alanı" }, slug: "guides/remote-workspace" }, { label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" }, { label: "Factory Droid Bridge", translations: { fr: "Pont Factory Droid", ko: "Factory Droid 브리지" }, slug: "guides/factory-droid" }, { label: "Cursor Private Inference", translations: { ko: "Cursor Private Inference" }, slug: "guides/cursor-private-inference" }, diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 0db5e7bcd5..bedd3e73a4 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -13,7 +13,9 @@ the hub's own processes dial `127.0.0.1:` with no credential, thr companion listener. Start from [the recipe below](#linux-systemd-or-macos-launchd), then hand a second machine a ready-made command with [`ocx hub invite`](#inviting-another-machine). -The management ingress never serves `/v1/*`, `/healthz`, `/readyz`, or WebSockets. Do not publish its +The management ingress never serves `/v1/*`, `/healthz`, or `/readyz`. When explicitly enabled, +Remote Workspace admits only its paired bearer-authenticated agent WebSocket and one-time pairing +exchange; see [Remote Workspace](/guides/remote-workspace/). Do not publish its port directly, do not add a cloud-firewall rule for it, and do not use Tailscale Funnel. Funnel is a public-internet surface and is outside this deployment model. diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md new file mode 100644 index 0000000000..2d5ffb814d --- /dev/null +++ b/docs-site/src/content/docs/guides/remote-workspace.md @@ -0,0 +1,189 @@ +--- +title: Remote Workspace +description: Keep Codex, Claude Code, Pi, and their logins on one OCX Hub while OCX-only computers provide the workspace and build environment. +--- + +Remote Workspace lets one OpenCodex Hub run your coding agents while another computer supplies the +project files, commands, tests, and build compute. A phone or third computer can control the session +through the Hub dashboard. + +```text +Phone browser -> Computer 1 OCX Hub -> encrypted channel -> Computer 2 OCX Executor + Codex / Claude / Pi project and commands + logins and sessions no coding CLI login +``` + +The Executor needs OpenCodex only. It does not need Codex, Claude Code, Pi, a ChatGPT login, or a +provider API key. It opens an outbound WebSocket to the Hub, so the Executor needs no public port or +router port-forward. + +:::caution[Experimental foundation] +Remote Workspace is opt-in and not a production rollout. Linux offers file tools and conditional +bubblewrap command execution. Windows and macOS offer file tools only: their official native +helpers reject probe and command requests. Windows commands remain unsupported until a verified +lifecycle owner can retain cleanup authority through cancellation. Missing command support never +falls back to executing on the Hub. +::: + +## Set up the Hub + +Computer 1 owns every coding-agent login and model session. Install and log in to whichever agents +you want to use there, then run OpenCodex as a Hub: + +```bash +ocx config set runtimeRole hub +OCX_REMOTE_WORKSPACE_ENABLED=1 ocx start +ocx gui +``` + +Set `OCX_REMOTE_WORKSPACE_ENABLED=1` on the Hub process itself; setting it only for a dashboard +command does not enable an already-running service. A Hub with no explicit opt-in returns disabled +status without creating workspace keys or probing coding-agent runtimes. + +Use an authenticated HTTPS deployment when opening the dashboard from a phone or another computer. +See [Remote Hub Deployment](/guides/remote-hub/) for the supported management-ingress and Tailscale +pattern. Do not publish an unauthenticated local dashboard port. + +Codex Remote Workspace uses current App Server permission profiles. If the Hub's selected Codex +configuration still sets legacy `sandbox_mode` or `sandbox_workspace_write`, the dashboard reports +Codex as unavailable instead of starting with a weaker boundary. Migrate that Codex profile before +using the feature; do not configure both the legacy sandbox and a permission profile. + +## Pair an Executor + +1. Open **Remote Workspace** in the Hub dashboard. +2. Select **Create pairing code**. +3. On Computer 2, change into the project directory you want to expose. +4. Copy the generated **Linux / macOS terminal** or **Windows PowerShell** command for that computer. + It pairs the current directory and keeps + `ocx remote-workspace agent` connected in that terminal. + +The equivalent manual flow is: + +```bash +cd /path/to/project +printf '%s\n' 'ONE-TIME-CODE' | ocx remote-workspace pair 'https://your-hub.example' \ + --pairing-code-stdin --root "$PWD" +ocx remote-workspace agent +``` + +On Windows PowerShell, use the command shown in the dashboard. The equivalent manual form is: + +```powershell +$pairingCode = 'ONE-TIME-CODE' +$pairingCode | ocx remote-workspace pair 'https://your-hub.example' ` + --pairing-code-stdin --root (Get-Location).Path +if ($LASTEXITCODE -eq 0) { ocx remote-workspace agent } +``` + +The current OCX Bun executable is added as one read-only file to the Linux sandbox automatically. If +the project needs a user-installed toolchain outside the system paths, pair it explicitly without +exposing the rest of the home directory: + +```bash +printf '%s\n' 'ONE-TIME-CODE' | ocx remote-workspace pair 'https://your-hub.example' \ + --pairing-code-stdin --root "$PWD" \ + --toolchain-root "$HOME/.nvm/versions/node/v24/bin" +``` + +The native helper source is packaged for review. Building it does not enable Windows or macOS +commands in this carry. `--executor-helper` remains a reviewed-helper selector; binary existence +or a configured path does not prove command support. + +The one-time code is read from standard input, not command-line arguments. Pairing creates a local +device signing key and a device-scoped bearer. The Hub stores only its hash and never receives the +real Executor path. Stop the foreground agent with Ctrl+C; running it again reconnects the same +device. + +Check local enrollment without printing secrets: + +```bash +ocx remote-workspace status +``` + +## Start a remote coding session + +In the dashboard choose: + +1. the online computer; +2. one locally approved workspace folder; +3. Codex, Claude Code, or Pi from the Hub; and +4. an access mode. + +**Read only** is the default and exposes directory listing and file reading. The write option is +shown as **Edit files and run commands** only when that Executor passed a command-sandbox probe; +otherwise it is shown as **Edit files only**. The dashboard shows two separate locations so it is +clear that the model and login remain on the Hub while workspace operations run on the selected +computer. + +Send prompts from the Hub dashboard on Computer 1, Computer 3, or a phone. The session cannot switch +to another computer or folder silently. If the Executor disconnects, the session enters +**Executor offline** and never falls back to the Hub's filesystem. + +**Stop** remains available while a prompt is running. It interrupts the Hub coding-agent turn, +cancels an active Executor command, and prevents a late response from reopening the stopped +session. + +## Restart and reconnect behavior + +The Hub persists bounded session metadata and a small recent event snapshot. After a Hub restart, +an unfinished session waits for its original Executor. Once that device reconnects, the next prompt +resumes the original Codex thread, Claude Code session, or Pi session ID. + +Claude Code creates its durable history on the first completed prompt. If the Hub stops before a +new Claude session has completed any prompt, there is no conversation to resume; start a new +session instead. + +A changed capability manifest does not silently weaken an existing session. Start a new session if +the Executor loses command containment or its available tools change. Revoking a computer closes its +socket and stops sessions bound to it. + +## Security boundaries + +- Provider credentials and coding-agent history remain on the Hub. +- Executor private keys, device bearer, and real root paths remain in its owner-only OCX state. +- Pairing-code failures are limited per kernel-observed peer on every listener. Ten failed codes in + ten minutes return a generic `429` with `Retry-After`; the Hub retains only bounded, expiring + hashes of those source identities. Tailscale Serve users share the management listener's loopback + bucket because a direct local caller could forge its identity header. +- Each work session uses an Ed25519-signed ephemeral P-256 ECDH handshake and ordered + AES-256-GCM messages. +- A socket is not shown as online until both sides agree on its current capability manifest. +- Reconnection may remove a capability when its local sandbox is unavailable, but never adds a + capability outside the grant recorded at pairing. +- Every request is bound to one model thread, device, root, access mode, and capability set. +- Paths are relative, canonicalized, bounded, and rejected on symlink, junction, or parent-directory + escape. Windows device names, alternate data streams, and trailing-dot/space aliases are denied. +- Executor operations are serialized, opened file identities are rechecked, and write hashes are + checked again immediately before atomic replacement. Replacing an approved root requires pairing + it again, and toolchain roots are revalidated before each command. +- File reads/writes reject hard-linked files. Before command execution, OCX scans at most 250,000 + workspace entries and disables the command path if any non-directory entry has multiple links; + path sandboxes cannot prove whether the other name for that inode is outside the approved root. +- Linux commands run through bubblewrap with one writable workspace, cleared environment, private + process namespaces, the current OCX Bun executable as one read-only file, bounded output + and timeout, and network disabled by default. Dedicated confinement tests require an explicitly + configured hosted environment; a green generic suite does not prove they ran. +- macOS advertises file tools only. A process group cannot contain a descendant after it calls + `setsid()`, and importing a broad Apple Seatbelt system profile merely to start a command would + expose unrelated host-service authority. The native helper therefore rejects both its probe and + direct command requests until OCX has a narrow, revocable descendant-containment owner. +- Windows and macOS native command requests fail closed. Their direct-helper refusal tests must be + distinguished from functioning command-confinement evidence; Windows command acceptance is open. +- The pinned native helper must be outside every approved writable workspace. OCX checks this both + before advertising command support and immediately before each command, so workspace code cannot + replace the binary that enforces its next sandbox. +- Stopping a session cancels an active Executor command and cleans up the Hub model process and + loopback tool bridge. Windows stops the owned npm-wrapper process tree rather than leaving its + Node child behind; Linux and macOS force-stop a CLI only if it ignores the graceful stop window. + +The Hub intentionally sees prompts and model output because it runs the coding agent. End-to-end +encryption protects Executor RPC payloads. The paired Hub is trusted to select approved roots over +authenticated WSS; it is not blind to its own model conversation. + +## Current scope + +Remote Workspace does not copy or synchronize credentials to other computers. It is separate from +Remote Hub provider routing and from any future hosted compute or Super Sync product. A production +release still requires signed Windows helper packaging, native CI proof on the exact binaries, +independent maintainer review, and a real three-computer acceptance run. diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index e39d0cc018..d91e6c1865 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -18,6 +18,19 @@ opencodex state. `ocx alias list [--json]` shows effective user and built-in aliases. Use `ocx alias set [/] ` and `ocx alias rm [/]` to edit them. Native model ids may contain additional slashes because the selector splits only at the first slash. Enable shipped defaults with `ocx alias defaults on|off [--provider ]`. +### `ocx remote-workspace` + +`ocx remote-workspace pair --pairing-code-stdin --root ` enrolls the local +computer as an OCX-only Executor. Repeat `--root` to approve more folders and use `--name` to +override the hostname. Repeat `--toolchain-root ` to expose a user-installed +Node, Rust, Go, or other toolchain directory read-only inside the command sandbox. On macOS and +Windows private-dogfood builds, `bun run build:remote-workspace-helper` creates the Rust helper that +the pair command discovers automatically; `--executor-helper ` selects another +explicitly reviewed build and pins its digest in local Executor state. +`ocx remote-workspace agent` maintains the outbound encrypted connection; +`ocx remote-workspace status [--json]` reports the Hub, device, roots, and advertised capabilities +without printing its bearer or private key. See [Remote Workspace](/guides/remote-workspace/). + - [Lifecycle](/reference/cli/lifecycle/) — setup, proxy and service lifecycle, health, diagnostics, catalog sync, the dashboard, and updates. - [Providers, accounts, and models](/reference/cli/providers-accounts/) — provider configuration, diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index cd8b2450c2..75a0f89fb2 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -145,6 +145,34 @@ should use the dedicated paths above so an older proxy cannot ignore a profile s See [Aside profile controls](/guides/integrations/#aside-profile-controls) for CLI commands and the proxy upgrade, restart, and retry sequence. +### Remote Workspace + +Requires Hub mode and `OCX_REMOTE_WORKSPACE_ENABLED=1` on the Hub process. Disabled status is +readable; mutations refuse without initializing workspace services. + +| Method and path | Purpose | Notable errors | +| --- | --- | --- | +| `GET /api/remote-workspace` | Read paired computers, current capabilities, Hub runtimes, and session snapshots | Disabled status when Hub role or explicit opt-in is absent | +| `POST /api/remote-workspace/pairing` | Create a ten-minute one-use Executor enrollment code | GUI session only; 429 pairing capacity | +| `GET /api/remote-workspace/runtimes` | Read Codex, Claude Code, and Pi availability on the Hub | — | +| `GET, POST /api/remote-workspace/sessions` | List sessions or start one bound to a device, root, runtime, and access mode | POST is GUI session only; 409 offline/unavailable/invalid target | +| `POST /api/remote-workspace/sessions/{id}/prompt` | Continue the bound model session | GUI session only; 409 active turn, offline Executor, or resume failure | +| `DELETE /api/remote-workspace/sessions/{id}` | Stop the model runtime and encrypted Executor session | GUI session only; 404 unknown session | +| `DELETE /api/remote-workspace/devices/{id}` | Revoke one computer and stop its sessions | GUI session only; 404 unknown device | + +Executor enrollment exchanges a one-use code at `POST /remote-workspace/pair` and then opens +`/remote-workspace/agent` as a bearer-authenticated outbound WebSocket. Those two machine endpoints +are not general management API authority. The bearer is device-scoped, and each work session adds a +signed E2EE handshake. Ten failed pairing codes from one kernel-observed peer return `429` with +`Retry-After` for the remainder of the fixed ten-minute window. Tailscale Serve clients share the +management listener's loopback peer bucket; the identity header is not used for throttling because +a direct local process could forge it. See [Remote Workspace](/guides/remote-workspace/) for the +end-user flow and trust boundaries. + +Session snapshots include `resumable`. It becomes true only after the selected coding-agent runtime +has durable history; notably, a new Claude Code session remains false until its first prompt +completes. + ### Combos | Method and path | Purpose | Notable errors | diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 91890ce664..95b175711f 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -10,6 +10,7 @@ import Storage from "./pages/Storage"; import CodexSet from "./pages/CodexSet"; import Integrations from "./pages/Integrations"; import Startup from "./pages/Startup"; +import RemoteWorkspace from "./pages/RemoteWorkspace"; import ErrorBoundary from "./components/ErrorBoundary"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; @@ -35,6 +36,7 @@ const PAGE_TKEY: Record = { logs: "nav.logs", usage: "nav.usage", storage: "nav.storage", + remote: "nav.remote", "codex-set": "nav.codexSet", integrations: "nav.integrations", }; @@ -68,6 +70,7 @@ const NAV: NavEntry[] = [ { id: "logs", tkey: "nav.logs", Icon: IconList }, { id: "usage", tkey: "nav.usage", Icon: IconActivity }, { id: "storage", tkey: "nav.storage", Icon: IconHardDrive }, + { id: "remote", tkey: "nav.remote", Icon: IconMonitor }, { id: "integrations", tkey: "nav.integrations", Icon: IconGlobe }, ]; @@ -432,6 +435,7 @@ export default function App() { {page === "logs" && } {page === "usage" && } {page === "storage" && } + {page === "remote" && } {page === "codex-set" && } {page === "integrations" && } diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index cf9762ab71..5d8588358b 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -11,6 +11,7 @@ export type Page = | "logs" | "usage" | "storage" + | "remote" | "codex-set" | "integrations"; @@ -23,6 +24,7 @@ export const VALID_PAGES = new Set([ "logs", "usage", "storage", + "remote", "codex-set", "integrations", ]); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 868e2a2855..5fc7be9304 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2705,4 +2705,64 @@ export const de: Record = { "models.pickerOrder.saveDraft": "Entwurf speichern", "models.pickerOrder.reloadDraft": "Neu laden und Entwurf verwerfen", "models.pickerOrder.catalogRequired": "Modellidentitäten fehlen oder sind mehrdeutig. Laden Sie die Modellseite neu, um den Katalog vor der Bearbeitung zu aktualisieren.", + "nav.remote": "Remote-Arbeitsbereich", + "remote.title": "Remote-Arbeitsbereich", + "remote.subtitle": "Codex, Claude Code oder Pi laufen auf diesem Hub; Dateien, Befehle, Tests und Builds bleiben auf dem ausgewählten Computer.", + "remote.loading": "Remote-Arbeitsbereich wird geladen…", + "remote.loadFailed": "Remote-Arbeitsbereich konnte nicht geladen werden.", + "remote.hubRequired": "Starten Sie den Hub im Hub-Modus mit OCX_REMOTE_WORKSPACE_ENABLED=1, um Remote Workspace zu aktivieren.", + "remote.refresh": "Aktualisieren", + "remote.addComputer": "Computer hinzufügen", + "remote.addComputerHint": "Gib lokal Ordner frei und halte den reinen OCX-Executor mit diesem Hub verbunden.", + "remote.createPairing": "Kopplungscode erstellen", + "remote.pairingCode": "Einmaliger Kopplungscode", + "remote.pairingExpires": "Läuft um {time} ab", + "remote.pairingCommand": "Auf dem hinzuzufügenden Computer ausführen", + "remote.pairingCommandPosix": "Linux- / macOS-Terminal", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Befehl kopieren", + "remote.copied": "Kopiert", + "remote.devices": "Computer", + "remote.noDevices": "Noch keine Computer gekoppelt.", + "remote.online": "Online", + "remote.offline": "Offline", + "remote.revoke": "Computer widerrufen", + "remote.revokeConfirm": "{name} widerrufen? Aktive Sitzungen auf diesem Computer werden beendet.", + "remote.newSession": "Neue Remote-Sitzung", + "remote.device": "Computer", + "remote.folder": "Arbeitsordner", + "remote.runtime": "Coding-Agent", + "remote.access": "Workspace-Zugriff", + "remote.access.readOnly": "Nur lesen", + "remote.access.workspace": "Dateien bearbeiten und Befehle ausführen", + "remote.access.workspaceFilesOnly": "Nur Dateien bearbeiten", + "remote.unavailable": "Nicht verfügbar", + "remote.capability.full": "Dateien + isolierte Befehle", + "remote.capability.files": "Nur Dateiwerkzeuge", + "remote.runsOnHub": "Modell und Anmeldung bleiben auf diesem Hub", + "remote.runsReadOnly": "Dateien können auf diesem Computer nur gelesen werden", + "remote.runsFilesCommands": "Dateien, Builds und Befehle laufen hier", + "remote.runsFilesOnly": "Dateiwerkzeuge laufen hier; Befehls-Sandbox nicht verfügbar", + "remote.execUnavailable": "Dieser Computer kann Dateien bearbeiten, aber Builds und Terminalbefehle sind ohne unterstützte Betriebssystem-Sandbox deaktiviert.", + "remote.notResumable": "Diese Sitzung wurde beendet, bevor der Coding-Agent einen dauerhaften Verlauf erstellt hat. Starten Sie eine neue Remote-Sitzung.", + "remote.startSession": "Remote-Sitzung starten", + "remote.sessionStarted": "Remote-Sitzung ist bereit.", + "remote.sessions": "Sitzungen", + "remote.noSessions": "Wähle einen Online-Computer, Ordner und Coding-Agenten.", + "remote.events": "Aktivität der Remote-Sitzung", + "remote.noEvents": "Noch keine Aktivität.", + "remote.prompt": "Nachricht", + "remote.promptPlaceholder": "Bitte den Hub-Agenten, im ausgewählten Remote-Ordner zu arbeiten…", + "remote.send": "Senden", + "remote.stop": "Sitzung stoppen", + "remote.requestFailed": "Remote-Workspace-Anfrage fehlgeschlagen.", + "remote.status.starting": "Startet", + "remote.status.ready": "Bereit", + "remote.status.running": "Läuft", + "remote.status.waiting": "Executor offline", + "remote.status.failed": "Fehlgeschlagen", + "remote.status.stopped": "Gestoppt", + "remote.event.status": "Status", + "remote.event.tool": "Remote-Werkzeug", + "remote.event.error": "Fehler", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index dfa9ad90e9..558e3cd7b2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2739,6 +2739,66 @@ export const en = { "models.pickerOrder.saveDraft": "Save draft", "models.pickerOrder.reloadDraft": "Reload and discard draft", "models.pickerOrder.catalogRequired": "Model identities are missing or ambiguous. Reload the Models page to refresh its catalog before editing Custom.", + "nav.remote": "Remote Workspace", + "remote.title": "Remote Workspace", + "remote.subtitle": "Run Codex, Claude Code, or Pi from this Hub while files, commands, tests, and builds stay on the computer you select.", + "remote.loading": "Loading Remote Workspace…", + "remote.loadFailed": "Could not load Remote Workspace.", + "remote.hubRequired": "Use Hub mode and start the Hub with OCX_REMOTE_WORKSPACE_ENABLED=1 to enable Remote Workspace.", + "remote.refresh": "Refresh", + "remote.addComputer": "Add a computer", + "remote.addComputerHint": "Approve one or more folders locally, then keep the OCX-only executor connected to this Hub.", + "remote.createPairing": "Create pairing code", + "remote.pairingCode": "One-time pairing code", + "remote.pairingExpires": "Expires at {time}", + "remote.pairingCommand": "Run on the computer you are adding", + "remote.pairingCommandPosix": "Linux / macOS terminal", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Copy command", + "remote.copied": "Copied", + "remote.devices": "Computers", + "remote.noDevices": "No computers are paired yet.", + "remote.online": "Online", + "remote.offline": "Offline", + "remote.revoke": "Revoke computer", + "remote.revokeConfirm": "Revoke {name}? Active sessions on this computer will stop.", + "remote.newSession": "New remote session", + "remote.device": "Computer", + "remote.folder": "Workspace folder", + "remote.runtime": "Coding agent", + "remote.access": "Workspace access", + "remote.access.readOnly": "Read only", + "remote.access.workspace": "Edit files and run commands", + "remote.access.workspaceFilesOnly": "Edit files only", + "remote.unavailable": "Unavailable", + "remote.capability.full": "Files + sandboxed commands", + "remote.capability.files": "File tools only", + "remote.runsOnHub": "Model and login stay on this Hub", + "remote.runsReadOnly": "Files can only be read on this computer", + "remote.runsFilesCommands": "Files, builds, and commands run here", + "remote.runsFilesOnly": "File tools run here; command sandbox unavailable", + "remote.execUnavailable": "This computer can edit files, but builds and terminal commands are disabled because a supported OS sandbox is not available.", + "remote.notResumable": "This session stopped before the coding agent created durable history. Start a new remote session.", + "remote.startSession": "Start remote session", + "remote.sessionStarted": "Remote session is ready.", + "remote.sessions": "Sessions", + "remote.noSessions": "Choose an online computer, folder, and coding agent to start.", + "remote.events": "Remote session activity", + "remote.noEvents": "No activity yet.", + "remote.prompt": "Message", + "remote.promptPlaceholder": "Ask the Hub agent to work inside the selected remote folder…", + "remote.send": "Send", + "remote.stop": "Stop session", + "remote.requestFailed": "Remote Workspace request failed.", + "remote.status.starting": "Starting", + "remote.status.ready": "Ready", + "remote.status.running": "Running", + "remote.status.waiting": "Executor offline", + "remote.status.failed": "Failed", + "remote.status.stopped": "Stopped", + "remote.event.status": "Status", + "remote.event.tool": "Remote tool", + "remote.event.error": "Error", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2cf33a7a96..849dfb4e50 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2693,4 +2693,64 @@ export const fr: Record = { "models.pickerOrder.saveDraft": "Enregistrer le brouillon", "models.pickerOrder.reloadDraft": "Recharger et supprimer le brouillon", "models.pickerOrder.catalogRequired": "Les identités des modèles sont manquantes ou ambiguës. Rechargez la page Modèles pour actualiser le catalogue avant de personnaliser l’ordre.", + "nav.remote": "Espace distant", + "remote.title": "Espace de travail distant", + "remote.subtitle": "Codex, Claude Code ou Pi s'exécutent sur ce Hub tandis que fichiers, commandes, tests et builds restent sur l'ordinateur choisi.", + "remote.loading": "Chargement de l'espace distant…", + "remote.loadFailed": "Impossible de charger l'espace distant.", + "remote.hubRequired": "Démarrez le Hub en mode Hub avec OCX_REMOTE_WORKSPACE_ENABLED=1 pour activer Remote Workspace.", + "remote.refresh": "Actualiser", + "remote.addComputer": "Ajouter un ordinateur", + "remote.addComputerHint": "Autorisez localement un ou plusieurs dossiers, puis gardez l'exécuteur OCX connecté à ce Hub.", + "remote.createPairing": "Créer un code d'association", + "remote.pairingCode": "Code d'association à usage unique", + "remote.pairingExpires": "Expire à {time}", + "remote.pairingCommand": "À exécuter sur l'ordinateur à ajouter", + "remote.pairingCommandPosix": "Terminal Linux / macOS", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Copier la commande", + "remote.copied": "Copié", + "remote.devices": "Ordinateurs", + "remote.noDevices": "Aucun ordinateur associé.", + "remote.online": "En ligne", + "remote.offline": "Hors ligne", + "remote.revoke": "Révoquer l'ordinateur", + "remote.revokeConfirm": "Révoquer {name} ? Ses sessions actives seront arrêtées.", + "remote.newSession": "Nouvelle session distante", + "remote.device": "Ordinateur", + "remote.folder": "Dossier de travail", + "remote.runtime": "Agent de code", + "remote.access": "Accès à l’espace de travail", + "remote.access.readOnly": "Lecture seule", + "remote.access.workspace": "Modifier les fichiers et exécuter des commandes", + "remote.access.workspaceFilesOnly": "Modifier uniquement les fichiers", + "remote.unavailable": "Indisponible", + "remote.capability.full": "Fichiers + commandes isolées", + "remote.capability.files": "Outils de fichiers uniquement", + "remote.runsOnHub": "Le modèle et la connexion restent sur ce Hub", + "remote.runsReadOnly": "Les fichiers de cet ordinateur sont accessibles en lecture seule", + "remote.runsFilesCommands": "Les fichiers, builds et commandes s’exécutent ici", + "remote.runsFilesOnly": "Les outils de fichiers s’exécutent ici ; bac à sable indisponible", + "remote.execUnavailable": "Cet ordinateur peut modifier les fichiers, mais les builds et commandes de terminal sont désactivés faute de bac à sable système pris en charge.", + "remote.notResumable": "Cette session s’est arrêtée avant que l’agent de code ne crée un historique durable. Démarrez une nouvelle session distante.", + "remote.startSession": "Démarrer la session distante", + "remote.sessionStarted": "La session distante est prête.", + "remote.sessions": "Sessions", + "remote.noSessions": "Choisissez un ordinateur en ligne, un dossier et un agent de code.", + "remote.events": "Activité de la session distante", + "remote.noEvents": "Aucune activité pour le moment.", + "remote.prompt": "Message", + "remote.promptPlaceholder": "Demandez à l'agent du Hub de travailler dans le dossier distant choisi…", + "remote.send": "Envoyer", + "remote.stop": "Arrêter la session", + "remote.requestFailed": "La requête d'espace distant a échoué.", + "remote.status.starting": "Démarrage", + "remote.status.ready": "Prêt", + "remote.status.running": "En cours", + "remote.status.waiting": "Exécuteur hors ligne", + "remote.status.failed": "Échec", + "remote.status.stopped": "Arrêté", + "remote.event.status": "État", + "remote.event.tool": "Outil distant", + "remote.event.error": "Erreur", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0f51bbb2d9..6d3c3a4666 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2726,4 +2726,64 @@ export const ja: Record = { "models.pickerOrder.saveDraft": "下書きを保存", "models.pickerOrder.reloadDraft": "下書きを破棄して再読み込み", "models.pickerOrder.catalogRequired": "モデルの識別情報が不足しているか曖昧です。モデルページを再読み込みしてカタログを更新してからカスタム順序を編集してください。", + "nav.remote": "リモートワークスペース", + "remote.title": "リモートワークスペース", + "remote.subtitle": "Codex、Claude Code、Pi はこの Hub で実行し、ファイル、コマンド、テスト、ビルドは選択したコンピューターで処理します。", + "remote.loading": "リモートワークスペースを読み込み中…", + "remote.loadFailed": "リモートワークスペースを読み込めませんでした。", + "remote.hubRequired": "Hub モードで OCX_REMOTE_WORKSPACE_ENABLED=1 を設定して Hub を起動すると、Remote Workspace を有効にできます。", + "remote.refresh": "更新", + "remote.addComputer": "コンピューターを追加", + "remote.addComputerHint": "ローカルでフォルダーを承認し、OCX 専用エグゼキューターをこの Hub に接続したままにします。", + "remote.createPairing": "ペアリングコードを作成", + "remote.pairingCode": "ワンタイムペアリングコード", + "remote.pairingExpires": "{time} に期限切れ", + "remote.pairingCommand": "追加するコンピューターで実行", + "remote.pairingCommandPosix": "Linux / macOS ターミナル", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "コマンドをコピー", + "remote.copied": "コピー済み", + "remote.devices": "コンピューター", + "remote.noDevices": "ペアリング済みのコンピューターはありません。", + "remote.online": "オンライン", + "remote.offline": "オフライン", + "remote.revoke": "コンピューターを解除", + "remote.revokeConfirm": "{name} を解除しますか?このコンピューターの実行中セッションは停止します。", + "remote.newSession": "新しいリモートセッション", + "remote.device": "コンピューター", + "remote.folder": "ワークスペースフォルダー", + "remote.runtime": "コーディングエージェント", + "remote.access": "ワークスペース権限", + "remote.access.readOnly": "読み取り専用", + "remote.access.workspace": "ファイル編集とコマンド実行", + "remote.access.workspaceFilesOnly": "ファイル編集のみ", + "remote.unavailable": "利用不可", + "remote.capability.full": "ファイル + 分離されたコマンド", + "remote.capability.files": "ファイルツールのみ", + "remote.runsOnHub": "モデルとログインはこの Hub に保持", + "remote.runsReadOnly": "このコンピューターのファイルは読み取りのみ", + "remote.runsFilesCommands": "ファイル、ビルド、コマンドはここで実行", + "remote.runsFilesOnly": "ファイルツールのみここで実行、コマンド分離は未対応", + "remote.execUnavailable": "このコンピューターではファイル編集はできますが、対応する OS サンドボックスがないためビルドとターミナルコマンドは無効です。", + "remote.notResumable": "コーディングエージェントが永続的な履歴を作成する前にセッションが停止しました。新しいリモートセッションを開始してください。", + "remote.startSession": "リモートセッションを開始", + "remote.sessionStarted": "リモートセッションの準備ができました。", + "remote.sessions": "セッション", + "remote.noSessions": "オンラインのコンピューター、フォルダー、エージェントを選択してください。", + "remote.events": "リモートセッションのアクティビティ", + "remote.noEvents": "まだアクティビティはありません。", + "remote.prompt": "メッセージ", + "remote.promptPlaceholder": "選択したリモートフォルダーでの作業を Hub エージェントに依頼…", + "remote.send": "送信", + "remote.stop": "セッションを停止", + "remote.requestFailed": "リモートワークスペースの要求に失敗しました。", + "remote.status.starting": "開始中", + "remote.status.ready": "準備完了", + "remote.status.running": "実行中", + "remote.status.waiting": "エグゼキューターがオフライン", + "remote.status.failed": "失敗", + "remote.status.stopped": "停止済み", + "remote.event.status": "状態", + "remote.event.tool": "リモートツール", + "remote.event.error": "エラー", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1db3ad6a32..347321bdc0 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2727,4 +2727,64 @@ export const ko: Record = { "models.pickerOrder.saveDraft": "초안 저장", "models.pickerOrder.reloadDraft": "초안 버리고 다시 불러오기", "models.pickerOrder.catalogRequired": "모델 식별 정보가 없거나 모호합니다. 모델 페이지를 새로고침해 목록을 갱신한 뒤 사용자 지정 순서를 편집하세요.", + "nav.remote": "원격 워크스페이스", + "remote.title": "원격 워크스페이스", + "remote.subtitle": "Codex, Claude Code, Pi는 이 Hub에서 실행하고 파일·명령·테스트·빌드는 선택한 컴퓨터에서 처리합니다.", + "remote.loading": "원격 워크스페이스 불러오는 중…", + "remote.loadFailed": "원격 워크스페이스를 불러오지 못했습니다.", + "remote.hubRequired": "허브 모드에서 OCX_REMOTE_WORKSPACE_ENABLED=1로 허브를 시작하면 원격 작업 공간을 사용할 수 있습니다.", + "remote.refresh": "새로고침", + "remote.addComputer": "컴퓨터 추가", + "remote.addComputerHint": "추가할 컴퓨터에서 폴더를 승인하고 OCX 전용 실행기를 이 Hub에 계속 연결하세요.", + "remote.createPairing": "페어링 코드 만들기", + "remote.pairingCode": "일회용 페어링 코드", + "remote.pairingExpires": "{time}에 만료", + "remote.pairingCommand": "추가할 컴퓨터에서 실행", + "remote.pairingCommandPosix": "Linux / macOS 터미널", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "명령어 복사", + "remote.copied": "복사됨", + "remote.devices": "컴퓨터", + "remote.noDevices": "아직 페어링된 컴퓨터가 없습니다.", + "remote.online": "온라인", + "remote.offline": "오프라인", + "remote.revoke": "컴퓨터 연결 해제", + "remote.revokeConfirm": "{name} 연결을 해제할까요? 이 컴퓨터의 활성 세션이 중지됩니다.", + "remote.newSession": "새 원격 세션", + "remote.device": "컴퓨터", + "remote.folder": "워크스페이스 폴더", + "remote.runtime": "코딩 에이전트", + "remote.access": "워크스페이스 권한", + "remote.access.readOnly": "읽기 전용", + "remote.access.workspace": "파일 편집 및 명령 실행", + "remote.access.workspaceFilesOnly": "파일 편집만", + "remote.unavailable": "사용 불가", + "remote.capability.full": "파일 + 격리된 명령 실행", + "remote.capability.files": "파일 도구만 지원", + "remote.runsOnHub": "모델과 로그인은 이 Hub에서 유지", + "remote.runsReadOnly": "이 컴퓨터의 파일은 읽기만 가능", + "remote.runsFilesCommands": "파일, 빌드, 명령은 이 컴퓨터에서 실행", + "remote.runsFilesOnly": "파일 도구만 이 컴퓨터에서 실행, 명령 격리 미지원", + "remote.execUnavailable": "이 컴퓨터의 파일은 편집할 수 있지만, 지원되는 OS 격리 기능이 없어 빌드와 터미널 명령은 비활성화됩니다.", + "remote.notResumable": "코딩 에이전트가 세션 기록을 만들기 전에 중단되었습니다. 새 원격 세션을 시작하세요.", + "remote.startSession": "원격 세션 시작", + "remote.sessionStarted": "원격 세션이 준비되었습니다.", + "remote.sessions": "세션", + "remote.noSessions": "온라인 컴퓨터, 폴더, 코딩 에이전트를 선택해 시작하세요.", + "remote.events": "원격 세션 활동", + "remote.noEvents": "아직 활동이 없습니다.", + "remote.prompt": "메시지", + "remote.promptPlaceholder": "Hub 에이전트에게 선택한 원격 폴더에서 작업을 요청하세요…", + "remote.send": "보내기", + "remote.stop": "세션 중지", + "remote.requestFailed": "원격 워크스페이스 요청에 실패했습니다.", + "remote.status.starting": "시작 중", + "remote.status.ready": "준비됨", + "remote.status.running": "실행 중", + "remote.status.waiting": "실행기 오프라인", + "remote.status.failed": "실패", + "remote.status.stopped": "중지됨", + "remote.event.status": "상태", + "remote.event.tool": "원격 도구", + "remote.event.error": "오류", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index fc6f61c152..a6e7f5cedd 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2728,4 +2728,64 @@ export const ru: Record = { "models.pickerOrder.saveDraft": "Сохранить черновик", "models.pickerOrder.reloadDraft": "Перезагрузить и сбросить черновик", "models.pickerOrder.catalogRequired": "Идентификаторы моделей отсутствуют или неоднозначны. Перезагрузите страницу моделей, чтобы обновить каталог перед редактированием порядка.", + "nav.remote": "Удалённое рабочее пространство", + "remote.title": "Удалённое рабочее пространство", + "remote.subtitle": "Codex, Claude Code или Pi работают на этом Hub, а файлы, команды, тесты и сборки остаются на выбранном компьютере.", + "remote.loading": "Загрузка удалённого рабочего пространства…", + "remote.loadFailed": "Не удалось загрузить удалённое рабочее пространство.", + "remote.hubRequired": "Для включения Remote Workspace запустите Hub в режиме Hub с OCX_REMOTE_WORKSPACE_ENABLED=1.", + "remote.refresh": "Обновить", + "remote.addComputer": "Добавить компьютер", + "remote.addComputerHint": "Разрешите локальные папки и держите исполнитель только с OCX подключённым к этому Hub.", + "remote.createPairing": "Создать код сопряжения", + "remote.pairingCode": "Одноразовый код сопряжения", + "remote.pairingExpires": "Истекает в {time}", + "remote.pairingCommand": "Запустите на добавляемом компьютере", + "remote.pairingCommandPosix": "Терминал Linux / macOS", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Копировать команду", + "remote.copied": "Скопировано", + "remote.devices": "Компьютеры", + "remote.noDevices": "Сопряжённых компьютеров пока нет.", + "remote.online": "В сети", + "remote.offline": "Не в сети", + "remote.revoke": "Отозвать компьютер", + "remote.revokeConfirm": "Отозвать {name}? Активные сеансы на этом компьютере будут остановлены.", + "remote.newSession": "Новый удалённый сеанс", + "remote.device": "Компьютер", + "remote.folder": "Папка рабочего пространства", + "remote.runtime": "Агент программирования", + "remote.access": "Доступ к рабочей области", + "remote.access.readOnly": "Только чтение", + "remote.access.workspace": "Изменять файлы и выполнять команды", + "remote.access.workspaceFilesOnly": "Только изменять файлы", + "remote.unavailable": "Недоступно", + "remote.capability.full": "Файлы + изолированные команды", + "remote.capability.files": "Только файловые инструменты", + "remote.runsOnHub": "Модель и вход остаются на этом Hub", + "remote.runsReadOnly": "Файлы на этом компьютере доступны только для чтения", + "remote.runsFilesCommands": "Файлы, сборки и команды выполняются здесь", + "remote.runsFilesOnly": "Здесь работают только файловые инструменты; песочница команд недоступна", + "remote.execUnavailable": "На этом компьютере можно редактировать файлы, но сборки и команды терминала отключены без поддерживаемой системной песочницы.", + "remote.notResumable": "Сеанс остановился до создания постоянной истории агентом. Запустите новый удалённый сеанс.", + "remote.startSession": "Запустить удалённый сеанс", + "remote.sessionStarted": "Удалённый сеанс готов.", + "remote.sessions": "Сеансы", + "remote.noSessions": "Выберите компьютер в сети, папку и агента программирования.", + "remote.events": "Активность удалённого сеанса", + "remote.noEvents": "Активности пока нет.", + "remote.prompt": "Сообщение", + "remote.promptPlaceholder": "Попросите агент Hub работать в выбранной удалённой папке…", + "remote.send": "Отправить", + "remote.stop": "Остановить сеанс", + "remote.requestFailed": "Запрос удалённого рабочего пространства завершился ошибкой.", + "remote.status.starting": "Запуск", + "remote.status.ready": "Готово", + "remote.status.running": "Выполняется", + "remote.status.waiting": "Исполнитель не в сети", + "remote.status.failed": "Ошибка", + "remote.status.stopped": "Остановлено", + "remote.event.status": "Состояние", + "remote.event.tool": "Удалённый инструмент", + "remote.event.error": "Ошибка", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ee6ae93adf..1aaeee8027 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2728,4 +2728,64 @@ export const tr: Record = { "models.pickerOrder.saveDraft": "Taslağı kaydet", "models.pickerOrder.reloadDraft": "Yeniden yükle ve taslağı sil", "models.pickerOrder.catalogRequired": "Model kimlikleri eksik veya belirsiz. Özel sırayı düzenlemeden önce kataloğu yenilemek için Modeller sayfasını yeniden yükleyin.", + "nav.remote": "Uzak Çalışma Alanı", + "remote.title": "Uzak Çalışma Alanı", + "remote.subtitle": "Codex, Claude Code veya Pi bu Hub üzerinde çalışır; dosyalar, komutlar, testler ve derlemeler seçtiğiniz bilgisayarda kalır.", + "remote.loading": "Uzak çalışma alanı yükleniyor…", + "remote.loadFailed": "Uzak çalışma alanı yüklenemedi.", + "remote.hubRequired": "Remote Workspace özelliğini açmak için Hub modunda OCX_REMOTE_WORKSPACE_ENABLED=1 ile Hub başlatın.", + "remote.refresh": "Yenile", + "remote.addComputer": "Bilgisayar ekle", + "remote.addComputerHint": "Klasörleri yerel olarak onaylayın ve yalnızca OCX kurulu yürütücüyü bu Hub'a bağlı tutun.", + "remote.createPairing": "Eşleştirme kodu oluştur", + "remote.pairingCode": "Tek kullanımlık eşleştirme kodu", + "remote.pairingExpires": "{time} saatinde sona erer", + "remote.pairingCommand": "Eklenecek bilgisayarda çalıştırın", + "remote.pairingCommandPosix": "Linux / macOS terminali", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "Komutu kopyala", + "remote.copied": "Kopyalandı", + "remote.devices": "Bilgisayarlar", + "remote.noDevices": "Henüz eşleştirilmiş bilgisayar yok.", + "remote.online": "Çevrimiçi", + "remote.offline": "Çevrimdışı", + "remote.revoke": "Bilgisayarı iptal et", + "remote.revokeConfirm": "{name} iptal edilsin mi? Bu bilgisayardaki etkin oturumlar durur.", + "remote.newSession": "Yeni uzak oturum", + "remote.device": "Bilgisayar", + "remote.folder": "Çalışma alanı klasörü", + "remote.runtime": "Kodlama aracısı", + "remote.access": "Çalışma alanı erişimi", + "remote.access.readOnly": "Salt okunur", + "remote.access.workspace": "Dosyaları düzenle ve komut çalıştır", + "remote.access.workspaceFilesOnly": "Yalnızca dosyaları düzenle", + "remote.unavailable": "Kullanılamıyor", + "remote.capability.full": "Dosyalar + yalıtılmış komutlar", + "remote.capability.files": "Yalnızca dosya araçları", + "remote.runsOnHub": "Model ve oturum bu Hub üzerinde kalır", + "remote.runsReadOnly": "Bu bilgisayardaki dosyalar yalnızca okunabilir", + "remote.runsFilesCommands": "Dosyalar, derlemeler ve komutlar burada çalışır", + "remote.runsFilesOnly": "Burada yalnızca dosya araçları çalışır; komut yalıtımı yok", + "remote.execUnavailable": "Bu bilgisayar dosyaları düzenleyebilir; ancak desteklenen bir işletim sistemi yalıtımı olmadığı için derlemeler ve terminal komutları devre dışıdır.", + "remote.notResumable": "Kodlama aracısı kalıcı geçmiş oluşturmadan önce oturum durdu. Yeni bir uzak oturum başlatın.", + "remote.startSession": "Uzak oturumu başlat", + "remote.sessionStarted": "Uzak oturum hazır.", + "remote.sessions": "Oturumlar", + "remote.noSessions": "Çevrimiçi bir bilgisayar, klasör ve kodlama aracısı seçin.", + "remote.events": "Uzak oturum etkinliği", + "remote.noEvents": "Henüz etkinlik yok.", + "remote.prompt": "Mesaj", + "remote.promptPlaceholder": "Hub aracısından seçili uzak klasörde çalışmasını isteyin…", + "remote.send": "Gönder", + "remote.stop": "Oturumu durdur", + "remote.requestFailed": "Uzak çalışma alanı isteği başarısız oldu.", + "remote.status.starting": "Başlatılıyor", + "remote.status.ready": "Hazır", + "remote.status.running": "Çalışıyor", + "remote.status.waiting": "Yürütücü çevrimdışı", + "remote.status.failed": "Başarısız", + "remote.status.stopped": "Durduruldu", + "remote.event.status": "Durum", + "remote.event.tool": "Uzak araç", + "remote.event.error": "Hata", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 8f38f5c0f4..985beab4aa 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2691,4 +2691,64 @@ export const zhTW: Record = { "models.pickerOrder.saveDraft": "儲存草稿", "models.pickerOrder.reloadDraft": "捨棄草稿並重新載入", "models.pickerOrder.catalogRequired": "模型識別資訊缺失或不明確。請重新載入模型頁面以更新目錄,再編輯自訂順序。", + "nav.remote": "遠端工作區", + "remote.title": "遠端工作區", + "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 執行,檔案、命令、測試與建置則留在所選電腦上處理。", + "remote.loading": "正在載入遠端工作區…", + "remote.loadFailed": "無法載入遠端工作區。", + "remote.hubRequired": "請在 Hub 模式下使用 OCX_REMOTE_WORKSPACE_ENABLED=1 啟動 Hub,以啟用遠端工作區。", + "remote.refresh": "重新整理", + "remote.addComputer": "新增電腦", + "remote.addComputerHint": "在本機核准一個或多個資料夾,並讓僅安裝 OCX 的執行端持續連線此 Hub。", + "remote.createPairing": "建立配對碼", + "remote.pairingCode": "一次性配對碼", + "remote.pairingExpires": "{time} 到期", + "remote.pairingCommand": "在要新增的電腦上執行", + "remote.pairingCommandPosix": "Linux / macOS 終端機", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "複製命令", + "remote.copied": "已複製", + "remote.devices": "電腦", + "remote.noDevices": "尚未配對電腦。", + "remote.online": "上線", + "remote.offline": "離線", + "remote.revoke": "撤銷電腦", + "remote.revokeConfirm": "撤銷 {name}?此電腦上的作用中工作階段將停止。", + "remote.newSession": "新增遠端工作階段", + "remote.device": "電腦", + "remote.folder": "工作區資料夾", + "remote.runtime": "程式設計代理", + "remote.access": "工作區權限", + "remote.access.readOnly": "唯讀", + "remote.access.workspace": "編輯檔案並執行命令", + "remote.access.workspaceFilesOnly": "僅編輯檔案", + "remote.unavailable": "無法使用", + "remote.capability.full": "檔案 + 沙箱命令", + "remote.capability.files": "僅檔案工具", + "remote.runsOnHub": "模型與登入保留在此 Hub", + "remote.runsReadOnly": "此電腦上的檔案僅可讀取", + "remote.runsFilesCommands": "檔案、建置與命令在此電腦執行", + "remote.runsFilesOnly": "僅檔案工具在此執行;命令沙箱無法使用", + "remote.execUnavailable": "此電腦可以編輯檔案,但因沒有支援的作業系統沙箱,建置與終端命令已停用。", + "remote.notResumable": "程式設計代理尚未建立持久歷史記錄時工作階段就已停止。請啟動新的遠端工作階段。", + "remote.startSession": "啟動遠端工作階段", + "remote.sessionStarted": "遠端工作階段已就緒。", + "remote.sessions": "工作階段", + "remote.noSessions": "請選擇上線電腦、資料夾與程式設計代理。", + "remote.events": "遠端工作階段活動", + "remote.noEvents": "尚無活動。", + "remote.prompt": "訊息", + "remote.promptPlaceholder": "請 Hub 代理在所選遠端資料夾中工作…", + "remote.send": "傳送", + "remote.stop": "停止工作階段", + "remote.requestFailed": "遠端工作區要求失敗。", + "remote.status.starting": "正在啟動", + "remote.status.ready": "就緒", + "remote.status.running": "執行中", + "remote.status.waiting": "執行端離線", + "remote.status.failed": "失敗", + "remote.status.stopped": "已停止", + "remote.event.status": "狀態", + "remote.event.tool": "遠端工具", + "remote.event.error": "錯誤", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 1cfd82623c..fc198fbb20 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2726,4 +2726,64 @@ export const zh: Record = { "models.pickerOrder.saveDraft": "保存草稿", "models.pickerOrder.reloadDraft": "丢弃草稿并重新加载", "models.pickerOrder.catalogRequired": "模型标识信息缺失或不明确。请重新加载模型页面以刷新目录,再编辑自定义顺序。", + "nav.remote": "远程工作区", + "remote.title": "远程工作区", + "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 上运行,文件、命令、测试和构建则留在所选电脑上执行。", + "remote.loading": "正在加载远程工作区…", + "remote.loadFailed": "无法加载远程工作区。", + "remote.hubRequired": "请在 Hub 模式下使用 OCX_REMOTE_WORKSPACE_ENABLED=1 启动 Hub,以启用远程工作区。", + "remote.refresh": "刷新", + "remote.addComputer": "添加电脑", + "remote.addComputerHint": "在本机批准一个或多个文件夹,并让仅安装 OCX 的执行端持续连接此 Hub。", + "remote.createPairing": "创建配对码", + "remote.pairingCode": "一次性配对码", + "remote.pairingExpires": "{time} 过期", + "remote.pairingCommand": "在要添加的电脑上运行", + "remote.pairingCommandPosix": "Linux / macOS 终端", + "remote.pairingCommandWindows": "Windows PowerShell", + "remote.copyCommand": "复制命令", + "remote.copied": "已复制", + "remote.devices": "电脑", + "remote.noDevices": "尚未配对电脑。", + "remote.online": "在线", + "remote.offline": "离线", + "remote.revoke": "撤销电脑", + "remote.revokeConfirm": "撤销 {name}?该电脑上的活动会话将停止。", + "remote.newSession": "新建远程会话", + "remote.device": "电脑", + "remote.folder": "工作区文件夹", + "remote.runtime": "编程代理", + "remote.access": "工作区权限", + "remote.access.readOnly": "只读", + "remote.access.workspace": "编辑文件并运行命令", + "remote.access.workspaceFilesOnly": "仅编辑文件", + "remote.unavailable": "不可用", + "remote.capability.full": "文件 + 沙箱命令", + "remote.capability.files": "仅文件工具", + "remote.runsOnHub": "模型和登录保留在此 Hub", + "remote.runsReadOnly": "此电脑上的文件仅可读取", + "remote.runsFilesCommands": "文件、构建和命令在此电脑运行", + "remote.runsFilesOnly": "仅文件工具在此运行;命令沙箱不可用", + "remote.execUnavailable": "此电脑可以编辑文件,但由于没有受支持的操作系统沙箱,构建和终端命令已禁用。", + "remote.notResumable": "编码代理尚未创建持久历史记录时会话就已停止。请启动新的远程会话。", + "remote.startSession": "启动远程会话", + "remote.sessionStarted": "远程会话已就绪。", + "remote.sessions": "会话", + "remote.noSessions": "请选择在线电脑、文件夹和编程代理。", + "remote.events": "远程会话活动", + "remote.noEvents": "暂无活动。", + "remote.prompt": "消息", + "remote.promptPlaceholder": "让 Hub 代理在所选远程文件夹中工作…", + "remote.send": "发送", + "remote.stop": "停止会话", + "remote.requestFailed": "远程工作区请求失败。", + "remote.status.starting": "正在启动", + "remote.status.ready": "就绪", + "remote.status.running": "运行中", + "remote.status.waiting": "执行端离线", + "remote.status.failed": "失败", + "remote.status.stopped": "已停止", + "remote.event.status": "状态", + "remote.event.tool": "远程工具", + "remote.event.error": "错误", }; diff --git a/gui/src/pages/RemoteWorkspace.tsx b/gui/src/pages/RemoteWorkspace.tsx new file mode 100644 index 0000000000..9295e39da2 --- /dev/null +++ b/gui/src/pages/RemoteWorkspace.tsx @@ -0,0 +1,381 @@ +import { useMemo, useRef, useState } from "react"; +import { useKeyedClientResource } from "../client-resource"; +import { readJsonOrThrow } from "../fetch-json"; +import { IconLink, IconMonitor, IconPlus, IconRefresh, IconTerminal, IconTrash } from "../icons"; +import { type TKey, useT } from "../i18n/shared"; +import { Notice, Select } from "../ui"; +import { remoteWorkspacePairingCommands } from "../remote-workspace-command"; + +type RuntimeProfile = "codex" | "claude" | "pi"; +type RemoteCapability = "workspace.read" | "workspace.write" | "workspace.exec"; +type RemoteAccessMode = "read-only" | "workspace"; +type SessionStatus = "starting" | "ready" | "running" | "waiting_for_executor" | "failed" | "stopped"; + +interface RemoteRoot { id: string; label: string } +interface RemoteDevice { + id: string; + name: string; + platform: string; + capabilities: RemoteCapability[]; + roots: RemoteRoot[]; + online: boolean; + createdAt: string; + lastSeenAt: string | null; +} +interface RuntimeAvailability { available: boolean; version?: string; reason?: string } +interface SessionEvent { sequence: number; at: string; type: "status" | "assistant" | "tool" | "error"; text: string } +interface RemoteSession { + id: string; + profile: RuntimeProfile; + accessMode: RemoteAccessMode; + deviceId: string; + deviceName: string; + rootId: string; + rootLabel: string; + capabilities: RemoteCapability[]; + tools: string[]; + threadId: string | null; + resumable: boolean; + status: SessionStatus; + createdAt: string; + updatedAt: string; + events: SessionEvent[]; +} +interface RemoteWorkspaceState { + available: boolean; + reason?: string; + devices: RemoteDevice[]; + runtimes: Record; + sessions: RemoteSession[]; +} +interface PairingGrant { code: string; expiresAt: string } + +const PROFILES: RuntimeProfile[] = ["codex", "claude", "pi"]; +const PROFILE_LABEL: Record = { codex: "Codex", claude: "Claude Code", pi: "Pi" }; +const STATUS_TKEY: Record = { + starting: "remote.status.starting", + ready: "remote.status.ready", + running: "remote.status.running", + waiting_for_executor: "remote.status.waiting", + failed: "remote.status.failed", + stopped: "remote.status.stopped", +}; +const EVENT_TKEY: Record, TKey> = { + status: "remote.event.status", + tool: "remote.event.tool", + error: "remote.event.error", +}; + +function isRuntimeProfile(value: string): value is RuntimeProfile { + return value === "codex" || value === "claude" || value === "pi"; +} + +function isRemoteAccessMode(value: string): value is RemoteAccessMode { + return value === "read-only" || value === "workspace"; +} + +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +export default function RemoteWorkspace({ apiBase }: { apiBase: string }) { + const t = useT(); + const resource = useKeyedClientResource( + `remote-workspace:${apiBase}`, + [apiBase], + async signal => { + const response = await fetch(`${apiBase}/api/remote-workspace`, { signal, cache: "no-store" }); + return await readJsonOrThrow(response, t("remote.loadFailed")); + }, + { pollMs: 3_000, deadlineMs: 10_000 }, + ); + const state = resource.data; + const [selectedDeviceId, setSelectedDeviceId] = useState(""); + const [selectedRootId, setSelectedRootId] = useState(""); + const [selectedProfile, setSelectedProfile] = useState("codex"); + const [selectedAccessMode, setSelectedAccessMode] = useState("read-only"); + const [selectedSessionId, setSelectedSessionId] = useState(""); + const [localSession, setLocalSession] = useState(null); + const [pairing, setPairing] = useState(null); + const [prompt, setPrompt] = useState(""); + const [busy, setBusy] = useState<"pair" | "session" | "revoke" | null>(null); + const [promptPending, setPromptPending] = useState(false); + const [stopPending, setStopPending] = useState(false); + const stoppedSessionId = useRef(null); + const [notice, setNotice] = useState<{ tone: "ok" | "err"; text: string } | null>(null); + const [copiedCommand, setCopiedCommand] = useState<"posix" | "powershell" | null>(null); + + const devices = state?.devices ?? []; + const effectiveDevice = devices.find(device => device.id === selectedDeviceId) + ?? devices.find(device => device.online) + ?? devices[0] + ?? null; + const effectiveRoot = effectiveDevice?.roots.find(root => root.id === selectedRootId) + ?? effectiveDevice?.roots[0] + ?? null; + const selectedCanExecute = selectedAccessMode === "workspace" + && (effectiveDevice?.capabilities.includes("workspace.exec") ?? false); + const workspaceAccessLabel = effectiveDevice && !effectiveDevice.capabilities.includes("workspace.exec") + ? t("remote.access.workspaceFilesOnly") + : t("remote.access.workspace"); + const availableProfiles = PROFILES.filter(profile => state?.runtimes?.[profile]?.available); + const effectiveProfile = availableProfiles.includes(selectedProfile) + ? selectedProfile + : availableProfiles[0] ?? selectedProfile; + const remoteSessions = state?.sessions ?? []; + const effectiveSession = remoteSessions.find(session => session.id === selectedSessionId) + ?? (localSession && localSession.id === selectedSessionId ? localSession : null) + ?? [...remoteSessions].reverse().find(session => session.status !== "stopped") + ?? localSession; + + const pairingCommands = useMemo(() => { + if (!pairing) return { posix: "", powershell: "" }; + const hub = typeof window === "undefined" ? "https://hub.example" : window.location.origin; + return remoteWorkspacePairingCommands(pairing.code, hub); + }, [pairing]); + + const mutate = async (path: string, init: RequestInit, fallback: string): Promise => { + const response = await fetch(`${apiBase}${path}`, init); + const body = await readJsonOrThrow(response, fallback); + if (body === undefined) throw new Error(fallback); + return body; + }; + + const createPairing = async () => { + setBusy("pair"); + setNotice(null); + try { + const grant = await mutate("/api/remote-workspace/pairing", { method: "POST" }, t("remote.requestFailed")); + setPairing(grant); + setCopiedCommand(null); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const createSession = async () => { + if (!effectiveDevice || !effectiveRoot) return; + setBusy("session"); + setNotice(null); + try { + const session = await mutate("/api/remote-workspace/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + profile: effectiveProfile, + deviceId: effectiveDevice.id, + rootId: effectiveRoot.id, + accessMode: selectedAccessMode, + }), + }, t("remote.requestFailed")); + setLocalSession(session); + setSelectedSessionId(session.id); + setNotice({ tone: "ok", text: t("remote.sessionStarted") }); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const sendPrompt = async () => { + if (!effectiveSession || !prompt.trim() || promptPending || stopPending || busy !== null) return; + const target = effectiveSession; + const submitted = prompt; + setPrompt(""); + setPromptPending(true); + setNotice(null); + try { + const session = await mutate(`/api/remote-workspace/sessions/${target.id}/prompt`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: submitted }), + }, t("remote.requestFailed")); + if (stoppedSessionId.current !== target.id) setLocalSession(session); + void resource.refresh(); + } catch (error) { + if (stoppedSessionId.current !== target.id) { + setPrompt(submitted); + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } + } finally { setPromptPending(false); } + }; + + const stopSession = async () => { + if (!effectiveSession || stopPending || busy !== null) return; + const target = effectiveSession; + setStopPending(true); + try { + await mutate(`/api/remote-workspace/sessions/${target.id}`, { method: "DELETE" }, t("remote.requestFailed")); + stoppedSessionId.current = target.id; + setLocalSession({ ...target, status: "stopped" }); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setStopPending(false); } + }; + + const revokeDevice = async (device: RemoteDevice) => { + if (!confirm(t("remote.revokeConfirm", { name: device.name }))) return; + setBusy("revoke"); + try { + await mutate(`/api/remote-workspace/devices/${device.id}`, { method: "DELETE" }, t("remote.requestFailed")); + if (selectedDeviceId === device.id) setSelectedDeviceId(""); + void resource.refresh(); + } catch (error) { + setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") }); + } finally { setBusy(null); } + }; + + const copyPairingCommand = async (kind: "posix" | "powershell", command: string) => { + setCopiedCommand(await copyText(command) ? kind : null); + }; + + if (resource.loading && !state) return
{t("remote.loading")}
; + if (resource.error && !state) { + return <>{t("remote.loadFailed")}; + } + if (state?.available === false) return {t("remote.hubRequired")}; + + return ( +
+
+
+

{t("remote.title")}

+

{t("remote.subtitle")}

+
+ +
+ + {notice ? {notice.text} : null} + +
+
+
+
+
+

{t("remote.addComputer")}

{t("remote.addComputerHint")}

+
+ + {pairing ? ( +
+ {t("remote.pairingCode")} +
{pairing.code}
+
{t("remote.pairingExpires", { time: new Date(pairing.expiresAt).toLocaleTimeString() })}
+ {t("remote.pairingCommandPosix")} +
{pairingCommands.posix}
+ + {t("remote.pairingCommandWindows")} +
{pairingCommands.powershell}
+ +
+ ) : null} +
+ +
+

{t("remote.devices")}

{devices.length}
+ {devices.length === 0 ?

{t("remote.noDevices")}

: ( +
+ {devices.map(device => ( +
+ + +
+ ))} +
+ )} +
+
+ +
+
+

{t("remote.newSession")}

+
+ + +
+ {effectiveDevice ? ( +
+ {PROFILE_LABEL[effectiveProfile]}{t("remote.runsOnHub")} + {effectiveDevice.name}{selectedAccessMode === "read-only" ? t("remote.runsReadOnly") : selectedCanExecute ? t("remote.runsFilesCommands") : t("remote.runsFilesOnly")} +
+ ) : null} + {selectedAccessMode === "workspace" && !selectedCanExecute && effectiveDevice ? {t("remote.execUnavailable")} : null} + {!state?.runtimes?.[effectiveProfile]?.available && state?.runtimes?.[effectiveProfile]?.reason + ?

{state.runtimes[effectiveProfile].reason}

+ : null} + +
+ +
+
+

{t("remote.sessions")}

{effectiveSession ? {PROFILE_LABEL[effectiveSession.profile]} · {effectiveSession.deviceName}/{effectiveSession.rootLabel} · {effectiveSession.accessMode === "read-only" ? t("remote.access.readOnly") : t("remote.access.workspace")} : null}
+ {effectiveSession ? {t(STATUS_TKEY[effectiveSession.status])} : null} +
+ {remoteSessions.length > 1 ? ( +