diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index d5616132fd4..2230760db0b 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -404,6 +404,14 @@ URL de base modifiée ressemblant à l'original n'est jamais sondée. Les crédi gratuits restants sont affichés sous forme de fenêtre en USD lorsque Command Code signale également les dépenses de la période. +**Contexte de projet Command Code.** `projectContext: "on"` optionnel sur le fournisseur OAuth `command-code` +uniquement (pas le préréglage à clé API `commandcode`) remplit `memory`, `taste` et `skills` de +`/alpha/generate` depuis le répertoire de travail du proxy. Définissez-le sur `providers.command-code` via +**Providers → Command Code → Edit JSON**, démarrez le proxy depuis le projet Codex de confiance et +redémarrez après enregistrement. Absent ou `"off"` conserve l'enveloppe vide même si `AGENTS.md` ou des +fichiers taste existent. Voir [Adapters](/fr/reference/adapters/#command-code) pour les chemins, plafonds +et le comportement fail-soft. + **Découverte SambaNova Cloud.** Le préréglage lit la liste publique `/v1/models` de SambaNova Cloud depuis l'hôte API fixe, préserve les identifiants natifs du fournisseur et limite la découverte à 128 KiB et 128 lignes brutes. Le catalogue n'étant pas authentifié, le parcours de connexion de la CLI signale que la clé ne diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md index c5b7a50b1ed..839cd5d9a6f 100644 --- a/docs-site/src/content/docs/fr/guides/sidecars.md +++ b/docs-site/src/content/docs/fr/guides/sidecars.md @@ -30,6 +30,12 @@ Lorsque Codex demande un hébergement `web_search` pour un modèle routé sans p (par défaut 3), supprime ensuite l'outil de recherche et force une réponse finale. De vrais outils clients tels que `apply_patch` ou le shell mettent fin au tour afin que ces appels parviennent à Codex. +**Exception Antigravity Gemini 3 :** lorsque le fournisseur routé est Google Antigravity (`google-antigravity`) +et que le modèle filaire est Gemini 3.x, opencodex attache `google_search` CCA (et `url_context` si le tour +contient des URL `http(s)`) sur la **même** requête principale au lieu de la boucle sidecar. Claude-on-CCA, +les backends explicites `openai` / `anthropic` / `xai` / `exa`, et Gemini < 3 avec des outils fonction Codex +restent sur le sidecar. + Chaque itération du modèle routé envoie `stream: true` en amont, mais, par défaut, opencodex met entièrement en mémoire tampon les événements sémantiques avant de décider s'il faut lancer une recherche ou renvoyer la réponse finale. Seuls les en-têtes et le statut du tour final, ainsi que les réponses 429 de la première itération, sont traités immédiatement. Ainsi, diff --git a/docs-site/src/content/docs/fr/reference/adapters.md b/docs-site/src/content/docs/fr/reference/adapters.md index a4e346c4a52..83e24a2e1c0 100644 --- a/docs-site/src/content/docs/fr/reference/adapters.md +++ b/docs-site/src/content/docs/fr/reference/adapters.md @@ -99,7 +99,21 @@ Si Kiro s’arrête sans appeler l’outil d’achèvement, l’adaptateur effec - Rejoue l’état de la conversation au moyen de blobs adressés par leur contenu, remappe les appels d’outils du serveur vers Codex, découvre les modèles Cursor disponibles en direct au moyen de l’appel RPC protobuf `GetUsableModels` et ne relance une opération qu’avant que la requête d’exécution ait été écrite sur le transport. - Expose Cursor Router sous `cursor/auto`, ainsi que les entrées explicites `cursor/auto-cost`, `cursor/auto-balance` et `cursor/auto-intelligence`. Les niveaux explicites sont encodés dans `requested_model.parameters`, tandis que l’ancienne entrée `cursor/auto` conserve la valeur par défaut du compte ou de l’équipe. - Envoie les niveaux ordinaires de `cursor/grok-4.5` avec les identifiants de protocole exacts issus de la découverte en direct de Cursor (`cursor-grok-4.5-low`, `-medium` ou `-high`). `cursor/grok-4.5-fast` reste sélectionnable, mais le modèle canonique `grok-4.5` est envoyé avec des paramètres distincts `effort` et `fast=true`. -- L’exécution locale native de commandes sur le système de fichiers, le shell ou le réseau par Cursor est refusée par défaut. Les intégrations explicites `mcpServers` et `desktopExecutor` disposent d’activations distinctes ; `nativeLocalExec: "on"` active l’exécuteur intégré plus large et contourne la sémantique d’approbation et de bac à sable de Codex. L’ancien réglage `unsafeAllowNativeLocalExec: true` reste équivalent uniquement lorsque `nativeLocalExec` n’est pas défini. +- L’exécution locale native de commandes sur le système de fichiers, le shell ou le réseau par Cursor est refusée par défaut. Les intégrations explicites `mcpServers` et `desktopExecutor` disposent d’activations distinctes ; `nativeLocalExec: "on"` active l’exécuteur intégré plus large et contourne la sémantique d’approbation et de bac à sable de Codex. L’ancien réglage `unsafeAllowNativeLocalExec: true` reste équivalent uniquement lorsque `nativeLocalExec` n’est pas défini. Avec la politique par défaut `off`, Shell/Read/Ls/Grep/Fetch natifs sont mappés vers `shell_command`/`exec_command` Codex lorsque cet outil pont est dans le catalogue ; write/delete restent refusés. + +## `command-code` + +**Cibles :** API agent d’abonnement Command Code **OAuth** (`POST {baseUrl}/alpha/generate`). +**Authentification :** Bearer OAuth via `ocx login command-code`. + +- Distinct du préréglage à clé API `commandcode` (`openai-chat` → `POST {baseUrl}/provider/v1/chat/completions`). La route à clé API ne lit jamais `projectContext` ni ne remplit l’enveloppe generate depuis le disque. +- `projectContext: "on"` optionnel sur `providers.command-code` copie des fichiers bornés depuis `process.cwd()` au moment de la requête vers `memory`, `taste` et `skills`. Absent ou `"off"` envoie `memory: ""`, `taste: null`, `skills: null` même si ces fichiers existent dans le dépôt — opt-in uniquement, jamais de chargement automatique. +- Démarrez le proxy depuis le répertoire de projet Codex de confiance afin que le répertoire de travail corresponde au dépôt que Codex édite. +- **Mémoire :** UTF-8 de `AGENTS.md` à la racine du cwd uniquement (pas `CLAUDE.md`, `CODEX.md` ni chemins du répertoire personnel). Plafond 32 768 octets ; dépassement tronqué avec ``. +- **Taste :** UTF-8 de `.commandcode/taste/taste.md`, ou `null` si absent. Plafond 8 192 octets avec le même marqueur. Un fichier présent mais vide envoie `""`. `x-taste-learning` reste `"false"` ; charger taste n’est pas l’apprentissage taste de Command Code. +- **Skills :** paquet XML depuis les racines de skills du projet, dans l’ordre : `.commandcode/skills`, `.agents/skills`, `.pi/skills`. Chaque sous-répertoire avec `SKILL.md` devient une entrée `…` (nom depuis le frontmatter YAML `name:` ou le nom du répertoire). Ignore les noms commençant par `.` et les non-répertoires ; premier gagnant par nom résolu ; 16 skills max ; plafond XML total 32 768 octets. Ne lit jamais `~/.commandcode/skills` ni d’autres arborescences de skills du répertoire personnel. +- Le confinement de chemin utilise des vérifications realpath sous cwd ; les échappements par lien symbolique sont omis. Chaque opération fichier a un délai de 2 secondes. Résultats mis en cache par cwd pendant 30 secondes (128 entrées max). Toute défaillance omet cette partie en fail-soft. +- `commandCodeVersion` épingle `x-command-code-version` (défaut `0.52.1`). `permissionMode` reste `"standard"` et `mode` reste `"agent"`. ## `azure-openai` (alias : `azure`) 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 feedf5ad0ca..3a3d041a1fb 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -128,6 +128,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor uniquement : commandes externes d'utilisation d'un ordinateur et d'enregistrement de l'écran. | | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Politique d'exécution locale de Cursor. `off` est la valeur par défaut ; actuellement, `codex-sandbox` échoue de manière sûre comme `off`. | +| `commandCodeVersion?` | `string` | Command Code OAuth (`adapter: "command-code"`) uniquement. Épingle l'en-tête `x-command-code-version` sur les requêtes `/alpha/generate`. Si absent, utilise la valeur par défaut de l'adaptateur (`0.52.1`). Non lu par le préréglage à clé API `commandcode` (`openai-chat` / `/provider/v1`). | +| `projectContext?` | `"off" \| "on"` | Command Code OAuth (`adapter: "command-code"`) uniquement. Lorsque `"on"`, copie des fichiers de projet bornés depuis le répertoire de travail du processus proxy vers l'enveloppe `/alpha/generate` `memory`, `taste` et `skills`. Absent ou `"off"` conserve l'enveloppe vide actuelle, même si ces fichiers existent sur le disque. Non lu par le préréglage à clé API `commandcode`. À définir sur `providers.command-code`, pas au niveau supérieur. Dans le tableau de bord, utilisez **Providers → Command Code → Edit JSON**. Démarrez le proxy depuis le répertoire de projet Codex de confiance afin que `process.cwd()` soit le dépôt visé. Fail-soft : fichier manquant, illisible, expiré ou hors du périmètre → cette partie est omise plutôt que de faire échouer le tour. Ne lit pas `~/.commandcode/skills` ni d'autres arborescences du répertoire personnel. `x-taste-learning` reste `"false"` quel que soit ce réglage. | Les fournisseurs à clé API peuvent détenir une clé littérale ou une référence à une variable d'environnement. Les fournisseurs OAuth utilisent le magasin d'identifiants alimenté par `ocx login` ; le comportement de lancement de Claude Code avec abonnement est @@ -296,8 +298,8 @@ sur la liste curatée `noVisionModels` et passent par le sidecar de description Les outils locaux pilotés par le serveur Cursor sont désactivés par défaut. Codex continue d'utiliser ses propres outils tels que `apply_patch` et `exec_command` avec sa propre politique d'approbation et de bac à sable : -- `"off"` (par défaut) rejette l'exécution des outils Cursor natifs `read`, `write`, `delete`, `ls`, `grep`, `shell` et - `fetch`. +- `"off"` (par défaut) refuse l'exécution proxy-locale des outils Cursor natifs `read`, `write`, `delete`, `ls`, `grep`, `shell` et + `fetch`. Lorsque le tour annonce un outil Codex nu `shell_command` ou `exec_command`, Shell/Read/Ls/Grep/Fetch natifs sont mappés vers ce pont Codex au lieu de s'exécuter sur l'hôte proxy ; write/delete restent refusés. - `"on"` active une exécution locale de confiance et contourne les règles d'approbation et de bac à sable de Codex. - `"codex-sandbox"` est conservé pour compatibilité, mais échoue de manière sûre comme `"off"` ; le texte de la requête ne constitue pas une attestation fiable d'exécution en bac à sable. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 075ed9424c1..a3afad5bc59 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -401,6 +401,13 @@ account bearer; the Provider-API key preset (`commandcode`) uses the active conf key. A user-edited lookalike base URL is never probed. Remaining monthly, purchased, and free credits are shown as a USD window when Command Code also reports period spend. +**Command Code project context.** Optional `projectContext: "on"` on the OAuth `command-code` provider +only (not the API-key `commandcode` preset) fills `/alpha/generate` `memory`, `taste`, and `skills` +from the proxy working directory. Set it on `providers.command-code` via **Providers → Command Code → +Edit JSON**, start the proxy from the trusted Codex project, and restart after saving. Absent or +`"off"` keeps the empty envelope even when `AGENTS.md` or taste files exist. See +[Adapters](/reference/adapters/#command-code) for file paths, caps, and fail-soft behavior. + **SambaNova Cloud discovery.** The preset reads SambaNova Cloud's public `/v1/models` list from the fixed API host, preserves provider-native ids, and caps discovery at 128 KiB and 128 raw rows. Because the catalog is unauthenticated, the CLI login flow reports the key as unverifiable instead of treating diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index d92316d7960..de385e5f0ac 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -32,7 +32,7 @@ a missing credential produces no sidecar plan and the request takes the normal r ## Web-search sidecar -When Codex requests hosted `web_search` for a non-passthrough routed model, opencodex: +When Codex requests hosted `web_search` for a non-passthrough routed model, opencodex normally: 1. **Drops** the hosted `web_search` tool and exposes a synthetic `web_search(query)` function tool to the routed model instead. The original hosted-tool options are retained for the sidecar call. @@ -45,6 +45,14 @@ When Codex requests hosted `web_search` for a non-passthrough routed model, open (default 3), then removes the search tool and forces a final answer. Real client tools such as `apply_patch` or shell finalize the turn so those calls reach Codex. +**Antigravity Gemini 3 exception:** when the routed provider is Google Antigravity (`google-antigravity`) +and the wire model is Gemini 3.x, opencodex attaches CCA `google_search` (and `url_context` when the +turn includes `http(s)` URLs) on the **same** main routed request instead of the synthetic sidecar loop. +Grounding citations are appended to the streamed answer. The sidecar remains the path for Claude-on-CCA, +explicit `webSearchSidecar.backend` overrides (`openai`, `anthropic`, `xai`, `exa`), and +Gemini models below 3.x when Codex function tools are also present. An explicit `gemini` backend +does not disable in-turn grounding when the route itself is Antigravity Gemini 3. + Every routed-model iteration requests upstream `stream: true`, but by default opencodex fully buffers semantic events internally before deciding whether to search or return the final answer. Only the first iteration's final headers/status and 429 key rotations are acquired eagerly. Thus diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index da28e655d2d..2a30fdff3be 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -277,6 +277,8 @@ discovery エンドポイントから取得します。チャットリクエス **Command Code の quota:** ダッシュボードと `ocx account refresh` は、正規ホスト `https://api.commandcode.ai` 上の `/alpha/billing/credits` ウィンドウ(5時間と週次)を照会します。OAuth プリセット (`command-code`) は保存済みアカウント bearer を使い、Provider-API キープリセット (`commandcode`) は設定済みの有効キーを使います。ユーザーが編集した類似ホストは照会しません。期間支出が返る場合は、残りの monthly / purchased / free credits を USD ウィンドウとして表示します。 +**Command Code プロジェクトコンテキスト。** OAuth `command-code` プロバイダーのみ(API キー `commandcode` プリセットではない)で、任意の `projectContext: "on"` がプロキシ作業ディレクトリから `/alpha/generate` の `memory` / `taste` / `skills` を埋めます。**Providers → Command Code → Edit JSON** で `providers.command-code` に設定し、信頼できる Codex プロジェクトからプロキシを起動して保存後に再起動してください。未設定または `"off"` のときは `AGENTS.md` や taste ファイルがあっても空のエンベロープのままです。ファイルパス、上限、fail-soft の挙動は [Adapters](/ja/reference/adapters/#command-code) を参照してください。 + **SambaNova Cloud の discovery:** preset は固定 API ホスト上の SambaNova Cloud の公開 `/v1/models` 一覧を読み、 プロバイダー固有の ID を保持し、discovery を 128 KiB と raw 128 行に制限します。カタログは認証不要のため、 CLI の login flow は公開レスポンスをキーの有効性の証拠にせず、キーを検証不能として報告します。chat リクエストは diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index 7a44113a5d7..7a86388875a 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -31,6 +31,12 @@ Codex がパススルーでないルーティングモデルにホスト型 `web **反復**します。限度に達すると検索ツールを削除し最終回答を強制します。`apply_patch` や shell のような実際のクライアントツールが出たらターンを終了し該当呼び出しが Codex に渡るようにします。 +**Antigravity Gemini 3 の例外:** ルーティング先が Google Antigravity (`google-antigravity`) で +ワイヤモデルが Gemini 3.x のとき、opencodex は合成サイドカーループの代わりに、同じメインリクエストへ +CCA `google_search`(ターンに `http(s)` URL があれば `url_context`)を付けます。Claude-on-CCA、 +明示的な `openai` / `anthropic` / `xai` / `exa` バックエンド、および Codex 関数ツール付きの +Gemini 3 未満は従来のサイドカーのままです。 + ルーティングモデルのすべての反復は上流に `stream: true` を要求しますが、opencodex は検索可否や最終 回答を決める前に意味のある event を内部ですべてバッファリングします。最初の反復の最終 header/status と 429 キーローテーションのみ先行取得します。したがって合成検索呼び出しと中間出力はクライアントに diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index 843755bec6b..8bf3f0a0183 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -131,7 +131,21 @@ filtered incomplete になります。実際のツール呼び出しを伴わな - content-addressed blob で対話状態を再生し、サーバーツール呼び出しを Codex に再マッピングします。protobuf の `GetUsableModels` RPC でリアルタイム Cursor モデルを探し、run リクエストが wire に commit される前だけリトライします。 - `cursor/grok-4.5-fast` は選択可能なモデルとして維持しつつ、Cursor には正規の `grok-4.5` モデルを送信し、個別の `effort` および `fast=true` 値は `requested_model.parameters` に格納します。 -- Cursor ネイティブのローカルファイルシステム/shell/network 実行はデフォルトで拒否します。明示的な `mcpServers` と `desktopExecutor` 統合はそれぞれ別の opt-in です。`unsafeAllowNativeLocalExec` はより広い組み込み executor を有効にし、Codex の承認/サンドボックスルールを迂回します。 +- Cursor ネイティブのローカルファイルシステム/shell/network 実行はデフォルトで拒否します。明示的な `mcpServers` と `desktopExecutor` 統合はそれぞれ別の opt-in です。`unsafeAllowNativeLocalExec` はより広い組み込み executor を有効にし、Codex の承認/サンドボックスルールを迂回します。既定の `off` では、カタログにブリッジツールがある場合、ネイティブ Shell/Read/Ls/Grep/Fetch は Codex の `shell_command`/`exec_command` にマップされ、write/delete は引き続き拒否されます。 + +## `command-code` + +**対象:** Command Code **OAuth** サブスクリプション agent API (`POST {baseUrl}/alpha/generate`)。 +**認証:** `ocx login command-code` の OAuth Bearer。 + +- API キーの `commandcode` プリセット (`openai-chat` → `POST {baseUrl}/provider/v1/chat/completions`) とは別経路です。API キー経路は `projectContext` を読まず、ディスクから generate エンベロープを埋めません。 +- `providers.command-code` の任意の `projectContext: "on"` は、リクエスト時に `process.cwd()` から上限付きファイルを `memory` / `taste` / `skills` へコピーします。未設定または `"off"` のときは、リポジトリにファイルがあっても `memory: ""`, `taste: null`, `skills: null` を送ります — 明示的 opt-in のみで、自動読み込みはありません。 +- Codex が編集するリポジトリと作業ディレクトリが一致するよう、信頼できる Codex プロジェクトディレクトリからプロキシを起動してください。 +- **Memory:** cwd の `AGENTS.md` の UTF-8 のみ(`CLAUDE.md`、`CODEX.md`、ホームパスは対象外)。上限 32,768 バイト。超過時は `` 付きで先頭を切り詰めます。 +- **Taste:** `.commandcode/taste/taste.md` の UTF-8。欠落時は `null`。上限 8,192 バイト、同じ切り詰めマーカー。存在するが空のファイルは `""` を送ります。`x-taste-learning` は `"false"` のままです。taste の読み込みは Command Code の taste learning ではありません。 +- **Skills:** プロジェクト skill ルートをこの順で XML 化: `.commandcode/skills`、`.agents/skills`、`.pi/skills`。`SKILL.md` を持つ各サブディレクトリが `…` になります(名前は YAML frontmatter の `name:` またはディレクトリ名)。`.` 始まり名と非ディレクトリはスキップ。解決名で first-wins。最大 16 skills。XML 合計上限 32,768 バイト。`~/.commandcode/skills` などホーム skill ツリーは読みません。 +- パス制限は cwd 配下の realpath チェック。シンボリックリンク逸脱は省略。各ファイル操作は 2 秒タイムアウト。結果は cwd ごとに 30 秒キャッシュ(最大 128 エントリ)。失敗時は fail-soft でその部分のみ省略。 +- `commandCodeVersion` は `x-command-code-version` を固定(デフォルト `0.52.1`)。`permissionMode` は `"standard"`、`mode` は `"agent"` のままです。 ## `azure-openai`(別名: `azure`) 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 27cf1406ecc..56f297b3be6 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -115,6 +115,8 @@ account を削除しても mapping は保持され、同じ id を再追加す | `desktopExecutor?` | `DesktopExecutorConfig` |カーソルのみ: 外部コンピュータ使用および画面録画コマンド。 | | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` |カーソルのローカル実行ポリシー。 `off` がデフォルトです。 `codex-sandbox` は現在、`off` と同様にフェールクローズされます。 | +| `commandCodeVersion?` | `string` | Command Code OAuth (`adapter: "command-code"`) のみ。`/alpha/generate` リクエストの `x-command-code-version` ヘッダーを固定します。未設定時はアダプターのデフォルト (`0.52.1`) を使います。API キーの `commandcode` プリセット (`openai-chat` / `/provider/v1`) では読みません。 | +| `projectContext?` | `"off" \| "on"` | Command Code OAuth (`adapter: "command-code"`) のみ。`"on"` のとき、プロキシプロセスの作業ディレクトリから上限付きのプロジェクトファイルを `/alpha/generate` の `memory` / `taste` / `skills` エンベロープへコピーします。未設定または `"off"` のときは、ディスク上にファイルがあっても空のエンベロープのままです。API キーの `commandcode` プリセットでは読みません。`providers.command-code` に設定し、トップレベルには置きません。ダッシュボードでは **Providers → Command Code → Edit JSON** を使います。`process.cwd()` が意図したリポジトリになるよう、信頼できる Codex プロジェクトディレクトリからプロキシを起動してください。fail-soft: 欠落・読取不能・タイムアウト・パス逸脱はその部分を省略し、ターン全体は失敗しません。`~/.commandcode/skills` などホーム配下の skill は読みません。このフラグに関係なく `x-taste-learning` は `"false"` のままです。 | API キープロバイダーは、リテラルキーまたは環境参照を保持する場合があります。 OAuth プロバイダーは、`ocx login` によって設定された資格情報ストアを使用します。サブスクリプションに基づくクロード コードの起動動作は、[`claudeCode.authMode`](/reference/configuration/server/#claude-code) で構成されます。 @@ -237,8 +239,8 @@ Anthropic アカウント ポリシーのリスクを理解していない限り カーソル サーバー駆動のローカル ツールは、デフォルトでは無効になっています。 Codex は、独自の承認とサンドボックス ポリシーを備えた `apply_patch` や `exec_command` などの独自のツールを引き続き使用します。 -- `"off"` (デフォルト) は、カーソルネイティブの `read`、`write`、`delete`、`ls`、`grep`、`shell`、および -`fetch`実行。 +- `"off"` (デフォルト) は、プロキシ上での Cursor ネイティブ `read`、`write`、`delete`、`ls`、`grep`、`shell`、および + `fetch` 実行を拒否します。ターンが素の Codex `shell_command` または `exec_command` を公開している場合、ネイティブ Shell/Read/Ls/Grep/Fetch はプロキシではなくその Codex シェルブリッジへマップされ、write/delete は引き続き拒否されます。 - `"on"` は、信頼できるローカルでの実行を選択し、Codex 承認/サンドボックス セマンティクスをバイパスします。 - `"codex-sandbox"` は互換性のために残されていますが、`"off"` と同様にフェールクローズされます。散文のリクエストは 信頼できるサンドボックス証明書ではありません。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 2a30c24f987..5cd1005472a 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -275,6 +275,8 @@ CLI 사용자는 `~/.commandcode/auth.json`의 로컬 CLI 자격 증명을 가 **Command Code 할당량:** 대시보드와 `ocx account refresh`는 정규 호스트 `https://api.commandcode.ai`에서 `/alpha/billing/credits` 창(5시간 및 주간)을 조회합니다. OAuth 프리셋(`command-code`)은 저장된 계정 bearer를 사용하고, Provider-API 키 프리셋(`commandcode`)은 현재 설정된 활성 키를 사용합니다. 사용자가 바꾼 유사 base URL은 조회하지 않습니다. Command Code가 기간 사용량을 함께 반환하면 남은 monthly / purchased / free credits가 USD 창으로 표시됩니다. +**Command Code 프로젝트 컨텍스트.** OAuth `command-code` 공급자에서만( API 키 `commandcode` 프리셋 아님) 선택적 `projectContext: "on"`이 프록시 작업 디렉터리에서 `/alpha/generate`의 `memory` / `taste` / `skills`를 채웁니다. **Providers → Command Code → Edit JSON**으로 `providers.command-code`에 설정하고, 신뢰할 수 있는 Codex 프로젝트에서 프록시를 시작한 뒤 저장 후 재시작하세요. 생략하거나 `"off"`이면 `AGENTS.md`나 taste 파일이 있어도 빈 엔벨로프를 유지합니다. 파일 경로, 상한, fail-soft 동작은 [Adapters](/ko/reference/adapters/#command-code)를 참고하세요. + **SambaNova Cloud 검색:** 프리셋은 고정 API 호스트의 SambaNova Cloud 공개 `/v1/models` 목록을 읽고, 프로바이더 네이티브 ID를 보존하며 discovery를 128 KiB와 raw 행 128개로 제한합니다. 카탈로그에는 인증이 필요하지 않으므로 CLI 로그인 흐름은 공개 응답을 키 유효성의 증거로 사용하지 않고 키를 검증할 수 없는 것으로 보고합니다. chat 요청은 diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index e75e0521e58..f037ed8dd50 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -32,6 +32,11 @@ Codex가 패스스루가 아닌 라우팅 모델에 호스팅 `web_search`를 **반복**합니다. 한도에 닿으면 검색 도구를 제거하고 최종 답변을 강제합니다. `apply_patch`나 shell 같은 실제 클라이언트 도구가 나오면 턴을 끝내 해당 호출이 Codex에 전달되게 합니다. +**Antigravity Gemini 3 예외:** 라우팅 프로바이더가 Google Antigravity(`google-antigravity`)이고 +와이어 모델이 Gemini 3.x이면, opencodex는 합성 사이드카 루프 대신 같은 메인 요청에 CCA `google_search` +(턴에 `http(s)` URL이 있으면 `url_context`)를 붙입니다. Claude-on-CCA, 명시적 `openai` / +`anthropic` / `xai` / `exa` 백엔드, Codex 함수 도구가 있는 Gemini 3 미만은 사이드카를 유지합니다. + 라우팅 모델의 모든 반복은 업스트림에 `stream: true`를 요청하지만, opencodex는 검색 여부나 최종 답변을 결정하기 전에 의미 있는 event를 내부에서 전부 버퍼링합니다. 첫 번째 반복의 최종 header/status와 429 key rotation만 미리 가져옵니다. 따라서 합성 검색 호출과 중간 출력은 클라이언트에 diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 4d0544cf356..9408a8dec87 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -161,7 +161,23 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. 별도의 `effort`, `fast=true` 값은 `requested_model.parameters`에 담습니다. - Cursor 네이티브 로컬 파일시스템/shell/network 실행은 기본적으로 거부합니다. 명시적인 `mcpServers`와 `desktopExecutor` 통합은 각각 별도 opt-in입니다. `unsafeAllowNativeLocalExec`은 - 더 넓은 내장 executor를 켜며 Codex 승인/샌드박스 규칙을 우회합니다. + 더 넓은 내장 executor를 켜며 Codex 승인/샌드박스 규칙을 우회합니다. 기본 `off`에서는 카탈로그에 + 브리지 도구가 있을 때 네이티브 Shell/Read/Ls/Grep/Fetch가 Codex `shell_command`/`exec_command`로 + 매핑되며 write/delete는 계속 거부됩니다. + +## `command-code` + +**대상:** Command Code **OAuth** 구독 agent API (`POST {baseUrl}/alpha/generate`). +**인증:** `ocx login command-code` OAuth Bearer. + +- API 키 `commandcode` 프리셋(`openai-chat` → `POST {baseUrl}/provider/v1/chat/completions`)과 별개입니다. API 키 경로는 `projectContext`를 읽지 않으며 디스크에서 generate 엔벨로프를 채우지 않습니다. +- `providers.command-code`의 선택적 `projectContext: "on"`은 요청 시 `process.cwd()`의 제한된 파일을 `memory` / `taste` / `skills`에 복사합니다. 생략하거나 `"off"`이면 저장소에 파일이 있어도 `memory: ""`, `taste: null`, `skills: null`을 보냅니다 — 명시적 opt-in만, 자동 로드 없음. +- Codex가 편집하는 저장소와 작업 디렉터리가 일치하도록 신뢰할 수 있는 Codex 프로젝트 디렉터리에서 프록시를 시작하세요. +- **Memory:** cwd의 `AGENTS.md` UTF-8만(`CLAUDE.md`, `CODEX.md`, 홈 경로 제외). 상한 32,768바이트. 초과 시 ``로 앞부분만 유지. +- **Taste:** `.commandcode/taste/taste.md` UTF-8, 없으면 `null`. 상한 8,192바이트, 동일한 잘림 마커. 존재하지만 빈 파일은 `""`를 보냅니다. `x-taste-learning`은 `"false"`로 유지됩니다. taste 로드는 Command Code taste learning이 아닙니다. +- **Skills:** 프로젝트 skill 루트를 순서대로 XML화: `.commandcode/skills`, `.agents/skills`, `.pi/skills`. `SKILL.md`가 있는 각 하위 디렉터리가 `…`가 됩니다(YAML frontmatter `name:` 또는 디렉터리 이름). `.`로 시작하는 이름과 비디렉터리는 건너뜀. 해석된 이름 기준 first-wins. 최대 16 skills. XML 총 상한 32,768바이트. `~/.commandcode/skills` 등 홈 skill 트리는 읽지 않습니다. +- 경로 제한은 cwd 아래 realpath 검사. 심볼릭 링크 이탈은 생략. 각 파일 작업 2초 타임아웃. 결과는 cwd별 30초 캐시(최대 128개). 실패 시 fail-soft로 해당 부분만 생략. +- `commandCodeVersion`은 `x-command-code-version` 고정(기본 `0.52.1`). `permissionMode`는 `"standard"`, `mode`는 `"agent"`로 유지. ## `azure-openai` (별칭: `azure`) 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 707129b2ed1..5a561e43072 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -115,6 +115,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor 전용입니다. 외부 computer-use 및 record-screen 명령입니다. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 로컬 실행 정책입니다. 기본값은 `off`입니다. `codex-sandbox`는 현재 `off`처럼 실패를 닫습니다. | +| `commandCodeVersion?` | `string` | Command Code OAuth (`adapter: "command-code"`) 전용입니다. `/alpha/generate` 요청의 `x-command-code-version` 헤더를 고정합니다. 생략 시 어댑터 기본값(`0.52.1`)을 사용합니다. API 키 `commandcode` 프리셋(`openai-chat` / `/provider/v1`)에서는 읽지 않습니다. | +| `projectContext?` | `"off" \| "on"` | Command Code OAuth (`adapter: "command-code"`) 전용입니다. `"on"`이면 프록시 프로세스 작업 디렉터리의 제한된 프로젝트 파일을 `/alpha/generate`의 `memory` / `taste` / `skills` 엔벨로프에 복사합니다. 생략하거나 `"off"`이면 디스크에 파일이 있어도 빈 엔벨로프를 유지합니다. API 키 `commandcode` 프리셋에서는 읽지 않습니다. 최상위가 아니라 `providers.command-code`에 설정합니다. 대시보드에서는 **Providers → Command Code → Edit JSON**을 사용합니다. `process.cwd()`가 의도한 저장소가 되도록 신뢰할 수 있는 Codex 프로젝트 디렉터리에서 프록시를 시작하세요. fail-soft: 누락·읽기 불가·타임아웃·경로 이탈은 해당 조각을 생략하며 턴 전체는 실패하지 않습니다. `~/.commandcode/skills` 등 홈 skill 트리는 읽지 않습니다. 이 플래그와 관계없이 `x-taste-learning`은 `"false"`로 유지됩니다. | API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAuth 공급자는 `ocx login`으로 채워지는 자격 증명 저장소를 사용합니다. 구독 기반 Claude Code 실행 동작은 [`claudeCode.authMode`](/reference/configuration/server/#claude-code)에서 설정합니다. @@ -240,7 +242,7 @@ Cursor 브리지는 실험적입니다. `ocx login cursor`를 실행한 뒤 `pro Cursor 서버 주도 로컬 도구는 기본값으로 비활성화됩니다. Codex는 계속해서 `apply_patch`, `exec_command` 같은 자체 도구를 자체 승인 및 샌드박스 정책과 함께 사용합니다. -- `"off"`(기본값)는 Cursor 네이티브 `read`, `write`, `delete`, `ls`, `grep`, `shell`, `fetch` 실행을 거부합니다. +- `"off"`(기본값)는 프록시에서 Cursor 네이티브 `read`, `write`, `delete`, `ls`, `grep`, `shell`, `fetch` 실행을 거부합니다. 턴에 bare Codex `shell_command` 또는 `exec_command`가 광고되어 있으면 네이티브 Shell/Read/Ls/Grep/Fetch는 프록시가 아니라 해당 Codex 셸 브리지로 매핑되며 write/delete는 계속 거부됩니다. - `"on"`은 신뢰된 로컬 실행을 허용하고, Codex 승인/샌드박스 의미를 우회합니다. - `"codex-sandbox"`는 호환성을 위해 남아 있지만 `"off"`처럼 실패를 닫습니다. 요청 문구는 신뢰할 수 있는 샌드박스 증명이 아닙니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index d5699ae4f5b..3b9abf27678 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -246,6 +246,22 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and and `desktopExecutor` integrations have separate opt-ins; `nativeLocalExec: "on"` enables the broader built-in executor and bypasses Codex approval/sandbox semantics, and legacy `unsafeAllowNativeLocalExec: true` remains equivalent only when `nativeLocalExec` is unset. + With default-off policy, native Shell/Read/Ls/Grep/Fetch map to Codex `shell_command`/`exec_command` + when that bridge tool is in the catalog; write/delete remain refused. + +## `command-code` + +**Targets:** Command Code **OAuth** subscription agent API (`POST {baseUrl}/alpha/generate`). +**Auth:** OAuth Bearer from `ocx login command-code`. + +- Distinct from the API-key `commandcode` preset (`openai-chat` → `POST {baseUrl}/provider/v1/chat/completions`). The API-key route never reads `projectContext` or fills the generate envelope from disk. +- Optional `projectContext: "on"` on `providers.command-code` copies bounded files from `process.cwd()` at request time into `memory`, `taste`, and `skills`. Absent or `"off"` sends `memory: ""`, `taste: null`, `skills: null` even when those files exist in the repo — opt-in only, never auto-load. +- Start the proxy from the trusted Codex project directory so the working directory matches the repository Codex is editing. +- **Memory:** UTF-8 of `AGENTS.md` at cwd only (not `CLAUDE.md`, `CODEX.md`, or home paths). Cap 32,768 bytes; oversize prefixes truncate with ``. +- **Taste:** UTF-8 of `.commandcode/taste/taste.md`, or `null` when missing. Cap 8,192 bytes with the same truncation marker. A present-but-empty file sends `""`. `x-taste-learning` remains `"false"`; loading taste is not Command Code taste learning. +- **Skills:** XML bundle from project skill roots in order: `.commandcode/skills`, `.agents/skills`, `.pi/skills`. Each subdirectory with `SKILL.md` becomes one `…` entry (name from YAML frontmatter `name:` or the directory name). Skips dotted names and non-directories; first-wins by resolved name; max 16 skills; total XML cap 32,768 bytes. Never reads `~/.commandcode/skills` or other home skill trees. +- Path confinement uses realpath checks under cwd; symlink escapes are omitted. Each file operation has a 2-second timeout. Results are cached per cwd for 30 seconds (max 128 entries). Any failure omits that piece fail-softly. +- `commandCodeVersion` pins `x-command-code-version` (default `0.52.1`). `permissionMode` stays `"standard"` and `mode` stays `"agent"`. ## `azure-openai` (alias: `azure`) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 64638ee9cc9..78f3db34ed1 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -130,6 +130,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor only: external computer-use and record-screen commands. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +| `commandCodeVersion?` | `string` | Command Code OAuth (`adapter: "command-code"`) only. Pins the `x-command-code-version` header on `/alpha/generate` requests. Absent uses the adapter default (`0.52.1`). Not read by the API-key `commandcode` preset (`openai-chat` / `/provider/v1`). | +| `projectContext?` | `"off" \| "on"` | Command Code OAuth (`adapter: "command-code"`) only. When `"on"`, copies bounded project files from the proxy process working directory into the `/alpha/generate` `memory`, `taste`, and `skills` envelope. Absent or `"off"` keeps today's empty envelope even when those files exist on disk. Not read by the API-key `commandcode` preset. Set on `providers.command-code`, not at the top level. In the dashboard use **Providers → Command Code → Edit JSON**. Start the proxy from the trusted Codex project directory so `process.cwd()` is the repo you intend. Fail-soft: missing, unreadable, timed-out, or path-escape conditions omit that piece rather than failing the turn. Does not read `~/.commandcode/skills` or other home trees. `x-taste-learning` stays `"false"` regardless of this flag. | ### FastWire B1 capability migration @@ -464,8 +466,10 @@ the curated `noVisionModels` list and use the vision describe sidecar instead. Cursor server-driven local tools are disabled by default. Codex continues using its own tools such as `apply_patch` and `exec_command` with its own approval and sandbox policy: -- `"off"` (default) rejects Cursor-native `read`, `write`, `delete`, `ls`, `grep`, `shell`, and - `fetch` execution. +- `"off"` (default) denies proxy-local Cursor-native `read`, `write`, `delete`, `ls`, `grep`, + `shell`, and `fetch` execution. When the turn advertises a bare Codex `shell_command` or + `exec_command` tool, native Shell/Read/Ls/Grep/Fetch map to that Codex shell bridge instead of + running on the proxy host; write/delete stay refused. - `"on"` opts into trusted-local execution and bypasses Codex approval/sandbox semantics. - `"codex-sandbox"` is retained for compatibility but fails closed like `"off"`; request prose is not trustworthy sandbox attestation. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 84e07fb6f6a..84921c968cd 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -300,6 +300,8 @@ Service token Nscale создаётся в [Nscale Console](https://console.nsca **Квота Command Code.** Дашборд и `ocx account refresh` опрашивают окна `/alpha/billing/credits` (5 часов и неделя) на каноническом хосте `https://api.commandcode.ai`. OAuth-пресет (`command-code`) использует сохранённый bearer аккаунта; пресет Provider-API ключа (`commandcode`) — активный настроенный ключ. Пользовательски изменённый похожий base URL не опрашивается. Если Command Code также сообщает расход за период, оставшиеся monthly / purchased / free credits показываются как USD-окно. +**Контекст проекта Command Code.** Необязательный `projectContext: "on"` только на OAuth-провайдере `command-code` (не на пресете API-ключа `commandcode`) заполняет `memory`, `taste` и `skills` в `/alpha/generate` из рабочей директории прокси. Задайте на `providers.command-code` через **Providers → Command Code → Edit JSON**, запустите прокси из доверенного проекта Codex и перезапустите после сохранения. Если отсутствует или `"off"`, пустой конверт сохраняется, даже когда есть `AGENTS.md` или taste-файлы. Пути, лимиты и fail-soft — в [Adapters](/ru/reference/adapters/#command-code). + **Discovery для SambaNova Cloud.** Пресет читает общедоступный список SambaNova Cloud `/v1/models` на фиксированном API-хосте, сохраняет нативные id провайдера и ограничивает discovery размером 128 KiB и 128 исходными строками. Каталог не требует аутентификации, поэтому процедура входа CLI сообщает, что diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index fed573bd8ea..79439d17445 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -36,6 +36,12 @@ opencodex: финальному ответу. Настоящие клиентские инструменты вроде `apply_patch` или shell завершают ход, чтобы эти вызовы дошли до Codex. +**Исключение Antigravity Gemini 3:** если маршрутизируемый провайдер — Google Antigravity (`google-antigravity`) +и wire-модель — Gemini 3.x, opencodex вешает CCA `google_search` (и `url_context`, если в ходе есть +URL `http(s)`) на **тот же** основной запрос вместо sidecar-цикла. Claude-on-CCA, явные бэкенды +`openai` / `anthropic` / `xai` / `exa` и Gemini ниже 3.x с функциональными инструментами Codex +по-прежнему идут через sidecar. + Каждая итерация маршрутизируемой модели запрашивает у вышестоящего провайдера `stream: true`, но opencodex полностью буферизует семантические события внутри, прежде чем решить, искать дальше или вернуть финальный ответ. Заранее получаются только финальные заголовки/статус первой итерации и diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index e856937e00d..4f436282f7c 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -161,7 +161,23 @@ authorization. - Нативное для Cursor локальное выполнение операций с файловой системой/shell/сетью по умолчанию запрещено. Явные интеграции `mcpServers` и `desktopExecutor` включаются отдельно; `unsafeAllowNativeLocalExec` включает более широкий встроенный executor и обходит семантику - одобрений/песочницы Codex. + одобрений/песочницы Codex. При политике `off` по умолчанию нативные Shell/Read/Ls/Grep/Fetch + маппятся в Codex `shell_command`/`exec_command`, когда этот bridge-инструмент есть в каталоге; + write/delete по-прежнему запрещены. + +## `command-code` + +**Цели:** agent API подписки Command Code **OAuth** (`POST {baseUrl}/alpha/generate`). +**Аутентификация:** OAuth Bearer через `ocx login command-code`. + +- Отличается от пресета API-ключа `commandcode` (`openai-chat` → `POST {baseUrl}/provider/v1/chat/completions`). Маршрут API-ключа никогда не читает `projectContext` и не заполняет generate-конверт с диска. +- Необязательный `projectContext: "on"` на `providers.command-code` копирует ограниченные файлы из `process.cwd()` во время запроса в `memory`, `taste` и `skills`. Если отсутствует или `"off"`, отправляет `memory: ""`, `taste: null`, `skills: null`, даже когда файлы есть в репозитории — только opt-in, без автозагрузки. +- Запускайте прокси из доверенной директории проекта Codex, чтобы рабочая директория совпадала с редактируемым репозиторием. +- **Memory:** только UTF-8 `AGENTS.md` в cwd (не `CLAUDE.md`, `CODEX.md` и не домашние пути). Лимит 32 768 байт; при превышении префикс обрезается с ``. +- **Taste:** UTF-8 `.commandcode/taste/taste.md` или `null`, если файла нет. Лимит 8 192 байт с тем же маркером. Пустой, но существующий файл отправляет `""`. `x-taste-learning` остаётся `"false"`; загрузка taste — это не taste learning Command Code. +- **Skills:** XML из корней skill проекта по порядку: `.commandcode/skills`, `.agents/skills`, `.pi/skills`. Каждый подкаталог с `SKILL.md` становится `…` (имя из YAML frontmatter `name:` или имени каталога). Пропускает имена с `.` и не-каталоги; first-wins по разрешённому имени; максимум 16 skills; общий XML-лимит 32 768 байт. Никогда не читает `~/.commandcode/skills` и другие домашние skill-деревья. +- Ограничение путей через realpath под cwd; symlink-выходы опускаются. Таймаут 2 секунды на операцию с файлом. Кэш по cwd на 30 секунд (максимум 128 записей). Любая ошибка fail-soft опускает только эту часть. +- `commandCodeVersion` фиксирует `x-command-code-version` (по умолчанию `0.52.1`). `permissionMode` остаётся `"standard"`, `mode` — `"agent"`. ## `azure-openai` (алиас: `azure`) 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 21b70bebb5a..fa004eb6311 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -128,6 +128,8 @@ cross-route credential fallback не существует. Строки API GPT- | `desktopExecutor?` | `DesktopExecutorConfig` | Только Cursor: команды внешнего computer-use и record-screen. | | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Политика local-exec для Cursor. `off` — дефолт; `codex-sandbox` сейчас ведёт себя fail-closed как `off`. | +| `commandCodeVersion?` | `string` | Только Command Code OAuth (`adapter: "command-code"`). Фиксирует заголовок `x-command-code-version` в запросах `/alpha/generate`. Если не задан, используется дефолт адаптера (`0.52.1`). Не читается пресетом API-ключа `commandcode` (`openai-chat` / `/provider/v1`). | +| `projectContext?` | `"off" \| "on"` | Только Command Code OAuth (`adapter: "command-code"`). При `"on"` копирует ограниченные файлы проекта из рабочей директории процесса прокси в конверт `/alpha/generate` (`memory`, `taste`, `skills`). Если отсутствует или `"off"`, сохраняется пустой конверт, даже когда файлы есть на диске. Не читается пресетом API-ключа `commandcode`. Задаётся на `providers.command-code`, не на верхнем уровне. В дашборде: **Providers → Command Code → Edit JSON**. Запускайте прокси из доверенной директории проекта Codex, чтобы `process.cwd()` указывал на нужный репозиторий. Fail-soft: отсутствие, нечитаемость, таймаут или выход за пределы пути — часть опускается, ход не падает. Не читает `~/.commandcode/skills` и другие домашние skill-деревья. `x-taste-learning` остаётся `"false"` независимо от флага. | Провайдеры с API-key могут хранить literal key или environment-reference. OAuth-провайдеры используют credential store, заполняемый через `ocx login`; поведение subscription-backed launcher'а @@ -293,8 +295,9 @@ Bridge Cursor экспериментальный. После `ocx login cursor` Server-driven local tool'ы Cursor по умолчанию выключены. Codex продолжает использовать собственные инструменты, такие как `apply_patch` и `exec_command`, со своей же approval/sandbox policy: -- `"off"` (по умолчанию) отвергает нативное выполнение `read`, `write`, `delete`, `ls`, `grep`, - `shell` и `fetch` со стороны Cursor. +- `"off"` (по умолчанию) запрещает proxy-local выполнение нативных Cursor `read`, `write`, `delete`, `ls`, `grep`, + `shell` и `fetch`. Когда в turn объявлен bare Codex `shell_command` или `exec_command`, нативные Shell/Read/Ls/Grep/Fetch + маппятся в этот Codex shell bridge вместо выполнения на proxy; write/delete по-прежнему запрещены. - `"on"` включает trusted local execution и обходит approval/sandbox semantics Codex. - `"codex-sandbox"` сохранён ради совместимости, но закрывается с ошибкой так же, как `"off"`; на prose запроса нельзя полагаться как на достоверную sandbox-attestation. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index f8095134d0d..9ab9f682cc8 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -437,6 +437,8 @@ anahtarını kullanır. [Command Code Studio](https://commandcode.ai/studio/) **Command Code kotası.** Pano ve `ocx account refresh`, kanonik `https://api.commandcode.ai` ana bilgisayarında `/alpha/billing/credits` pencerelerini (5 saat ve haftalık) sorgular. OAuth önayarı (`command-code`) kayıtlı hesap bearer'ını kullanır; Provider-API anahtar önayarı (`commandcode`) etkin yapılandırılmış anahtarı kullanır. Kullanıcının değiştirdiği benzer bir temel URL asla sorgulanmaz. Command Code dönem harcamasını da bildirirse kalan monthly / purchased / free credits USD penceresi olarak gösterilir. +**Command Code proje bağlamı.** Yalnızca OAuth `command-code` sağlayıcısında (API anahtarı `commandcode` önayarında değil) isteğe bağlı `projectContext: "on"`, proxy çalışma dizininden `/alpha/generate` `memory` / `taste` / `skills` alanlarını doldurur. **Providers → Command Code → Edit JSON** ile `providers.command-code` üzerinde ayarlayın, güvenilir Codex projesinden proxy'yi başlatın ve kaydettikten sonra yeniden başlatın. Eksik veya `"off"` iken `AGENTS.md` veya taste dosyaları olsa bile boş zarf korunur. Dosya yolları, üst sınırlar ve fail-soft davranışı için [Adapters](/tr/reference/adapters/#command-code) bölümüne bakın. + **SambaNova Cloud keşfi.** Önayar, sabit API ana bilgisayarından SambaNova Cloud'un genel `/v1/models` listesini okur, sağlayıcı yerel kimliklerini korur ve keşfi 128 KiB ve 128 ham satırla sınırlar. Katalog kimlik doğrulamasız diff --git a/docs-site/src/content/docs/tr/guides/sidecars.md b/docs-site/src/content/docs/tr/guides/sidecars.md index 1057ab70a73..b45f6a30a28 100644 --- a/docs-site/src/content/docs/tr/guides/sidecars.md +++ b/docs-site/src/content/docs/tr/guides/sidecars.md @@ -37,6 +37,12 @@ Codex, doğrudan geçiş olmayan bir yönlendirilmiş model için barındırıla kaldırır ve nihai bir yanıta zorlar. `apply_patch` veya kabuk gibi gerçek istemci araçları turu sonlandırır, böylece bu çağrılar Codex'e ulaşır. +**Antigravity Gemini 3 istisnası:** yönlendirilen sağlayıcı Google Antigravity (`google-antigravity`) +ve tel modeli Gemini 3.x ise opencodex, sentetik sidecar döngüsü yerine **aynı** ana isteğe CCA +`google_search` (turda `http(s)` URL varsa `url_context`) ekler. Claude-on-CCA, açık `openai` / +`anthropic` / `xai` / `exa` arka uçları ve Codex işlev araçları olan Gemini 3 altı modeller +sidecar'da kalır. + Her yönlendirilen model yinelemesi yukarı akışta `stream: true` ister, ancak varsayılan olarak opencodex arama yapmaya veya nihai yanıtı döndürmeye karar vermeden önce anlamsal olayları dahili olarak tamamen arabelleğe alır. Yalnızca diff --git a/docs-site/src/content/docs/tr/reference/adapters.md b/docs-site/src/content/docs/tr/reference/adapters.md index 8b0077176fa..086b9ae5a4e 100644 --- a/docs-site/src/content/docs/tr/reference/adapters.md +++ b/docs-site/src/content/docs/tr/reference/adapters.md @@ -264,7 +264,23 @@ başlığından Cursor OAuth/erişim belirteci. vardır; `nativeLocalExec: "on"` daha geniş yerleşik yürütücüyü etkinleştirir ve Codex onay/sanal alan anlambilimini atlar ve eski `unsafeAllowNativeLocalExec: true` yalnızca `nativeLocalExec` ayarlanmadığında - eşdeğer kalır. + eşdeğer kalır. Varsayılan `off` ile, katalogda köprü aracı varken yerel + Shell/Read/Ls/Grep/Fetch Codex `shell_command`/`exec_command` çağrılarına + eşlenir; write/delete reddedilmeye devam eder. + +## `command-code` + +**Hedefler:** Command Code **OAuth** abonelik agent API'si (`POST {baseUrl}/alpha/generate`). +**Kimlik doğrulama:** `ocx login command-code` OAuth Bearer. + +- API anahtarı `commandcode` önayından (`openai-chat` → `POST {baseUrl}/provider/v1/chat/completions`) ayrıdır. API anahtarı yolu `projectContext` okumaz ve generate zarfını diskten doldurmaz. +- `providers.command-code` üzerindeki isteğe bağlı `projectContext: "on"`, istek anında `process.cwd()` içindeki sınırlı dosyaları `memory`, `taste` ve `skills` alanlarına kopyalar. Eksik veya `"off"` iken depoda dosyalar olsa bile `memory: ""`, `taste: null`, `skills: null` gönderilir — yalnızca opt-in, otomatik yükleme yok. +- Çalışma dizini Codex'in düzenlediği depoyla eşleşsin diye proxy'yi güvenilir Codex proje dizininden başlatın. +- **Memory:** yalnızca cwd'deki `AGENTS.md` UTF-8 (`CLAUDE.md`, `CODEX.md` veya ev yolları değil). Üst sınır 32.768 bayt; aşımda `` ile önek kesilir. +- **Taste:** `.commandcode/taste/taste.md` UTF-8 veya yoksa `null`. Üst sınır 8.192 bayt, aynı kesme işareti. Var ama boş dosya `""` gönderir. `x-taste-learning` `"false"` kalır; taste yüklemek Command Code taste learning değildir. +- **Skills:** proje skill köklerinden sırayla XML: `.commandcode/skills`, `.agents/skills`, `.pi/skills`. `SKILL.md` içeren her alt dizin bir `…` olur (ad YAML frontmatter `name:` veya dizin adından). `.` ile başlayan adlar ve dizin olmayanlar atlanır; çözümlenen ada göre first-wins; en fazla 16 skill; toplam XML üst sınırı 32.768 bayt. `~/.commandcode/skills` veya diğer ev skill ağaçlarını okumaz. +- Yol sınırlandırması cwd altında realpath ile yapılır; symlink kaçışları düşürülür. Her dosya işlemi 2 saniye zaman aşımı. Sonuçlar cwd başına 30 saniye önbelleğe alınır (en fazla 128 giriş). Herhangi bir hata fail-soft olarak yalnızca o parçayı düşürür. +- `commandCodeVersion`, `x-command-code-version` sabitler (varsayılan `0.52.1`). `permissionMode` `"standard"`, `mode` `"agent"` kalır. ## `azure-openai` (takma ad: `azure`) 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 d4e414700a7..09d292efcb7 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -134,6 +134,8 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `desktopExecutor?` | `DesktopExecutorConfig` | Yalnızca Cursor: harici bilgisayar kullanımı ve ekran kaydetme komutları. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor yerel yürütme politikası. `off` varsayılandır; `codex-sandbox` şu anda `off` gibi kapalı olarak başarısız olur. | +| `commandCodeVersion?` | `string` | Yalnızca Command Code OAuth (`adapter: "command-code"`). `/alpha/generate` isteklerinde `x-command-code-version` başlığını sabitler. Belirtilmezse adaptör varsayılanı (`0.52.1`) kullanılır. API anahtarı `commandcode` önayarı (`openai-chat` / `/provider/v1`) tarafından okunmaz. | +| `projectContext?` | `"off" \| "on"` | Yalnızca Command Code OAuth (`adapter: "command-code"`). `"on"` iken, proxy işlem çalışma dizinindeki sınırlı proje dosyalarını `/alpha/generate` `memory` / `taste` / `skills` zarfına kopyalar. Eksik veya `"off"` iken dosyalar diskte olsa bile boş zarf korunur. API anahtarı `commandcode` önayarı tarafından okunmaz. Üst düzeyde değil, `providers.command-code` üzerinde ayarlayın. Panoda **Providers → Command Code → Edit JSON** kullanın. `process.cwd()` hedeflediğiniz depo olsun diye proxy'yi güvenilir Codex proje dizininden başlatın. Fail-soft: eksik, okunamayan, zaman aşımı veya yol kaçışı o parçayı düşürür; tur başarısız olmaz. `~/.commandcode/skills` veya diğer ev skill ağaçlarını okumaz. `x-taste-learning` bu bayraktan bağımsız olarak `"false"` kalır. | API anahtarı sağlayıcıları değişmez bir anahtar veya bir ortam referansı tutabilir. OAuth sağlayıcıları `ocx login` tarafından doldurulan kimlik bilgisi @@ -326,8 +328,10 @@ Cursor sunucu güdümlü yerel araçlar varsayılan olarak devre dışıdır. Co onayı ve sanal alan politikasıyla `apply_patch` ve `exec_command` gibi kendi araçlarını kullanmaya devam eder: -- `"off"` (varsayılan), Cursor yerel `read`, `write`, `delete`, `ls`, `grep`, - `shell` ve `fetch` yürütmesini reddeder. +- `"off"` (varsayılan), proxy üzerinde Cursor yerel `read`, `write`, `delete`, `ls`, `grep`, + `shell` ve `fetch` yürütmesini reddeder. Tur bare Codex `shell_command` veya `exec_command` + duyurduğunda yerel Shell/Read/Ls/Grep/Fetch proxy yerine bu Codex köprüsüne eşlenir; write/delete + reddedilmeye devam eder. - `"on"`, güvenilen yerel yürütmeyi seçer ve Codex onay/sanal alan anlambilimini atlar. - `"codex-sandbox"` uyumluluk için tutulur ancak `"off"` gibi kapalı olarak diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 7ff618f09e0..2e73a0bd04d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -261,6 +261,8 @@ inference key 可从 [Vultr Console](https://my.vultr.com) 的订阅概览复制 **Command Code 配额:**仪表盘和 `ocx account refresh` 会在规范主机 `https://api.commandcode.ai` 上探测 `/alpha/billing/credits` 窗口(5 小时和每周)。OAuth 预设 (`command-code`) 使用已保存的账户 bearer;Provider-API 密钥预设 (`commandcode`) 使用当前配置的有效密钥。用户改写后的仿冒 base URL 不会被探测。当 Command Code 同时返回周期消耗时,剩余的 monthly / purchased / free credits 会显示为 USD 窗口。 +**Command Code 项目上下文。** 仅在 OAuth `command-code` 提供商上(不是 API 密钥 `commandcode` 预设)可选的 `projectContext: "on"` 会从代理工作目录填充 `/alpha/generate` 的 `memory`、`taste` 和 `skills`。通过 **Providers → Command Code → Edit JSON** 设置在 `providers.command-code` 上,从受信任的 Codex 项目启动代理,保存后重启。未设置或 `"off"` 时即使存在 `AGENTS.md` 或 taste 文件也保持空信封。文件路径、上限和 fail-soft 行为见 [Adapters](/zh-cn/reference/adapters/#command-code)。 + **SambaNova Cloud 发现:**该预设从固定 API 主机读取 SambaNova Cloud 的公开 `/v1/models` 列表,保留提供商原生 模型 id,并将发现限制为 128 KiB 和 128 条原始记录。该目录无需鉴权,因此 CLI 登录流程不会把公开响应 当作密钥有效性的证明,而会将密钥报告为无法验证。chat 请求仍使用已配置的 Bearer 密钥;由于 SambaNova diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index 522dfdb41c0..9aee6840654 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -28,6 +28,12 @@ OAuth 账户时使用 `anthropic`,否则使用 `openai`。显式选择 `anthro search 工具并强制生成最终答案。如果模型调用 `apply_patch` 或 shell 等真实客户端工具,当前 turn 会结束,以便这些调用到达 Codex。 +**Antigravity Gemini 3 例外:** 当路由提供方是 Google Antigravity(`google-antigravity`)且 +wire 模型为 Gemini 3.x 时,opencodex 会在**同一**主请求上附加 CCA `google_search`(若该 turn +含 `http(s)` URL 则再附加 `url_context`),而不是走合成 sidecar 循环。Claude-on-CCA、显式 +`openai` / `anthropic` / `xai` / `exa` 后端,以及同时带有 Codex 函数工具的 Gemini 3 以下型号 +仍走 sidecar。 + 路由模型的每次迭代都会向上游请求 `stream: true`,但 opencodex 会在决定搜索还是返回最终答案前, 在内部完整缓冲所有语义 event。只有第一次迭代的最终 header/status 和 429 key rotation 会被提前 取得。因此,合成搜索调用和中间输出不会作为模型输出暴露给客户端。 diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index e7749293981..cba0df3855d 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -146,7 +146,22 @@ Cursor 的 HTTP/1.1 兼容传输:通过 `agent.v1.AgentService/RunSSE` 接收 `effort` 和 `fast=true` 值放入 `requested_model.parameters`。 - Cursor 原生本地 filesystem/shell/network 执行默认被拒绝。显式 `mcpServers` 与 `desktopExecutor` 集成分别需要 opt-in;`unsafeAllowNativeLocalExec` 会启用更广泛的内置 - executor,并绕过 Codex 审批和 sandbox 语义。 + executor,并绕过 Codex 审批和 sandbox 语义。默认 `off` 下,当目录中有桥接工具时,原生 + Shell/Read/Ls/Grep/Fetch 会映射为 Codex `shell_command`/`exec_command` 调用;write/delete 仍被拒绝。 + +## `command-code` + +**目标:** Command Code **OAuth** 订阅 agent API(`POST {baseUrl}/alpha/generate`)。 +**认证:** 通过 `ocx login command-code` 的 OAuth Bearer。 + +- 与 API 密钥 `commandcode` 预设(`openai-chat` → `POST {baseUrl}/provider/v1/chat/completions`)不同。API 密钥路由从不读取 `projectContext`,也不会从磁盘填充 generate 信封。 +- 在 `providers.command-code` 上可选的 `projectContext: "on"` 会在请求时从 `process.cwd()` 复制有界文件到 `memory`、`taste` 和 `skills`。未设置或 `"off"` 时即使仓库中已有这些文件,也发送 `memory: ""`、`taste: null`、`skills: null` — 仅 opt-in,不会自动加载。 +- 请从受信任的 Codex 项目目录启动代理,使工作目录与 Codex 正在编辑的仓库一致。 +- **Memory:** 仅 cwd 下的 `AGENTS.md` UTF-8(不是 `CLAUDE.md`、`CODEX.md` 或主目录路径)。上限 32,768 字节;超限时前缀截断并附加 ``。 +- **Taste:** `.commandcode/taste/taste.md` 的 UTF-8,缺失时为 `null`。上限 8,192 字节,使用相同截断标记。存在但为空的文件发送 `""`。`x-taste-learning` 保持 `"false"`;加载 taste 不是 Command Code 的 taste learning。 +- **Skills:** 按顺序从项目 skill 根生成 XML:`.commandcode/skills`、`.agents/skills`、`.pi/skills`。每个含 `SKILL.md` 的子目录成为一个 `…` 条目(名称来自 YAML frontmatter 的 `name:` 或目录名)。跳过以 `.` 开头的名称和非目录;按解析名 first-wins;最多 16 个 skill;XML 总上限 32,768 字节。从不读取 `~/.commandcode/skills` 或其他主目录 skill 树。 +- 路径限制使用 cwd 下的 realpath 检查;符号链接逃逸会被省略。每个文件操作 2 秒超时。结果按 cwd 缓存 30 秒(最多 128 条)。任何失败都会 fail-soft 地省略该部分。 +- `commandCodeVersion` 固定 `x-command-code-version`(默认 `0.52.1`)。`permissionMode` 保持 `"standard"`,`mode` 保持 `"agent"`。 ## `azure-openai`(别名:`azure`) 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 1564842cbb7..09007cea559 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 @@ -115,6 +115,8 @@ selector,而不是分配一个新名称。 | `desktopExecutor?` | `DesktopExecutorConfig` | 仅 Cursor:外部 computer-use 和录屏命令。 | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本地执行策略。`off` 是默认值;`codex-sandbox` 目前会像 `off` 一样失败关闭。 | +| `commandCodeVersion?` | `string` | 仅 Command Code OAuth(`adapter: "command-code"`)。固定 `/alpha/generate` 请求的 `x-command-code-version` 头。未设置时使用适配器默认值(`0.52.1`)。API 密钥 `commandcode` 预设(`openai-chat` / `/provider/v1`)不会读取。 | +| `projectContext?` | `"off" \| "on"` | 仅 Command Code OAuth(`adapter: "command-code"`)。为 `"on"` 时,从代理进程工作目录复制有界项目文件到 `/alpha/generate` 的 `memory` / `taste` / `skills` 信封。未设置或 `"off"` 时保持空信封,即使磁盘上已有这些文件。API 密钥 `commandcode` 预设不会读取。请设置在 `providers.command-code` 上,而非顶层。在仪表板使用 **Providers → Command Code → Edit JSON**。请从受信任的 Codex 项目目录启动代理,使 `process.cwd()` 指向目标仓库。fail-soft:缺失、不可读、超时或路径逃逸会省略该部分,而不会让整个回合失败。不会读取 `~/.commandcode/skills` 或其他主目录 skill 树。无论此标志如何,`x-taste-learning` 保持 `"false"`。 | API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者使用由 `ocx login` 填充的凭据存储;基于订阅的 Claude Code 启动行为在 [`claudeCode.authMode`](/reference/configuration/server/#claude-code) 下配置。 @@ -242,7 +244,8 @@ Cursor Router 的优化层级会作为独立的 Codex id 暴露,因为选择 Cursor 由服务端驱动的本地工具默认是禁用的。Codex 继续使用自己的工具,例如 `apply_patch` 和 `exec_command`,并沿用自己的审批与沙箱策略: -- `"off"`(默认)会拒绝执行 Cursor 原生的 `read`、`write`、`delete`、`ls`、`grep`、`shell` 和 `fetch`。 +- `"off"`(默认)拒绝在 proxy 上执行 Cursor 原生的 `read`、`write`、`delete`、`ls`、`grep`、`shell` 和 + `fetch`。当 turn 公布了 bare Codex `shell_command` 或 `exec_command` 时,原生 Shell/Read/Ls/Grep/Fetch 会映射到该 Codex shell 桥接工具,而不是在 proxy 上运行;write/delete 仍被拒绝。 - `"on"` 会启用受信任的本地执行,并绕过 Codex 的审批/沙箱语义。 - `"codex-sandbox"` 为兼容性保留,但会像 `"off"` 一样失败关闭;请求文案并不是可信的沙箱证明。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 110b2fd9a98..1aec7c44d54 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -343,6 +343,8 @@ endpoint 取得。Chat request 使用設定的 Bearer key。可在 **Command Code 配額。** 儀表板與 `ocx account refresh` 會在正規主機 `https://api.commandcode.ai` 探測 `/alpha/billing/credits` 視窗(5 小時與每週)。OAuth preset (`command-code`) 使用已儲存的帳號 bearer;Provider-API key preset (`commandcode`) 使用目前設定的有效 key。使用者改寫過的仿冒 base URL 不會被探測。當 Command Code 同時回報週期消耗時,剩餘的 monthly / purchased / free credits 會顯示為 USD 視窗。 +**Command Code 專案上下文。** 僅在 OAuth `command-code` 供應商上(不是 API 金鑰 `commandcode` 預設)可選的 `projectContext: "on"` 會從代理工作目錄填充 `/alpha/generate` 的 `memory`、`taste` 和 `skills`。請透過 **Providers → Command Code → Edit JSON** 設在 `providers.command-code` 上,從受信任的 Codex 專案啟動代理,儲存後重新啟動。未設定或 `"off"` 時即使已有 `AGENTS.md` 或 taste 檔案也保持空信封。檔案路徑、上限與 fail-soft 行為見 [Adapters](/zh-tw/reference/adapters/#command-code)。 + **SambaNova Cloud 探索。** preset 從固定 API host 讀取 SambaNova Cloud 公開的 `/v1/models` 列表,保留 provider-native id,並把 discovery 限制在 128 KiB/128 個 raw row。因 catalog 不需要認證,CLI login 流程會把 key 回報為 unverifiable,而不會把公開 response 當成有效 key 的證明。Chat request 仍使用 diff --git a/docs-site/src/content/docs/zh-tw/guides/sidecars.md b/docs-site/src/content/docs/zh-tw/guides/sidecars.md index 1ffe8079614..19f072166de 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -28,6 +28,12 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro search 工具並強制生成最終答案。如果模型呼叫 `apply_patch` 或 shell 等真實用戶端工具,目前 turn 會結束,以便這些呼叫到達 Codex。 +**Antigravity Gemini 3 例外:** 當路由提供方是 Google Antigravity(`google-antigravity`)且 +wire 模型為 Gemini 3.x 時,opencodex 會在**同一**主請求上附加 CCA `google_search`(若該 turn +含 `http(s)` URL 再附加 `url_context`),而不是走合成 sidecar 迴圈。Claude-on-CCA、顯式 +`openai` / `anthropic` / `xai` / `exa` 後端,以及同時帶有 Codex 函數工具的 Gemini 3 以下型號 +仍走 sidecar。 + 路由模型的每次迭代都會向上遊請求 `stream: true`,但 opencodex 會在決定搜尋還是回傳最終答案前, 在內部完整緩衝所有語義 event。只有第一次迭代的最終 header/status 和 429 key rotation 會被提前 取得。因此,合成搜尋呼叫和中間輸出不會作為模型輸出暴露給用戶端。 diff --git a/docs-site/src/content/docs/zh-tw/reference/adapters.md b/docs-site/src/content/docs/zh-tw/reference/adapters.md index ead8331ddd8..d17044b8cb5 100644 --- a/docs-site/src/content/docs/zh-tw/reference/adapters.md +++ b/docs-site/src/content/docs/zh-tw/reference/adapters.md @@ -124,7 +124,22 @@ Kiro 的 assistant 文字本身沒有可靠的回合結束標記,但終止的 `GetUsableModels` RPC 發現即時 Cursor 模型,並且只在 run request 尚未 commit 到 wire 前重試。 - Cursor 原生本機 filesystem/shell/network 執行預設被拒絕。顯式 `mcpServers` 與 `desktopExecutor` 整合分別需要 opt-in;`unsafeAllowNativeLocalExec` 會啟用更廣泛的內建 - executor,並繞過 Codex 審批和 sandbox 語義。 + executor,並繞過 Codex 審批和 sandbox 語義。預設 `off` 下,當目錄中有橋接工具時,原生 + Shell/Read/Ls/Grep/Fetch 會映射為 Codex `shell_command`/`exec_command` 呼叫;write/delete 仍被拒絕。 + +## `command-code` + +**目標:** Command Code **OAuth** 訂閱 agent API(`POST {baseUrl}/alpha/generate`)。 +**認證:** 透過 `ocx login command-code` 的 OAuth Bearer。 + +- 與 API 金鑰 `commandcode` 預設(`openai-chat` → `POST {baseUrl}/provider/v1/chat/completions`)不同。API 金鑰路由不會讀取 `projectContext`,也不會從磁碟填充 generate 信封。 +- 在 `providers.command-code` 上可選的 `projectContext: "on"` 會在請求時從 `process.cwd()` 複製有界檔案到 `memory`、`taste` 和 `skills`。未設定或 `"off"` 時,即使儲存庫中已有這些檔案,仍傳送 `memory: ""`、`taste: null`、`skills: null` — 僅 opt-in,不會自動載入。 +- 請從受信任的 Codex 專案目錄啟動代理,使工作目錄與 Codex 正在編輯的儲存庫一致。 +- **Memory:** 僅 cwd 下的 `AGENTS.md` UTF-8(不是 `CLAUDE.md`、`CODEX.md` 或主目錄路徑)。上限 32,768 位元組;超限時以前綴截斷並附加 ``。 +- **Taste:** `.commandcode/taste/taste.md` 的 UTF-8,缺失時為 `null`。上限 8,192 位元組,使用相同截斷標記。存在但為空的檔案傳送 `""`。`x-taste-learning` 保持 `"false"`;載入 taste 不是 Command Code 的 taste learning。 +- **Skills:** 依序從專案 skill 根產生 XML:`.commandcode/skills`、`.agents/skills`、`.pi/skills`。每個含 `SKILL.md` 的子目錄成為一個 `…` 項目(名稱來自 YAML frontmatter 的 `name:` 或目錄名)。跳過以 `.` 開頭的名稱與非目錄;依解析名 first-wins;最多 16 個 skill;XML 總上限 32,768 位元組。不會讀取 `~/.commandcode/skills` 或其他主目錄 skill 樹。 +- 路徑限制使用 cwd 下的 realpath 檢查;符號連結逸出會被省略。每個檔案操作 2 秒逾時。結果按 cwd 快取 30 秒(最多 128 筆)。任何失敗都會 fail-soft 地省略該部分。 +- `commandCodeVersion` 固定 `x-command-code-version`(預設 `0.52.1`)。`permissionMode` 保持 `"standard"`,`mode` 保持 `"agent"`。 ## `azure-openai`(別名:`azure`) 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 f47b5bef059..5959d0513d5 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 @@ -90,6 +90,8 @@ description: 供應商項目、認證、端點、模型目錄、配額、context | `desktopExecutor?` | `DesktopExecutorConfig` | 僅 Cursor:外部 computer-use 與 record-screen 指令。 | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor 本機執行政策。`off` 為預設;`codex-sandbox` 目前像 `off` 般 fail closed。 | +| `commandCodeVersion?` | `string` | 僅 Command Code OAuth(`adapter: "command-code"`)。固定 `/alpha/generate` 請求的 `x-command-code-version` 標頭。未設定時使用適配器預設值(`0.52.1`)。API 金鑰 `commandcode` 預設(`openai-chat` / `/provider/v1`)不會讀取。 | +| `projectContext?` | `"off" \| "on"` | 僅 Command Code OAuth(`adapter: "command-code"`)。為 `"on"` 時,從代理程序工作目錄複製有界專案檔案到 `/alpha/generate` 的 `memory` / `taste` / `skills` 信封。未設定或 `"off"` 時保持空信封,即使磁碟上已有這些檔案。API 金鑰 `commandcode` 預設不會讀取。請設在 `providers.command-code` 上,而非頂層。在儀表板使用 **Providers → Command Code → Edit JSON**。請從受信任的 Codex 專案目錄啟動代理,使 `process.cwd()` 指向目標儲存庫。fail-soft:缺失、不可讀、逾時或路徑逸出會省略該部分,而不會讓整個回合失敗。不會讀取 `~/.commandcode/skills` 或其他主目錄 skill 樹。無論此旗標如何,`x-taste-learning` 保持 `"false"`。 | API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用由 `ocx login` 填入的憑證存放;訂閱支援的 Claude Code 啟動行為在 [`claudeCode.authMode`](/zh-tw/reference/configuration/server/#claude-code) 下設定。 @@ -208,8 +210,8 @@ Cursor Router 的最佳化階梯以獨立的 Codex id 暴露,因為 picker 無 Cursor 伺服器驅動的本機工具預設停用。Codex 繼續使用其自身工具如 `apply_patch` 與 `exec_command` 及其自身的核准與沙箱政策: -- `"off"`(預設)拒絕 Cursor 原生的 `read`、`write`、`delete`、`ls`、`grep`、`shell` 與 - `fetch` 執行。 +- `"off"`(預設)拒絕在 proxy 上執行 Cursor 原生的 `read`、`write`、`delete`、`ls`、`grep`、`shell` 與 + `fetch`。當 turn 公布了 bare Codex `shell_command` 或 `exec_command` 時,原生 Shell/Read/Ls/Grep/Fetch 會映射到該 Codex shell 橋接工具,而不是在 proxy 上執行;write/delete 仍被拒絕。 - `"on"` 選擇加入受信任的本機執行並繞過 Codex 核准/沙箱語意。 - `"codex-sandbox"` 為相容性而保留,但像 `"off"` 般 fail closed;請求文字不是可信的沙箱證明。 diff --git a/docs/superpowers/specs/2026-08-23-cursor-native-exec-bridge-design.md b/docs/superpowers/specs/2026-08-23-cursor-native-exec-bridge-design.md new file mode 100644 index 00000000000..f17c63efd90 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-cursor-native-exec-bridge-design.md @@ -0,0 +1,82 @@ +# Cursor Native Exec Bridge Design + +## Problem + +When `nativeLocalExec` is not `"on"`, Cursor server-driven native exec frames (`shell`, `read`, `ls`, `grep`, `fetch`, and related shell variants) fall through to `handleCursorNativeExec`, which writes policy-rejection native results containing `#604` retry prose (`nativeShellDisabledMessage()`). Cursor continues the same run waiting for a result that already arrived, and the model often retries native tools instead of emitting a Codex `shell_command` or `exec_command` call. + +The MCP Responses bridge already does the correct thing for client catalog tools: emit `tool_call_start` / `tool_call_delta` / `tool_call_end`, arm `scheduleClientToolFinalize`, and cancel the Cursor run without writing fake `mcpResult` / `shellResult` frames. Native execs that map to a one-shot shell command should reuse that same path when a bare Codex shell bridge tool is advertised on the turn. + +## Architecture + +- New pure mapper `src/adapters/cursor/native-exec-bridge.ts` exports `planNativeExecBridge`, `advertisedBareCodexShellBridgeName`, and `nativeExecBridgeToMcpExec`. +- `live-transport.ts` intercepts native exec in `handleServerMessage` after the existing `mcpArgs` / `planMcpArgsHandling` block and before `local_side_effect` / `handleCursorNativeExec`. +- On bridge hit: synthesize `mcpArgs` with `providerIdentifier: "opencodex-responses"`, call `planMcpArgsHandling`, then `noteClientToolActivity`, push events, and `cancelCursorRun` or `scheduleClientToolFinalize`. Return without Connect writes and without marking `local_side_effect`. +- On miss: unchanged policy path through `handleCursorNativeExec`. + +```mermaid +flowchart TD + execFrame[execServerMessage] + mcpCheck{mcpArgs and opencodex-responses?} + mcpPlan[planMcpArgsHandling] + nativeOn{nativeLocalExec on?} + localExec[handleCursorNativeExec] + bridgePlan[planNativeExecBridge] + synth[synthetic mcpArgs] + finalize[scheduleClientToolFinalize then cancelCursorRun] + policy[policy rejection frames] + + execFrame --> mcpCheck + mcpCheck -->|yes| mcpPlan --> finalize + mcpCheck -->|no| nativeOn + nativeOn -->|yes| localExec + nativeOn -->|no| bridgePlan + bridgePlan -->|bridge| synth --> mcpPlan + bridgePlan -->|no bridge| localExec --> policy +``` + +## Mapper table + +Bridge fires only when all of the following hold: + +- `nativeLocalExecEnabled` is false (`cursorUnsafeNativeLocalExecEnabled` false; covers unset, `"off"`, and `"codex-sandbox"`). +- `advertisedShellBridgeName` is a bare catalog name from `CODEX_SHELL_BRIDGE_TOOL_NAMES` (`exec_command` then `shell_command`), taken from `state.clientToolNames` via `normalizeCursorWireName`. Code-mode turns that only expose freeform `exec` do not bridge. +- Exec case is one of: `shellArgs`, `shellStreamArgs`, `backgroundShellSpawnArgs`, `readArgs`, `lsArgs`, `grepArgs`, `fetchArgs`. +- Mapped `command` is non-empty after trim. + +Arg mapping: + +- `shellArgs` / `shellStreamArgs` / `backgroundShellSpawnArgs`: pass `command` through; copy non-empty `workingDirectory` to `workdir`; drop stream/background semantics. +- `readArgs`: `cat -- ${posixSingleQuote(path)}` +- `lsArgs`: `ls -la -- ${posixSingleQuote(path)}` +- `grepArgs`: `rg -n -- ${posixSingleQuote(pattern)}` plus quoted path when present; ignore glob/context/multiline in this slice. +- `fetchArgs`: `curl -fsSL -- ${posixSingleQuote(url)}` +- `writeArgs`, `deleteArgs`, `writeShellStdinArgs`, computer-use, screen, diagnostics, unknown cases, or empty command/path/url: `{ bridge: false }`. + +`posixSingleQuote` wraps in single quotes and replaces `'` with `'\''`. + +Synthetic MCP exec matches existing `execMcpArgs` test shape: `providerIdentifier: "opencodex-responses"`, `name` / `toolName` from the advertised bridge name, `toolCallId` from native args or `exec_${execMsg.id}`, and UTF-8 JSON bytes per arg key (`command`, optional `workdir`). + +## Turn ending + +Reuse the MCP client-tool finalize path exactly. Parallel native execs in one receive window share `CLIENT_TOOL_FINALIZE_GRACE_MS` (and `clientToolFinalizeGraceMsForRequest` expansion). `finalizeAfterDrain` still no-ops if `openToolCalls` reopened. Never write `shellResult`, `readResult`, `lsResult`, `grepResult`, `fetchResult`, or `mcpResult` on a bridged exec. + +Continuation is the next `/v1/responses` with `function_call_output` (existing 363 path), not a same-stream native result. + +## Tests + +- Pure mapper tests in `tests/cursor-native-exec-bridge.test.ts` cover shell/read/ls/grep/fetch mapping, quoting, catalog preference, policy gates, and write/delete refusal. +- Live-transport harness tests prove bridged native exec emits `tool_call_*` events, does not `stream.write` Connect payloads, and does not surface `#604` text; `nativeLocalExec: "on"` and `writeArgs` stay on the native policy path. +- Existing MCP and native-exec regression files must stay green. + +## Out of scope + +- Mapping write/delete, computer-use, screen, diagnostics, or `writeShellStdinArgs` +- Bridging into code-mode nested `tools.exec(...)` +- Grep glob/context flags, ls ignore list, fetch headers +- Changing `nativeLocalExec` default, max mode, server webSearch/Exa auto-approve +- Antigravity in-turn search, Command Code memory/skills +- New operator config flags + +## Windows / POSIX risk + +Read/ls/grep/fetch templates use POSIX shell quoting and commands (`cat`, `ls`, `rg`, `curl`). The bridge passes native `shellArgs.command` through unchanged. Codex executes on the client host shell; opencodex does not guess the client OS. Operators on Windows clients must rely on Codex-side adaptation (already documented in `#604` prose for the no-bridge fallthrough). diff --git a/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts new file mode 100644 index 00000000000..40a308c97fd --- /dev/null +++ b/src/adapters/command-code-project-context.ts @@ -0,0 +1,377 @@ +import { existsSync, realpathSync } from "node:fs"; +import { open, opendir } from "node:fs/promises"; +import { join, sep } from "node:path"; + +export type CommandCodeProjectContext = { + memory: string; + taste: string | null; + skills: string | null; +}; + +export const EMPTY_COMMAND_CODE_PROJECT_CONTEXT: CommandCodeProjectContext = { + memory: "", + taste: null, + skills: null, +}; + +const MEMORY_CAP_BYTES = 32_768; +const TASTE_CAP_BYTES = 8_192; +const SKILLS_XML_CAP_BYTES = 32_768; +const MAX_SKILLS = 16; +// Upper bound on how many candidate skill directories one root may enumerate before +// stopping. Realistic projects have far fewer; this bounds pathological/hostile +// directories (millions of entries) to a finite scan while preserving alphabetical +// selection for any realistic case (≤ MAX_SKILL_DIRS_TO_SCAN valid dirs per root). +const MAX_SKILL_DIRS_TO_SCAN = 256; +const FILE_OP_TIMEOUT_MS = 2_000; +const PROJECT_CONTEXT_TTL_MS = 30_000; +const MAX_PROJECT_CONTEXT_CACHE_ENTRIES = 128; + +const TRUNCATION_MARKER = "\n"; + +const SKILL_ROOTS = [ + ".commandcode/skills", + ".agents/skills", + ".pi/skills", +] as const; + +export const projectContextCache = new Map(); + +/** + * Evict expired entries first, then the oldest live entry if at capacity. + * Called before inserting a new key so the cache never exceeds the cap. + */ +function pruneExpiredProjectContextCache(now: number): void { + for (const [key, entry] of projectContextCache) { + if (now - entry.collectedAt >= PROJECT_CONTEXT_TTL_MS) { + projectContextCache.delete(key); + } + } +} + +export function pruneProjectContextCache(now: number): void { + pruneExpiredProjectContextCache(now); + if (projectContextCache.size >= MAX_PROJECT_CONTEXT_CACHE_ENTRIES) { + let oldestKey: string | null = null; + let oldestAt = Infinity; + for (const [key, entry] of projectContextCache) { + if (entry.collectedAt < oldestAt) { + oldestAt = entry.collectedAt; + oldestKey = key; + } + } + if (oldestKey !== null) projectContextCache.delete(oldestKey); + } +} + +/** Fail-soft canonical path; returns null when the path does not exist or cannot be resolved. */ +function canonicalPath(candidate: string): string | null { + if (!existsSync(candidate)) return null; + try { + return realpathSync.native(candidate); + } catch { + return null; + } +} + +function normalizePathIdentity(path: string): string { + return process.platform === "win32" ? path.toLowerCase() : path; +} + +function confinedCanonicalPath(filePath: string, cwdCanonical: string): string | null { + const fileCanonical = canonicalPath(filePath); + if (!fileCanonical) return null; + const fileId = normalizePathIdentity(fileCanonical); + const cwdId = normalizePathIdentity(cwdCanonical); + if (fileId === cwdId || fileId.startsWith(cwdId + sep)) return fileCanonical; + return null; +} + +async function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timeout")), ms); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +function truncateUtf8(text: string, capBytes: number): string { + const buf = Buffer.from(text, "utf8"); + if (buf.length <= capBytes) return text; + const markerBuf = Buffer.from(TRUNCATION_MARKER, "utf8"); + const prefixCap = capBytes - markerBuf.length; + if (prefixCap <= 0) return TRUNCATION_MARKER.slice(0, capBytes); + let end = prefixCap; + while (end > 0 && (buf[end]! & 0xc0) === 0x80) end--; + return buf.subarray(0, end).toString("utf8") + TRUNCATION_MARKER; +} + +async function readUtf8File(path: string, capBytes: number): Promise { + type FileHandle = Awaited>; + let fileHandle: FileHandle | undefined; + const closedHandles = new WeakSet(); + const opened = open(path, "r"); + const closeBestEffort = (handle: FileHandle): Promise => { + if (closedHandles.has(handle)) return Promise.resolve(); + closedHandles.add(handle); + return Promise.resolve() + .then(() => handle.close()) + .catch(() => { + /* closing a timed-out read is best-effort */ + }); + }; + + const read = (async () => { + const handle = await opened; + fileHandle = handle; + try { + const data = Buffer.alloc(capBytes + 1); + const { bytesRead } = await handle.read(data, 0, data.length, 0); + return data.subarray(0, bytesRead).toString("utf8"); + } finally { + await closeBestEffort(handle); + if (fileHandle === handle) fileHandle = undefined; + } + })(); + + try { + return await withTimeout(read, FILE_OP_TIMEOUT_MS); + } catch { + if (fileHandle) void closeBestEffort(fileHandle); + void opened.then(handle => closeBestEffort(handle), () => undefined); + return null; + } +} + +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function parseSkillFrontmatter(text: string): { name: string | null; body: string } { + const opening = text.startsWith("---\r\n") ? "---\r\n" : text.startsWith("---\n") ? "---\n" : null; + if (!opening) return { name: null, body: text }; + const closing = text.slice(opening.length).match(/^---(?:\r?\n|$)/m); + if (!closing || closing.index === undefined) return { name: null, body: text }; + const end = opening.length + closing.index; + const frontmatter = text.slice(opening.length, end).replace(/\r?\n$/, ""); + let name: string | null = null; + for (const line of frontmatter.split(/\r?\n/)) { + const match = line.match(/^name:\s*(.+)$/); + if (match) { + const parsed = match[1]!.trim(); + if (parsed.length > 0) name = parsed; + break; + } + } + const bodyStart = end + closing[0].length; + const body = text.slice(bodyStart); + return { name, body }; +} + +async function readMemory(cwd: string, cwdCanonical: string): Promise { + const path = join(cwd, "AGENTS.md"); + const canonical = confinedCanonicalPath(path, cwdCanonical); + if (!canonical) return ""; + const text = await readUtf8File(canonical, MEMORY_CAP_BYTES); + if (text === null) return ""; + return truncateUtf8(text, MEMORY_CAP_BYTES); +} + +async function readTaste(cwd: string, cwdCanonical: string): Promise { + const path = join(cwd, ".commandcode", "taste", "taste.md"); + const canonical = confinedCanonicalPath(path, cwdCanonical); + if (!canonical) return null; + if (!existsSync(canonical)) return null; + const text = await readUtf8File(canonical, TASTE_CAP_BYTES); + if (text === null) return null; + return truncateUtf8(text, TASTE_CAP_BYTES); +} + +interface SkillEntry { + name: string; + body: string; +} + +async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget: number): Promise { + if (scanBudget <= 0) return []; + const skillRootCanonical = confinedCanonicalPath(skillRoot, cwdCanonical); + if (!skillRootCanonical) return []; + let dir: Awaited> | undefined; + try { + return await withTimeout( + (async () => { + const openedDir = await opendir(skillRootCanonical); + dir = openedDir; + const names: string[] = []; + try { + for await (const entry of openedDir) { + if (entry.name.startsWith(".")) continue; + if (!entry.isDirectory()) continue; + const skillMd = join(skillRoot, entry.name, "SKILL.md"); + const skillMdCanonical = confinedCanonicalPath(skillMd, cwdCanonical); + if (!skillMdCanonical) continue; + if (!existsSync(skillMdCanonical)) continue; + names.push(entry.name); + if (names.length >= scanBudget) break; + } + } catch { + try { + await openedDir.close(); + } catch { + /* closing a failed iterator is best-effort */ + } + /* directory iteration is best-effort */ + } + names.sort(); + return names; + })(), + FILE_OP_TIMEOUT_MS, + ); + } catch { + if (dir) { + try { + await dir.close(); + } catch { + /* closing a timed-out iterator is best-effort */ + } + } + return []; + } +} + +async function readSkill(skillRoot: string, dirName: string, cwdCanonical: string): Promise { + const path = join(skillRoot, dirName, "SKILL.md"); + const canonical = confinedCanonicalPath(path, cwdCanonical); + if (!canonical) return null; + const text = await readUtf8File(canonical, SKILLS_XML_CAP_BYTES); + if (text === null) return null; + const { name, body } = parseSkillFrontmatter(text); + return { name: name ?? dirName, body }; +} + +function buildSkillsXml(skills: SkillEntry[]): string | null { + if (skills.length === 0) return null; + const lines = [""]; + let usedBytes = Buffer.byteLength(lines[0]! + "\n", "utf8"); + + for (const skill of skills) { + const open = ` `; + const close = ""; + let body = skill.body; + let line = `${open}${xmlEscape(body)}${close}`; + let lineBytes = Buffer.byteLength(line + "\n", "utf8"); + + if (usedBytes + lineBytes > SKILLS_XML_CAP_BYTES) { + const overhead = Buffer.byteLength(open + close + "\n", "utf8"); + const bodyBudget = SKILLS_XML_CAP_BYTES - usedBytes - overhead; + if (bodyBudget <= 0) break; + const fittedBody = truncateUtf8BodyForXml(body, bodyBudget); + if (fittedBody === null) break; + const wasTruncated = fittedBody !== body; + body = fittedBody; + line = `${open}${xmlEscape(body)}${close}`; + lineBytes = Buffer.byteLength(line + "\n", "utf8"); + if (usedBytes + lineBytes > SKILLS_XML_CAP_BYTES) break; + lines.push(line); + usedBytes += lineBytes; + if (wasTruncated) break; + continue; + } + + lines.push(line); + usedBytes += lineBytes; + } + + lines.push(""); + if (lines.length === 2) return null; + return lines.join("\n"); +} + +function truncateUtf8BodyForXml(body: string, capBytes: number): string | null { + const rawBuf = Buffer.from(body, "utf8"); + if (Buffer.byteLength(xmlEscape(body), "utf8") <= capBytes) return body; + if (Buffer.byteLength(xmlEscape(TRUNCATION_MARKER), "utf8") > capBytes) return null; + + // XML entities can expand a raw body by several bytes per character. Binary-search the + // largest UTF-8 prefix whose escaped form, including the marker, fits the actual wire cap. + let low = 0; + let high = rawBuf.length; + let best = TRUNCATION_MARKER; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + let rawEnd = mid; + while (rawEnd > 0 && (rawBuf[rawEnd]! & 0xc0) === 0x80) rawEnd--; + const candidate = rawBuf.subarray(0, rawEnd).toString("utf8") + TRUNCATION_MARKER; + if (Buffer.byteLength(xmlEscape(candidate), "utf8") <= capBytes) { + best = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + return best; +} + +async function readSkills(cwd: string, cwdCanonical: string): Promise { + const seen = new Set(); + const collected: SkillEntry[] = []; + + for (const rootRel of SKILL_ROOTS) { + const skillRoot = join(cwd, ...rootRel.split("/")); + const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN); + for (const dirName of dirs) { + if (collected.length >= MAX_SKILLS) break; + const skill = await readSkill(skillRoot, dirName, cwdCanonical); + if (!skill) continue; + if (seen.has(skill.name)) continue; + seen.add(skill.name); + collected.push(skill); + } + if (collected.length >= MAX_SKILLS) break; + } + + return buildSkillsXml(collected); +} + +async function collectProjectContext(cwd: string): Promise { + const cwdCanonical = canonicalPath(cwd); + if (!cwdCanonical) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT }; + + const [memory, taste, skills] = await Promise.all([ + readMemory(cwd, cwdCanonical), + readTaste(cwd, cwdCanonical), + readSkills(cwd, cwdCanonical), + ]); + + return { memory, taste, skills }; +} + +export async function loadCommandCodeProjectContext(cwd: string | undefined): Promise { + if (!cwd) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT }; + + const hadCachedEntry = projectContextCache.has(cwd); + const cached = projectContextCache.get(cwd); + if (cached && Date.now() - cached.collectedAt < PROJECT_CONTEXT_TTL_MS) { + return cached.value; + } + + const value = await collectProjectContext(cwd); + const now = Date.now(); + if (hadCachedEntry) { + pruneExpiredProjectContextCache(now); + } else { + pruneProjectContextCache(now); + } + projectContextCache.set(cwd, { collectedAt: now, value }); + return value; +} diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 8f2edbdb553..09352728bd5 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -12,6 +12,7 @@ import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from import { identifyRoutedModel } from "./identity"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { parseDataUrl } from "./image"; +import { EMPTY_COMMAND_CODE_PROJECT_CONTEXT, loadCommandCodeProjectContext } from "./command-code-project-context"; // Retain the short ids emitted by the first local integration. New requests use the live catalog's // provider-native IDs directly; this map is compatibility-only and is not a model fallback list. @@ -463,8 +464,11 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA ...(choiceInstruction ? [choiceInstruction] : []), ].join("\n\n"), parsed.modelId); const reasoningEffort = supportedCommandCodeEffort(provider, parsed.modelId, parsed.options.reasoning); + const projectContext = provider.projectContext === "on" + ? await loadCommandCodeProjectContext(cwd) + : EMPTY_COMMAND_CODE_PROJECT_CONTEXT; const body = { - config: await commandCodeConfig(cwd), memory: "", taste: null, skills: null, + config: await commandCodeConfig(cwd), ...projectContext, permissionMode: "standard", mode: "agent", params: { model: canonicalCommandCodeModelId(parsed.modelId), diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index ad48ec67134..b5d479ed6a7 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -55,12 +55,18 @@ import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, i import { mcpArgsFromToolCall } from "./protobuf-events"; import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions"; import { + cursorUnsafeNativeLocalExecEnabled, handleCursorNativeExec, handleCursorNativeKv, releaseCursorBlobRequestScope, type CursorBlobRequestScopeToken, type CursorNativeExecContext, } from "./native-exec"; +import { + advertisedBareCodexShellBridgeName, + nativeExecBridgeToMcpExec, + planNativeExecBridge, +} from "./native-exec-bridge"; import { effectiveCursorNativeExecAllow } from "./exec-policy"; import { resolveMcpServers } from "./mcp-config"; import { CursorMcpManager } from "./mcp-manager"; @@ -1448,6 +1454,21 @@ class LiveCursorTransport implements CursorTransport { return; } } + const advertisedShellBridgeName = advertisedBareCodexShellBridgeName(state.clientToolNames ?? []); + const nativePlan = planNativeExecBridge(execMsg, { + nativeLocalExecEnabled: cursorUnsafeNativeLocalExecEnabled(this.execContext), + advertisedShellBridgeName, + }); + if (nativePlan.bridge) { + const plan = planMcpArgsHandling(nativeExecBridgeToMcpExec(execMsg, nativePlan), state); + if (plan.handledByResponsesBridge) { + this.noteClientToolActivity(); + for (const event of plan.events) push(event); + if (plan.cancelCursorRun) this.cancelCursorRun(); + else if (plan.finalizeWhenDrained) this.scheduleClientToolFinalize(state, push); + return; + } + } // Native exec/MCP is handled inside this transport and can mutate files/process state // without emitting a Responses tool event. Mark the turn replay-unsafe before executing so // an eventual invalid_argument cannot cause the adapter's fresh-conversation fallback to diff --git a/src/adapters/cursor/native-exec-bridge.ts b/src/adapters/cursor/native-exec-bridge.ts new file mode 100644 index 00000000000..e6f3acd842a --- /dev/null +++ b/src/adapters/cursor/native-exec-bridge.ts @@ -0,0 +1,141 @@ +import { create } from "@bufbuild/protobuf"; +import { + ExecServerMessageSchema, + McpArgsSchema, + type ExecServerMessage, +} from "./gen/agent_pb"; +import { + CODEX_SHELL_BRIDGE_TOOL_NAMES, + isCodexShellBridgeToolName, + normalizeCursorWireName, + OCX_RESPONSES_TOOL_PROVIDER, +} from "./tool-definitions"; + +export type NativeExecBridgePlan = + | { bridge: false } + | { + bridge: true; + toolName: string; + toolCallId: string; + args: { command: string; workdir?: string }; + }; + +export function posixSingleQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export function advertisedBareCodexShellBridgeName(clientToolNames: Iterable): string | undefined { + const advertised = new Set( + [...clientToolNames] + .map(normalizeCursorWireName) + .filter(isCodexShellBridgeToolName), + ); + for (const bridgeName of CODEX_SHELL_BRIDGE_TOOL_NAMES) { + if (advertised.has(bridgeName)) return bridgeName; + } + return undefined; +} + +export function planNativeExecBridge( + execMsg: ExecServerMessage, + opts: { nativeLocalExecEnabled: boolean; advertisedShellBridgeName?: string }, +): NativeExecBridgePlan { + if (opts.nativeLocalExecEnabled || !opts.advertisedShellBridgeName) return { bridge: false }; + + const execCase = execMsg.message.case; + if (!execCase) return { bridge: false }; + + let toolCallId = `exec_${execMsg.id}`; + let command = ""; + let workdir: string | undefined; + + switch (execCase) { + case "shellArgs": + case "shellStreamArgs": { + const args = execMsg.message.value; + command = args.command.trim(); + if (args.workingDirectory.trim()) workdir = args.workingDirectory.trim(); + if (args.toolCallId) toolCallId = args.toolCallId; + break; + } + case "backgroundShellSpawnArgs": { + const args = execMsg.message.value; + command = args.command.trim(); + if (args.workingDirectory.trim()) workdir = args.workingDirectory.trim(); + if (args.toolCallId) toolCallId = args.toolCallId; + break; + } + case "readArgs": { + const args = execMsg.message.value; + const path = args.path.trim(); + if (!path) return { bridge: false }; + command = `cat -- ${posixSingleQuote(path)}`; + if (args.toolCallId) toolCallId = args.toolCallId; + break; + } + case "lsArgs": { + const args = execMsg.message.value; + const path = args.path.trim(); + if (!path) return { bridge: false }; + command = `ls -la -- ${posixSingleQuote(path)}`; + if (args.toolCallId) toolCallId = args.toolCallId; + break; + } + case "grepArgs": { + const args = execMsg.message.value; + const pattern = args.pattern.trim(); + if (!pattern) return { bridge: false }; + const path = args.path?.trim(); + command = path + ? `rg -n -- ${posixSingleQuote(pattern)} ${posixSingleQuote(path)}` + : `rg -n -- ${posixSingleQuote(pattern)}`; + if (args.toolCallId) toolCallId = args.toolCallId; + break; + } + case "fetchArgs": { + const args = execMsg.message.value; + const url = args.url.trim(); + if (!url) return { bridge: false }; + command = `curl -fsSL -- ${posixSingleQuote(url)}`; + if (args.toolCallId) toolCallId = args.toolCallId; + break; + } + default: + return { bridge: false }; + } + + if (!command) return { bridge: false }; + + return { + bridge: true, + toolName: opts.advertisedShellBridgeName, + toolCallId, + args: workdir ? { command, workdir } : { command }, + }; +} + +export function nativeExecBridgeToMcpExec( + execMsg: ExecServerMessage, + plan: Extract, +): ExecServerMessage { + const args: Record = { + command: new TextEncoder().encode(JSON.stringify(plan.args.command)), + }; + if (plan.args.workdir) { + args.workdir = new TextEncoder().encode(JSON.stringify(plan.args.workdir)); + } + return create(ExecServerMessageSchema, { + id: execMsg.id, + execId: execMsg.execId, + message: { + case: "mcpArgs", + value: create(McpArgsSchema, { + name: plan.toolName, + toolName: plan.toolName, + toolCallId: plan.toolCallId, + providerIdentifier: OCX_RESPONSES_TOOL_PROVIDER, + args, + }), + }, + }); +} diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index d4597ce33c8..52e4c0db7ad 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -5,7 +5,7 @@ import { retryableGoogleStatus, safeGoogleHttpErrorMessage, } from "./google-errors"; -import { repairGoogleInvalidRequestBody } from "./google-wire-compiler"; +import { isGoogleMixedBuiltinToolError, repairGoogleInvalidRequestBody, stripGoogleBuiltinToolsFromWireBody } from "./google-wire-compiler"; import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error"; import { recordAntigravityCooldown } from "../oauth/antigravity-routing"; import { @@ -378,7 +378,17 @@ async function fetchGoogleWithRetryInternal( } catch (error) { if (ctx.abortSignal?.aborted) throw error; } - const repairedBody = repairGoogleInvalidRequestBody(activeRequest.body, payloadText); + // Mixed built-in + function tools must win over schema repair: those 400s often mention + // function_declarations, which would otherwise empty parameters and leave google_search attached. + let repairedBody: string | undefined; + if (isGoogleMixedBuiltinToolError(payloadText)) { + repairedBody = stripGoogleBuiltinToolsFromWireBody(activeRequest.body); + if (repairedBody === undefined) { + repairedBody = repairGoogleInvalidRequestBody(activeRequest.body, payloadText); + } + } else { + repairedBody = repairGoogleInvalidRequestBody(activeRequest.body, payloadText); + } if (repairedBody !== undefined) { compatibilityReplayUsed = true; activeRequest = { ...activeRequest, body: repairedBody }; diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts index ee5e79905dc..9bd40e5e222 100644 --- a/src/adapters/google-wire-compiler.ts +++ b/src/adapters/google-wire-compiler.ts @@ -94,10 +94,35 @@ function compileContents(value: unknown, toWireName: (name: string) => string): }); } +const GOOGLE_BUILTIN_TOOL_KEYS = new Set([ + "googleSearch", "google_search", + "urlContext", "url_context", + "codeExecution", "code_execution", +]); + +function isGoogleBuiltinToolObject(rawTool: unknown): boolean { + if (!isObject(rawTool)) return false; + const keys = Object.keys(rawTool); + return keys.length > 0 && keys.every(key => GOOGLE_BUILTIN_TOOL_KEYS.has(key)); +} + +function passthroughGoogleBuiltinTools(rawTool: unknown): unknown[] { + if (!isObject(rawTool) || Array.isArray(rawTool.functionDeclarations)) return []; + if (!isGoogleBuiltinToolObject(rawTool)) return []; + const out: Record = {}; + for (const key of Object.keys(rawTool)) { + if (key === "googleSearch" || key === "google_search") out.google_search = {}; + else if (key === "urlContext" || key === "url_context") out.url_context = {}; + else if (key === "codeExecution" || key === "code_execution") out.code_execution = rawTool[key] ?? {}; + } + return Object.keys(out).length > 0 ? [out] : []; +} + function compileTools(value: unknown, toWireName: (name: string) => string): unknown[] | undefined { if (!Array.isArray(value)) return undefined; const tools = value.flatMap(rawTool => { - if (!isObject(rawTool) || !Array.isArray(rawTool.functionDeclarations)) return []; + const builtins = passthroughGoogleBuiltinTools(rawTool); + if (!isObject(rawTool) || !Array.isArray(rawTool.functionDeclarations)) return builtins; const functionDeclarations = rawTool.functionDeclarations.flatMap(rawDeclaration => { if (!isObject(rawDeclaration) || typeof rawDeclaration.name !== "string") return []; return [{ @@ -106,7 +131,10 @@ function compileTools(value: unknown, toWireName: (name: string) => string): unk parameters: sanitizeGeminiToolParameters(rawDeclaration.parameters), }]; }); - return functionDeclarations.length > 0 ? [{ functionDeclarations }] : []; + return [ + ...builtins, + ...(functionDeclarations.length > 0 ? [{ functionDeclarations }] : []), + ]; }); return tools.length > 0 ? tools : undefined; } @@ -238,3 +266,56 @@ export function repairGoogleInvalidRequestBody(body: string, errorPayload: strin } return changed ? JSON.stringify(parsed) : undefined; } + +function stripBuiltinToolsFromRoot(root: JsonObject): boolean { + if (!Array.isArray(root.tools)) return false; + const before = root.tools.length; + const filtered = root.tools.filter(rawTool => !isGoogleBuiltinToolObject(rawTool)); + root.tools = filtered; + return filtered.length !== before; +} + +/** One mixed-tool 400 replay: drop known built-in siblings and keep functionDeclarations. */ +export function stripGoogleBuiltinToolsFromWireBody(body: string): string | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(body) as unknown; + } catch { + return undefined; + } + if (!isObject(parsed)) return undefined; + const root = isObject(parsed.request) ? parsed.request : parsed; + if (!stripBuiltinToolsFromRoot(root)) return undefined; + return JSON.stringify(parsed); +} + +export function isGoogleMixedBuiltinToolError(errorPayload: string): boolean { + const mentionsBuiltin = /\b(?:google[_ ]?search|url[_ ]?context|code[_ ]?execution|built[- ]?in(?:\s+tools?)?)\b/i.test(errorPayload); + if (!mentionsBuiltin) return false; + + // A mixed-tool error names a builtin AND a function/tool term AND a coexistence verb. + // Builtin mention is gated above; each pattern below requires a coexistence verb to + // appear within a bounded window of a tool/function/declaration term (or the phrase + // itself implies coexistence, e.g. "mutually exclusive"). Bare "coexist"/"alongside" + // without a nearby tool/function term is NOT enough — a schema error can say fields + // "coexist" while merely mentioning a builtin. + const describesIncompatibleCoexistence = [ + /\bmix\w*\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i, + /\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b[^\n.!?]{0,80}\bmix\w*\b/i, + /\bcombin\w*\b[^\n.!?]{0,80}\bwith\b/i, + /\bcoexist\w*\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i, + /\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b[^\n.!?]{0,80}\bcoexist\w*\b/i, + /\balongside\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i, + /\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b[^\n.!?]{0,80}\balongside\b/i, + /\buse\w*\b[^\n.!?]{0,80}\btogether\b/i, + /\btogether\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i, + /\buse\w*\b[^\n.!?]{0,80}\bwith\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i, + /\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b[^\n.!?]{0,80}\bwith\b[^\n.!?]{0,80}\buse\w*\b/i, + /\bnot supported with\b[^\n.!?]{0,80}\b(?:tool\w*|function(?:[_ ]declarations?)?|declaration\w*)\b/i, + /\bmutually exclusive\b/i, + /\bincompatib\w*\b[^\n.!?]{0,80}\b(?:coexist\w*|combin\w*|alongside|used together|mutually exclusive)\b/i, + /\b(?:coexist\w*|combin\w*|alongside|used together|mutually exclusive)\b[^\n.!?]{0,80}\bincompatib\w*\b/i, + ].some(pattern => pattern.test(errorPayload)); + + return describesIncompatibleCoexistence; +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 806d39f43b5..1453d13923d 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -28,6 +28,12 @@ import { sanitizeGeminiToolParameters } from "./google-tool-schema"; import { identifyRoutedModel } from "./identity"; import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay"; import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; +import { + extractCcaGroundingSources, + formatCcaGroundingSourcesAppendix, + isCcaSearchSuggestionHtml, +} from "../web-search/gemini-executor"; +import type { WebSearchSource } from "../web-search/parse"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { lookupReplayThoughtSignature } from "../responses/thought-signature-replay"; import { @@ -335,22 +341,29 @@ function messagesToGeminiFormat( return { systemInstruction, contents }; } -function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { - if (!parsed.context.tools?.length) return undefined; +function toolsToGeminiFormat( + parsed: OcxParsedRequest, + wireModelId: string, +): unknown[] | undefined { + const grounding = parsed._ccaInTurnGrounding; const allowed = isAllowedToolChoice(parsed.options.toolChoice) ? new Set(parsed.options.toolChoice.allowedTools) : undefined; const tools = allowed - ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools)) - : parsed.context.tools; - if (tools.length === 0) return undefined; - return [{ - functionDeclarations: tools.map(t => ({ - name: namespacedToolName(t.namespace, t.name), - description: t.description, - parameters: t.parameters, - })), - }]; + ? parsed.context.tools?.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools)) ?? [] + : parsed.context.tools ?? []; + const functionDeclarations = tools.map(t => ({ + name: namespacedToolName(t.namespace, t.name), + description: t.description, + parameters: t.parameters, + })); + const wireTools: unknown[] = []; + if (functionDeclarations.length > 0) wireTools.push({ functionDeclarations }); + if (grounding && !/claude/i.test(wireModelId)) { + if (grounding.search) wireTools.push({ google_search: {} }); + if (grounding.urlContext) wireTools.push({ url_context: {} }); + } + return wireTools.length > 0 ? wireTools : undefined; } /** @@ -509,11 +522,12 @@ function googleToolCallMetadataFromPart( * Keep that provider visibility bit authoritative here so the streaming and buffered parsers * cannot accidentally expose the same hidden reasoning through different event types. */ -function googlePartTextEvent(part: GoogleResponsePart): AdapterEvent | undefined { +function googlePartTextEvent(part: GoogleResponsePart, filterCcaSearchSuggestionHtml = false): AdapterEvent | undefined { // A malformed scalar/object is not text and must not cross the AdapterEvent boundary. Dropping // only this optional field preserves the rest of the part without inventing assistant output by // coercion; an empty string keeps its existing no-event behavior. if (typeof part.text !== "string" || part.text.length === 0) return undefined; + if (filterCcaSearchSuggestionHtml && isCcaSearchSuggestionHtml(part.text)) return undefined; return part.thought === true ? { type: "reasoning_raw_delta", text: part.text } : { type: "text_delta", text: part.text }; @@ -669,6 +683,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte let vertexReplayModel: string | undefined; let vertexReplaySession: string | undefined; let restoreGoogleToolName = (name: string): string => name; + const emitInTurnGroundingSourcesQueue: boolean[] = []; return { name: "google", @@ -702,7 +717,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte identityModelId, provider.googleMode === "cloud-code-assist", ); - const tools = toolsToGeminiFormat(parsed); + const tools = toolsToGeminiFormat(parsed, routedModelId); const body: Record = { contents }; if (systemInstruction) body.systemInstruction = systemInstruction; @@ -836,6 +851,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (/claude/i.test(wireModelId)) { headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"; } + emitInTurnGroundingSourcesQueue.push(!!parsed._ccaInTurnGrounding); return { url, method: "POST", headers, body: JSON.stringify(envelope) }; } @@ -861,6 +877,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (apiKey) { const url = `https://aiplatform.googleapis.com/v1/publishers/google/models/${parsed.modelId}:${method}${streamParam}`; headers["x-goog-api-key"] = apiKey; + emitInTurnGroundingSourcesQueue.push(!!parsed._ccaInTurnGrounding); return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; } const project = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT; @@ -873,6 +890,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const url = `https://${host}/v1/projects/${project}/locations/${location}/publishers/google/models/${parsed.modelId}:${method}${streamParam}`; const token = await getVertexAccessToken(); headers["Authorization"] = `Bearer ${token}`; + emitInTurnGroundingSourcesQueue.push(!!parsed._ccaInTurnGrounding); return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; } @@ -884,10 +902,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const compiled = compileGoogleWireBody(body); restoreGoogleToolName = compiled.restoreToolName; + emitInTurnGroundingSourcesQueue.push(!!parsed._ccaInTurnGrounding); return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; }, async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator { + const emitInTurnGroundingSources = emitInTurnGroundingSourcesQueue.shift() ?? false; + const filterCcaSearchSuggestionHtml = + provider.googleMode === "cloud-code-assist" && emitInTurnGroundingSources; if (!response.body) { yield { type: "error", message: "No response body" }; return; @@ -912,6 +934,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte let sawAnyFrame = false; let sawTerminalSignal = false; let pendingStreamThoughtSig: string | undefined; + const groundingSources: WebSearchSource[] = []; + let groundingSourcesEmitted = false; + + const mergeGroundingSources = (groundingMetadata: unknown): void => { + for (const source of extractCcaGroundingSources(groundingMetadata)) { + if (!groundingSources.some(existing => existing.url === source.url)) groundingSources.push(source); + } + }; const handleDataLine = async function* (line: string): AsyncGenerator { const payload = line.slice(5).trim(); @@ -1006,8 +1036,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const candidate = rawCandidate as { content?: unknown; finishReason?: string; + groundingMetadata?: unknown; }; + if (emitInTurnGroundingSources && candidate.groundingMetadata) { + mergeGroundingSources(candidate.groundingMetadata); + } + if (typeof candidate.finishReason === "string" && candidate.finishReason) { lastFinishReason = candidate.finishReason; sawTerminalSignal = true; @@ -1055,7 +1090,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingStreamThoughtSig = sig; } - const textEvent = googlePartTextEvent(part); + const textEvent = googlePartTextEvent(part, filterCcaSearchSuggestionHtml); if (textEvent) { emittedContentEvent = true; yield textEvent; @@ -1166,6 +1201,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte yield { type: "error", message: "upstream stream ended without a terminal signal — possible truncation" }; return; } + if (emitInTurnGroundingSources && !groundingSourcesEmitted && groundingSources.length > 0) { + const appendix = formatCcaGroundingSourcesAppendix(groundingSources); + if (appendix) { + groundingSourcesEmitted = true; + yield { type: "text_delta", text: appendix }; + } + } const stopReason = lastFinishReason === "MAX_TOKENS" ? "max_tokens" : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(lastFinishReason ?? "") diff --git a/src/config.ts b/src/config.ts index b1d6f5a5ed0..2de35ae7965 100644 --- a/src/config.ts +++ b/src/config.ts @@ -735,6 +735,8 @@ const providerConfigSchema = z.object({ // accepted, persisted, and then silently resolved to the `code_mode_only` default — the // operator asked for shell mode, got code mode, and was told nothing (#2106). codexToolMode: z.enum(["code_mode_only", "shell"]).optional(), + // Validated rather than passed through: same rationale as codexToolMode above. + projectContext: z.enum(["off", "on"]).optional(), responsesItemIdRepair: z.object({ message: z.array(z.string().min(1)).optional(), reasoning: z.array(z.string().min(1)).optional(), diff --git a/src/lab/subject/behavior-fingerprint.ts b/src/lab/subject/behavior-fingerprint.ts index 51dd9d9efb6..9283ec28f1c 100644 --- a/src/lab/subject/behavior-fingerprint.ts +++ b/src/lab/subject/behavior-fingerprint.ts @@ -3,7 +3,7 @@ import { jcsStringify } from "../digest"; import type { LabBehaviorSource, LabBehaviorValues } from "../live/types"; const CLOSED_KEYS = new Set([ - "wire.adapter", "wire.upstreamProtocol", "wire.responsesPath", "wire.commandCodeVersion", "wire.modelSuffixMode", + "wire.adapter", "wire.upstreamProtocol", "wire.responsesPath", "wire.commandCodeVersion", "wire.commandCodeProjectContext", "wire.modelSuffixMode", "auth.mode", "auth.transport", "responses.stateful", "responses.upstreamStreaming", "responses.serviceTier", "responses.fastWireKind", "responses.fastWireValue", "responses.snapshotRepair", "responses.itemIdRepair", "limits.contextWindow", "limits.maxInputTokens", "limits.maxOutputTokens", diff --git a/src/oauth/index.ts b/src/oauth/index.ts index fe3abe76560..439e8a0192d 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1073,6 +1073,9 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (existing?.commandCodeVersion !== undefined) { next.commandCodeVersion = existing.commandCodeVersion; } + if (existing?.projectContext !== undefined) { + next.projectContext = existing.projectContext; + } // User-configured price overlays are operator data, not preset state; a // re-login, add-account, or reauth must not silently drop them from the // Logs/Usage estimates. diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 2677864454f..1113d9ae299 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -148,6 +148,9 @@ export function resolveProductionBehaviorValues( "wire.upstreamProtocol": behaviorRow("provider_config", upstreamProtocol), "wire.responsesPath": behaviorRow("provider_config", effective.responsesPath ?? null), "wire.commandCodeVersion": behaviorRow("provider_config", effective.commandCodeVersion ?? null), + ...(adapter === "command-code" + ? { "wire.commandCodeProjectContext": behaviorRow("provider_config", effective.projectContext ?? "off") } + : {}), "wire.modelSuffixMode": behaviorRow( "provider_config", effective.modelSuffixBracketStrip === true ? "bracket_strip" : "none", diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c73f78aa0ee..3d24e12b585 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -125,7 +125,7 @@ import { rotateCursorAccountOnAuth, } from "../../oauth/cursor-routing"; import { getAccountCredential } from "../../oauth/store"; -import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; +import { buildWebSearchTool, mediaBridgeWillRun, planWebSearch, resolveCcaInTurnGrounding, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue"; @@ -4061,13 +4061,23 @@ async function handleResponsesInner( // - non-runTurn: web-search wins over image when both eligible (documented priority) // - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn // can proceed for web-search-only turns - const wsPlan = !routedCompaction - ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar) - : undefined; const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; - const canRunWebSearch = !!wsPlan && !adapter.runTurn; - if ((imgPlan || vidPlan) && canRunWebSearch) { + const hasMediaPlan = !!(imgPlan || vidPlan); + // A media plan is not enough to suppress Gemini 2.x grounding: both bridges inject tools only + // on streaming turns. This is the potential-injection value used to resolve sidecar precedence. + const mediaMayInject = mediaBridgeWillRun(hasMediaPlan, false, !!adapter.runTurn, parsed.stream); + const wsPlan = !routedCompaction + ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar, mediaMayInject) + : undefined; + const webSearchWinsMedia = !!wsPlan && !adapter.runTurn; + const mediaWillRun = mediaBridgeWillRun(hasMediaPlan, !!wsPlan, !!adapter.runTurn, parsed.stream); + const ccaInTurnGrounding = !routedCompaction + ? resolveCcaInTurnGrounding(config, parsed, false, route.provider, route.modelId, mediaWillRun) + : undefined; + if (ccaInTurnGrounding) parsed._ccaInTurnGrounding = ccaInTurnGrounding; + const canRunWebSearch = webSearchWinsMedia && !ccaInTurnGrounding; + if (hasMediaPlan && webSearchWinsMedia) { // Web search takes priority when both are active — the media bridge cannot run // alongside runWithWebSearch. Surface a runtime signal so the user knows their // configured video/image bridge was skipped for this turn, rather than silently @@ -4075,7 +4085,7 @@ async function handleResponsesInner( if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn"); if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn"); } - if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) { + if (mediaWillRun || (imgPlan && !parsed.stream && !webSearchWinsMedia)) { // The image bridge detects a hosted image_generation tool and requires streaming. // The video bridge activates from config and injects a tool — it also needs streaming // (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip diff --git a/src/types/provider.ts b/src/types/provider.ts index 3dfca58ddce..3c57c184915 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -169,6 +169,13 @@ export interface OcxProviderConfig { * version here instead of waiting for a code change. Absent uses the adapter's current default. */ commandCodeVersion?: string; + /** + * Command Code OAuth `/alpha/generate` project-context envelope. When `"on"`, the adapter + * fills `memory`, `taste`, and `skills` from bounded files under `process.cwd()`. Absent or + * `"off"` keeps the empty envelope (`memory: ""`, `taste: null`, `skills: null`). Does not + * enable taste learning (`x-taste-learning` stays `"false"`). + */ + projectContext?: "off" | "on"; /** * Responses upstream that stores nothing server-side (DeepSeek documents "the API * is stateless"). Stateful request parameters are dropped, `store` is pinned false, diff --git a/src/types/request.ts b/src/types/request.ts index 6c5b6d26354..38ba7a9eadd 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -94,6 +94,12 @@ export interface OcxParsedRequest { * executes searches via the gpt-5.4-mini sidecar (see src/web-search). Absent when not requested. */ _webSearch?: Record; + /** + * Antigravity Gemini in-turn CCA grounding: google_search and optional url_context ride the main + * routed fetch instead of the web-search sidecar loop. Set by core.ts when resolveCcaInTurnGrounding + * matches; consumed by the Google adapter at buildRequest/parseStream time. + */ + _ccaInTurnGrounding?: { search: boolean; urlContext: boolean }; /** Hosted image_generation tool config stashed for the image bridge sidecar (see src/images). */ _imageGeneration?: { toolNames: Set; originalTool?: Record }; /** diff --git a/src/web-search/gemini-executor.ts b/src/web-search/gemini-executor.ts index f575526ef49..889db3c4266 100644 --- a/src/web-search/gemini-executor.ts +++ b/src/web-search/gemini-executor.ts @@ -19,6 +19,7 @@ import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; import { getProviderRegistryEntry } from "../providers/registry"; import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse"; +import { appendSafeWebSearchSource } from "./sources"; import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor"; const CCA_FALLBACK_BASE = "https://daily-cloudcode-pa.googleapis.com"; @@ -117,25 +118,46 @@ export async function runGeminiWebSearch( } /** Map a CCA generateContent payload (possibly wrapped in {response}) to text + grounding sources. */ +export function extractCcaGroundingSources(groundingMetadata: unknown): WebSearchSource[] { + const sources: WebSearchSource[] = []; + const gm = isRec(groundingMetadata) ? groundingMetadata : undefined; + if (!gm || !Array.isArray(gm.groundingChunks)) return sources; + for (const chunk of gm.groundingChunks) { + const web = isRec(chunk) && isRec(chunk.web) ? chunk.web : undefined; + if (!web) continue; + // Route through the shared safe-source validator: enforces http(s) scheme, + // control-character filtering, dedup, and per-message count/byte budgets. + appendSafeWebSearchSource(sources, { url: web.uri, title: web.title }); + } + return sources; +} + +/** Skip search-suggestion HTML widgets Google may attach alongside grounded answers. */ +export function isCcaSearchSuggestionHtml(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed.startsWith("<")) return false; + return /]/i.test(trimmed) || /search[_-]?suggest/i.test(trimmed) || /grounding-widget/i.test(trimmed); +} + +export function formatCcaGroundingSourcesAppendix(sources: readonly WebSearchSource[]): string { + if (sources.length === 0) return ""; + const lines = ["", "Sources:"]; + sources.slice(0, 8).forEach((s, i) => { + lines.push(`[${i + 1}] ${s.title ? `${s.title} — ` : ""}${s.url}`); + }); + return lines.join("\n"); +} + export function mapCcaGroundedResponse(payload: unknown): SidecarOutcome { const root = isRec(payload) && isRec(payload.response) ? payload.response : payload; if (!isRec(root)) return { text: "", sources: [], error: "gemini sidecar returned a non-JSON or empty body" }; const candidate = Array.isArray(root.candidates) && isRec(root.candidates[0]) ? root.candidates[0] : undefined; if (!candidate) return { text: "", sources: [], error: "gemini sidecar returned no candidates" }; const parts = isRec(candidate.content) && Array.isArray(candidate.content.parts) ? candidate.content.parts : []; - const text = parts.map(p => (isRec(p) && typeof p.text === "string" ? p.text : "")).join(""); - const sources: WebSearchSource[] = []; - const seen = new Set(); - const gm = isRec(candidate.groundingMetadata) ? candidate.groundingMetadata : undefined; - if (gm && Array.isArray(gm.groundingChunks)) { - for (const chunk of gm.groundingChunks) { - const web = isRec(chunk) && isRec(chunk.web) ? chunk.web : undefined; - const uri = web && typeof web.uri === "string" ? web.uri : undefined; - if (!uri || seen.has(uri)) continue; - seen.add(uri); - sources.push({ url: uri, ...(typeof web?.title === "string" && web.title.length > 0 ? { title: web.title } : {}) }); - } - } + const text = parts + .map(p => (isRec(p) && typeof p.text === "string" && !isCcaSearchSuggestionHtml(p.text) ? p.text : "")) + .join(""); + const sources = extractCcaGroundingSources(candidate.groundingMetadata); if (text.length === 0) return { text: "", sources, error: "gemini sidecar returned no text" }; return { text, sources }; } diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 8dad3b3d734..2f6f513ebcd 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -1,4 +1,4 @@ -import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; +import type { OcxConfig, OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../types"; import { modelInList, toolChoiceToolPredicate } from "../types"; import { isModelTextOnly } from "../vision"; import type { SidecarSettings } from "./executor"; @@ -166,6 +166,86 @@ export function resolveSidecarBackend( return "openai"; } +export interface CcaInTurnGrounding { + search: boolean; + urlContext: boolean; +} + +const HTTP_URL_RE = /https?:\/\/[^\s<>"')\]]+/i; +const EXPLICIT_SIDECAR_BACKENDS = new Set(["openai", "anthropic", "xai", "exa"]); + +function isGemini3ModelId(modelId: string): boolean { + return /gemini-3/i.test(modelId); +} + +/** Whether a planned media bridge will actually inject tools on this turn. */ +export function mediaBridgeWillRun( + hasMediaPlan: boolean, + hasWebSearchPlan: boolean, + adapterRunsTurn: boolean, + isStreaming = true, +): boolean { + return hasMediaPlan && isStreaming && (!hasWebSearchPlan || adapterRunsTurn); +} + +function messageTextContainsHttpUrl(content: string | OcxContentPart[]): boolean { + if (typeof content === "string") return HTTP_URL_RE.test(content); + for (const part of content) { + if (part.type === "image") { + const url = part.imageUrl; + if (url && !url.startsWith("data:") && /^https?:/i.test(url)) return true; + continue; + } + if (part.type === "text" && HTTP_URL_RE.test(part.text ?? "")) return true; + } + return false; +} + +/** True when request text carries http(s) URLs that become remote-url placeholders upstream. */ +export function requestNeedsCcaUrlContext(parsed: OcxParsedRequest): boolean { + for (const msg of parsed.context.messages) { + if (msg.role === "user" || msg.role === "developer") { + if (messageTextContainsHttpUrl(msg.content)) return true; + continue; + } + if (msg.role === "toolResult" && typeof msg.content === "string" && HTTP_URL_RE.test(msg.content)) return true; + if (msg.role === "assistant") { + for (const part of msg.content) { + if (part.type === "text" && HTTP_URL_RE.test(part.text ?? "")) return true; + } + } + } + return false; +} + +/** + * Antigravity Gemini in-turn google_search / url_context on the main routed fetch. Returns undefined + * when the request should use the web-search sidecar (Claude-on-CCA, explicit sidecar backend, or + * Gemini <3 with Codex function tools, including media bridge tools that will be injected later). + */ +export function resolveCcaInTurnGrounding( + config: OcxConfig, + parsed: OcxParsedRequest, + isPassthrough: boolean, + provider: OcxProviderConfig, + modelId: string, + hasMediaBridge = false, +): CcaInTurnGrounding | undefined { + if (!parsed._webSearch || isPassthrough) return undefined; + if (provider.googleMode !== "cloud-code-assist") return undefined; + if (/claude/i.test(modelId)) return undefined; + if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined; + const cfg = config.webSearchSidecar ?? {}; + if (cfg.enabled === false) return undefined; + if (cfg.backend !== undefined && EXPLICIT_SIDECAR_BACKENDS.has(cfg.backend)) return undefined; + const hasCodexTools = (parsed.context.tools?.length ?? 0) > 0 || hasMediaBridge; + if (hasCodexTools && !isGemini3ModelId(modelId)) return undefined; + return { + search: true, + urlContext: requestNeedsCcaUrlContext(parsed), + }; +} + export interface SidecarPlan { /** Which executor runs the search. Anthropic does not require a forward provider. */ backend: WebSearchBackendId; @@ -215,7 +295,11 @@ export function planWebSearch( provider: OcxProviderConfig, modelId: string, openAiSidecar?: ResolvedOpenAiForwardSidecar, + // Core passes the potential media plan here so sidecar precedence can be resolved + // before deciding whether the media bridge actually runs. + hasMediaBridge = false, ): SidecarPlan | undefined { + if (resolveCcaInTurnGrounding(config, parsed, isPassthrough, provider, modelId, hasMediaBridge)) return undefined; if (!parsed._webSearch || isPassthrough) return undefined; if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined; const cfg = config.webSearchSidecar ?? {}; diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index aa733bbe56a..232bbcc1f44 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -381,6 +381,11 @@ approval and sandbox path. `nativeLocalExec: "on"` is the explicit config-owner local experiments; `off` and the backwards-compatible `codex-sandbox` spelling both fail closed. MCP, screen recording, and computer-use stay on their separate explicit executor/MCP config paths. +When `nativeLocalExec` is not `"on"` but the turn advertises a bare Codex `shell_command` or +`exec_command` bridge tool, native Shell/Read/Ls/Grep/Fetch exec frames map to one Codex shell tool +call through the existing MCP split-turn instead of writing proxy-local policy rejections. Write and +delete stay refused; `nativeLocalExec: "on"` still runs native exec on the proxy host. + [Decision Log] - 목적과 의도: prevent caller-controlled Responses text from authorizing Cursor native local shell, filesystem, or fetch execution. - 기존 구현 및 제약 조건: the adapter preserved top-level `instructions`, system messages, and developer messages, then treated a `sandbox_mode ... danger-full-access` prose marker as an exec allow signal in `codex-sandbox` mode. @@ -389,6 +394,14 @@ MCP, screen recording, and computer-use stay on their separate explicit executor - 다른 대안 대신 이 방식을 선택한 이유: opencodex has no trustworthy per-request sandbox attestation in request text or headers, so any prompt-carried marker is spoofable by data-plane callers. - 장점, 단점 및 영향: this closes prompt-to-native-exec escalation while preserving an explicit operator escape hatch; existing configs that relied on `codex-sandbox` must switch to `nativeLocalExec: "on"` for trusted local experiments. +[Decision Log] +- 목적과 의도: when default-off policy blocks proxy-local native exec, route read-class native execs to the advertised Codex shell bridge instead of writing `#604` retry prose that stalls the Cursor run. +- 기존 구현 및 제약 조건: `handleServerMessage` fell through every native exec to `handleCursorNativeExec`, which wrote policy-rejection Connect frames even when `shell_command`/`exec_command` was already in the catalog. +- 검토한 주요 대안: keep pure rejection; auto-enable proxy-local exec in codex-sandbox mode; add a new operator flag for bridging. +- 선택한 방식: pure mapper plus reuse of `planMcpArgsHandling` / client-tool finalize; no new config flag; write/delete stay on the policy path. +- 다른 대안 대신 이 방식을 선택한 이유: the MCP bridge already ends the Cursor turn correctly without fake native results; bridging reuses that path and keeps Codex execution on the client host. +- 장점, 단점 및 영향: default-off turns stop looping on native retries; POSIX command templates may need client-side adaptation on non-POSIX Codex hosts. + Cursor's generic tool-use prompt filter must preserve every Responses-owned execution-path tool that survives the transport budget: unified Desktop `exec` as well as the legacy `exec_command`/`shell_command` aliases. The legacy aliases receive Cursor-specific shell guidance; diff --git a/tests/command-code-project-context.test.ts b/tests/command-code-project-context.test.ts new file mode 100644 index 00000000000..5616010a90e --- /dev/null +++ b/tests/command-code-project-context.test.ts @@ -0,0 +1,656 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const fsPromises = await import("node:fs/promises"); +const realOpendir = fsPromises.opendir; +const realOpen = fsPromises.open; +const opendirMock = mock(realOpendir); +const openMock = mock(realOpen); +mock.module("node:fs/promises", () => ({ + ...fsPromises, + opendir: opendirMock, + open: openMock, +})); + +const { + EMPTY_COMMAND_CODE_PROJECT_CONTEXT, + loadCommandCodeProjectContext, + projectContextCache, + pruneProjectContextCache, +} = await import("../src/adapters/command-code-project-context"); + +const MAX_PROJECT_CONTEXT_CACHE_ENTRIES = 128; +const PROJECT_CONTEXT_TTL_MS = 30_000; + +function makeTempDir(prefix: string): string { + return realpathSync.native(mkdtempSync(join(tmpdir(), prefix))); +} + +function writeSkill( + root: string, + skillRoot: string, + dirName: string, + body: string, + frontmatter?: string, +): void { + const skillDir = join(root, skillRoot, dirName); + mkdirSync(skillDir, { recursive: true }); + const content = frontmatter ? `---\n${frontmatter}\n---\n${body}` : body; + writeFileSync(join(skillDir, "SKILL.md"), content, "utf8"); +} + +beforeEach(() => { + projectContextCache.clear(); +}); + +afterEach(() => { + projectContextCache.clear(); +}); + +describe("loadCommandCodeProjectContext", () => { + test("undefined cwd returns empty context", async () => { + const result = await loadCommandCodeProjectContext(undefined); + expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + }); + + test("missing files return empty memory, null taste, null skills", async () => { + const root = makeTempDir("ocx-cc-ctx-empty-"); + try { + const result = await loadCommandCodeProjectContext(root); + expect(result).toEqual({ memory: "", taste: null, skills: null }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("loads memory, taste, and skills XML from fixture tree", async () => { + const root = makeTempDir("ocx-cc-ctx-fixture-"); + try { + writeFileSync(join(root, "AGENTS.md"), "project agents content", "utf8"); + mkdirSync(join(root, ".commandcode", "taste"), { recursive: true }); + writeFileSync(join(root, ".commandcode", "taste", "taste.md"), "taste prefs", "utf8"); + writeSkill(root, ".commandcode/skills", "yaml-skill", "yaml body", "name: YAML Named"); + writeSkill(root, ".commandcode/skills", "dir-fallback", "dir body"); + + const result = await loadCommandCodeProjectContext(root); + expect(result.memory).toBe("project agents content"); + expect(result.taste).toBe("taste prefs"); + expect(result.skills).toBe( + '\n' + + ' dir body\n' + + ' yaml body\n' + + "", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("first-wins across skill roots by resolved name", async () => { + const root = makeTempDir("ocx-cc-ctx-firstwins-"); + try { + writeSkill(root, ".commandcode/skills", "shared", "from commandcode"); + writeSkill(root, ".agents/skills", "shared", "from agents"); + writeSkill(root, ".pi/skills", "shared", "from pi"); + writeSkill(root, ".agents/skills", "agents-only", "agents only body"); + writeSkill(root, ".pi/skills", "pi-only", "pi only body"); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).toContain('from commandcode'); + expect(result.skills).not.toContain("from agents"); + expect(result.skills).not.toContain("from pi"); + expect(result.skills).toContain('agents only body'); + expect(result.skills).toContain('pi only body'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("skips hidden skill dirs and entries without SKILL.md", async () => { + const root = makeTempDir("ocx-cc-ctx-skip-"); + try { + mkdirSync(join(root, ".commandcode", "skills", ".hidden"), { recursive: true }); + writeFileSync(join(root, ".commandcode", "skills", ".hidden", "SKILL.md"), "hidden", "utf8"); + mkdirSync(join(root, ".commandcode", "skills", "no-skill-md"), { recursive: true }); + writeFileSync(join(root, ".commandcode", "skills", "no-skill-md", "README.md"), "readme", "utf8"); + writeSkill(root, ".commandcode/skills", "visible", "visible body"); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).toBe('\n visible body\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("times out a hanging skill directory iteration", async () => { + const root = makeTempDir("ocx-cc-ctx-iteration-timeout-"); + const skillRoot = join(root, ".commandcode", "skills"); + mkdirSync(skillRoot, { recursive: true }); + let closeCalls = 0; + const hangingDir = { + close: async () => { + closeCalls++; + }, + [Symbol.asyncIterator]() { + return { + next: () => new Promise(() => {}), + }; + }, + }; + + opendirMock.mockImplementation(async path => { + if (String(path) === skillRoot) { + return hangingDir as Awaited>; + } + return realOpendir(path); + }); + + try { + const result = await Promise.race([ + loadCommandCodeProjectContext(root), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 2_500)), + ]); + expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + expect(closeCalls).toBe(1); + } finally { + opendirMock.mockImplementation(realOpendir); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("caps skills at 16 entries", async () => { + const root = makeTempDir("ocx-cc-ctx-maxskills-"); + try { + for (let i = 0; i < 17; i++) { + writeSkill(root, ".commandcode/skills", `skill-${String(i).padStart(2, "0")}`, `body ${i}`); + } + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).not.toBeNull(); + const matches = result.skills!.match(/ { + const root = makeTempDir("ocx-cc-ctx-skillbudget-"); + const skillRoot = join(root, ".commandcode", "skills"); + try { + // 300 valid skill dirs — above MAX_SKILL_DIRS_TO_SCAN (256). Enumeration must + // stop near the scan cap, not walk all 300 (each costs canonicalize + existsSync). + for (let i = 0; i < 300; i++) { + writeSkill(root, ".commandcode/skills", `skill-${String(i).padStart(3, "0")}`, `body ${i}`); + } + + let entriesIterated = 0; + opendirMock.mockImplementation(async path => { + const dir = await realOpendir(path); + if (String(path) !== skillRoot) return dir; + const realIter = dir[Symbol.asyncIterator](); + return { + close: () => dir.close(), + [Symbol.asyncIterator]() { + return { + next: async () => { + const res = await realIter.next(); + if (!res.done) entriesIterated++; + return res; + }, + }; + }, + } as Awaited>; + }); + + const result = await loadCommandCodeProjectContext(root); + const matches = result.skills!.match(/ { + const root = makeTempDir("ocx-cc-ctx-trunc-mem-"); + try { + const payload = "x".repeat(32768 + 100); + writeFileSync(join(root, "AGENTS.md"), payload, "utf8"); + + const result = await loadCommandCodeProjectContext(root); + expect(result.memory.endsWith("\n")).toBe(true); + expect(Buffer.byteLength(result.memory, "utf8")).toBeLessThanOrEqual(32768); + expect(result.memory.startsWith("x".repeat(100))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("bounds the file read to the memory cap plus one byte", async () => { + const root = makeTempDir("ocx-cc-ctx-bounded-read-"); + const agentsPath = join(root, "AGENTS.md"); + let requestedLength = 0; + try { + writeFileSync(agentsPath, "x".repeat(4 * 1024 * 1024), "utf8"); + openMock.mockImplementation(async path => { + const handle = await realOpen(path); + if (String(path) !== agentsPath) return handle; + const originalRead = handle.read.bind(handle); + return { + ...handle, + read: async (buffer: Buffer, offset: number, length: number, position: number) => { + requestedLength = length; + return originalRead(buffer, offset, length, position); + }, + close: handle.close.bind(handle), + } as Awaited>; + }); + + const result = await loadCommandCodeProjectContext(root); + + expect(requestedLength).toBe(32_768 + 1); + expect(Buffer.byteLength(result.memory, "utf8")).toBeLessThanOrEqual(32_768); + expect(result.memory.endsWith("\n")).toBe(true); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("times out and closes a hanging file read", async () => { + const root = makeTempDir("ocx-cc-ctx-read-timeout-"); + const agentsPath = join(root, "AGENTS.md"); + let closeCalls = 0; + const hangingFile = { + read: () => new Promise(() => {}), + close: async () => { + closeCalls++; + }, + }; + openMock.mockImplementation(async path => { + if (String(path) === agentsPath) { + return hangingFile as Awaited>; + } + return realOpen(path); + }); + + try { + writeFileSync(agentsPath, "hanging", "utf8"); + const result = await Promise.race([ + loadCommandCodeProjectContext(root), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 2_500)), + ]); + + expect(result).not.toBe("timeout"); + expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + expect(closeCalls).toBe(1); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("omits symlink escape for AGENTS.md", async () => { + if (process.platform === "win32") return; + const root = makeTempDir("ocx-cc-ctx-symlink-"); + try { + const outside = makeTempDir("ocx-cc-ctx-outside-"); + try { + writeFileSync(join(outside, "secret.txt"), "outside secret", "utf8"); + symlinkSync(join(outside, "secret.txt"), join(root, "AGENTS.md")); + const result = await loadCommandCodeProjectContext(root); + expect(result.memory).toBe(""); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("reads the canonical file after confinement check", async () => { + if (process.platform === "win32") return; + const root = makeTempDir("ocx-cc-ctx-toctou-"); + const outside = makeTempDir("ocx-cc-ctx-toctou-outside-"); + const agentsPath = join(root, "AGENTS.md"); + const insidePath = join(root, "agents-inside.md"); + const outsidePath = join(outside, "secret.txt"); + try { + writeFileSync(insidePath, "inside content", "utf8"); + writeFileSync(outsidePath, "outside secret", "utf8"); + symlinkSync(insidePath, agentsPath); + openMock.mockImplementation(async path => { + if (String(path) === agentsPath) { + unlinkSync(agentsPath); + symlinkSync(outsidePath, agentsPath); + } + return realOpen(path); + }); + + const result = await loadCommandCodeProjectContext(root); + + expect(result.memory).toBe("inside content"); + } finally { + openMock.mockImplementation(realOpen); + rmSync(outside, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("omits unreadable AGENTS.md when chmod is enforced", async () => { + if (process.platform === "win32") return; + const root = makeTempDir("ocx-cc-ctx-unreadable-"); + try { + const agentsPath = join(root, "AGENTS.md"); + writeFileSync(agentsPath, "secret", "utf8"); + chmodSync(agentsPath, 0o000); + const canRead = (() => { + try { + readFileSync(agentsPath, "utf8"); + return true; + } catch { + return false; + } + })(); + if (!canRead) { + const result = await loadCommandCodeProjectContext(root); + expect(result.memory).toBe(""); + } + } finally { + try { + chmodSync(join(root, "AGENTS.md"), 0o644); + } catch { + /* file may not exist */ + } + rmSync(root, { recursive: true, force: true }); + } + }); + + test("escapes XML special characters in skill names and bodies", async () => { + const root = makeTempDir("ocx-cc-ctx-xml-"); + try { + writeSkill( + root, + ".commandcode/skills", + "xml-skill", + 'body with & < > " chars', + 'name: Skill & "Quoted"', + ); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).toBe( + '\n' + + ' body with & < > " chars\n' + + "", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("does not treat a non-exact delimiter line as frontmatter closing", async () => { + const root = makeTempDir("ocx-cc-ctx-frontmatter-marker-"); + try { + writeSkill(root, ".commandcode/skills", "marker-skill", ""); + writeFileSync( + join(root, ".commandcode", "skills", "marker-skill", "SKILL.md"), + "---\nname: Marker\n---foo\nbody", + "utf8", + ); + + const result = await loadCommandCodeProjectContext(root); + + expect(result.skills).toBe( + '\n' + + ' ---\nname: Marker\n---foo\nbody\n' + + "", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("preserves the body for empty frontmatter", async () => { + const root = makeTempDir("ocx-cc-ctx-empty-frontmatter-"); + try { + writeSkill(root, ".commandcode/skills", "empty-frontmatter", ""); + writeFileSync( + join(root, ".commandcode", "skills", "empty-frontmatter", "SKILL.md"), + "---\n---\nbody", + "utf8", + ); + + const result = await loadCommandCodeProjectContext(root); + + expect(result.skills).toBe('\n body\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("accepts CRLF YAML frontmatter delimiters", async () => { + const root = makeTempDir("ocx-cc-ctx-crlf-"); + try { + writeSkill( + root, + ".commandcode/skills", + "crlf-skill", + "crlf body", + ); + writeFileSync( + join(root, ".commandcode", "skills", "crlf-skill", "SKILL.md"), + "---\r\nname: CRLF Named\r\n---\r\ncrlf body", + "utf8", + ); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).toBe('\n crlf body\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fits XML-escaped repeated ampersands within the skills byte cap", async () => { + const root = makeTempDir("ocx-cc-ctx-xml-cap-"); + try { + writeSkill(root, ".commandcode/skills", "ampersands", "&".repeat(32_768)); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).not.toBeNull(); + expect(Buffer.byteLength(result.skills!, "utf8")).toBeLessThanOrEqual(32_768); + expect(result.skills).toContain("&"); + expect(result.skills).toContain("<!-- truncated -->"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("prunes expired entries before refreshing an existing cache key", async () => { + const root = makeTempDir("ocx-cc-ctx-refresh-prune-"); + try { + writeFileSync(join(root, "AGENTS.md"), "refreshed", "utf8"); + const now = Date.now(); + const emptyValue = { memory: "", taste: null, skills: null }; + projectContextCache.set("/expired", { collectedAt: now - PROJECT_CONTEXT_TTL_MS - 1, value: emptyValue }); + projectContextCache.set(root, { collectedAt: now - PROJECT_CONTEXT_TTL_MS - 1, value: emptyValue }); + + const result = await loadCommandCodeProjectContext(root); + expect(result.memory).toBe("refreshed"); + expect(projectContextCache.has("/expired")).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("empty taste.md yields empty string not null", async () => { + const root = makeTempDir("ocx-cc-ctx-empty-taste-"); + try { + mkdirSync(join(root, ".commandcode", "taste"), { recursive: true }); + writeFileSync(join(root, ".commandcode", "taste", "taste.md"), "", "utf8"); + + const result = await loadCommandCodeProjectContext(root); + expect(result.taste).toBe(""); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("cache hit within TTL returns same object without re-read", async () => { + const root = makeTempDir("ocx-cc-ctx-cache-"); + try { + writeFileSync(join(root, "AGENTS.md"), "version one", "utf8"); + const first = await loadCommandCodeProjectContext(root); + writeFileSync(join(root, "AGENTS.md"), "version two", "utf8"); + const second = await loadCommandCodeProjectContext(root); + expect(second).toBe(first); + expect(second.memory).toBe("version one"); + + const cached = projectContextCache.get(root); + expect(cached).toBeDefined(); + cached!.collectedAt = Date.now() - PROJECT_CONTEXT_TTL_MS - 1; + const third = await loadCommandCodeProjectContext(root); + expect(third).not.toBe(first); + expect(third.memory).toBe("version two"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("projectContextCache eviction", () => { + const dummyValue = { memory: "", taste: null, skills: null }; + + test("expired entries are evicted before capacity check", () => { + const now = Date.now(); + projectContextCache.set("/old1", { collectedAt: now - 60_000, value: dummyValue }); + projectContextCache.set("/old2", { collectedAt: now - 45_000, value: dummyValue }); + projectContextCache.set("/fresh", { collectedAt: now - 1_000, value: dummyValue }); + + pruneProjectContextCache(now); + + expect(projectContextCache.size).toBe(1); + expect(projectContextCache.has("/fresh")).toBe(true); + expect(projectContextCache.has("/old1")).toBe(false); + expect(projectContextCache.has("/old2")).toBe(false); + }); + + test("oldest live entry is evicted when at capacity", () => { + const now = Date.now(); + for (let i = 0; i < MAX_PROJECT_CONTEXT_CACHE_ENTRIES; i++) { + projectContextCache.set(`/dir-${i}`, { + collectedAt: now - (MAX_PROJECT_CONTEXT_CACHE_ENTRIES - i), + value: dummyValue, + }); + } + + pruneProjectContextCache(now); + + expect(projectContextCache.size).toBe(MAX_PROJECT_CONTEXT_CACHE_ENTRIES - 1); + expect(projectContextCache.has("/dir-0")).toBe(false); + expect(projectContextCache.has(`/dir-${MAX_PROJECT_CONTEXT_CACHE_ENTRIES - 1}`)).toBe(true); + }); + + test("cache never exceeds the cap when inserting via loader", async () => { + const now = Date.now(); + const roots: string[] = []; + try { + for (let i = 0; i < MAX_PROJECT_CONTEXT_CACHE_ENTRIES + 10; i++) { + const root = makeTempDir(`ocx-cc-ctx-cap-${i}-`); + roots.push(root); + writeFileSync(join(root, "AGENTS.md"), `agents ${i}`, "utf8"); + pruneProjectContextCache(now + i); + await loadCommandCodeProjectContext(root); + } + expect(projectContextCache.size).toBeLessThanOrEqual(MAX_PROJECT_CONTEXT_CACHE_ENTRIES); + } finally { + for (const root of roots) { + if (existsSync(root)) rmSync(root, { recursive: true, force: true }); + } + } + }); + + test("refreshing an expired cached key does not evict a live sibling at capacity", async () => { + const root = makeTempDir("ocx-cc-ctx-refresh-capacity-"); + const now = Date.now(); + const emptyValue = { memory: "", taste: null, skills: null }; + let releaseFirstRead!: () => void; + let releaseSecondRead!: () => void; + let firstReadStarted!: () => void; + let secondReadStarted!: () => void; + const firstRead = new Promise(resolve => { firstReadStarted = resolve; }); + const secondRead = new Promise(resolve => { secondReadStarted = resolve; }); + const firstGate = new Promise(resolve => { releaseFirstRead = resolve; }); + const secondGate = new Promise(resolve => { releaseSecondRead = resolve; }); + let readCount = 0; + + for (let i = 0; i < MAX_PROJECT_CONTEXT_CACHE_ENTRIES - 1; i++) { + projectContextCache.set(`/sibling-${i}`, { collectedAt: now, value: emptyValue }); + } + projectContextCache.set(root, { + collectedAt: now - PROJECT_CONTEXT_TTL_MS - 1, + value: emptyValue, + }); + writeFileSync(join(root, "AGENTS.md"), "refreshed", "utf8"); + openMock.mockImplementation(async path => { + if (String(path) === join(root, "AGENTS.md")) { + const handle = await realOpen(path); + const originalRead = handle.read.bind(handle); + return { + ...handle, + read: async (buffer: Buffer, offset: number, length: number, position: number) => { + readCount++; + if (readCount === 1) { + firstReadStarted(); + await firstGate; + } else if (readCount === 2) { + secondReadStarted(); + await secondGate; + } + return originalRead(buffer, offset, length, position); + }, + close: handle.close.bind(handle), + } as Awaited>; + } + return realOpen(path); + }); + + try { + const firstLoad = loadCommandCodeProjectContext(root); + await firstRead; + const secondLoad = loadCommandCodeProjectContext(root); + await secondRead; + + releaseFirstRead(); + await firstLoad; + releaseSecondRead(); + await secondLoad; + + expect(projectContextCache.size).toBe(MAX_PROJECT_CONTEXT_CACHE_ENTRIES); + expect(projectContextCache.has("/sibling-0")).toBe(true); + expect(projectContextCache.get(root)?.value.memory).toBe("refreshed"); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/command-code-provider.test.ts b/tests/command-code-provider.test.ts index 3453fc6d77c..04e099939e4 100644 --- a/tests/command-code-provider.test.ts +++ b/tests/command-code-provider.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createCommandCodeAdapter } from "../src/adapters/command-code"; +import { projectContextCache } from "../src/adapters/command-code-project-context"; import { loginCommandCode, parseCommandCodeCallback, shouldImportLocalCommandCodeAuth } from "../src/oauth/command-code"; import { buildModelsRequest, OAUTH_PROVIDERS } from "../src/oauth"; import { commandCodeReasoningEfforts, resetCommandCodeReasoningEffortsForTest } from "../src/providers/command-code-efforts"; @@ -32,7 +36,10 @@ async function builtRequest(...args: Parameters resetCommandCodeReasoningEffortsForTest()); +afterEach(() => { + resetCommandCodeReasoningEffortsForTest(); + projectContextCache.clear(); +}); describe("Command Code provider", () => { test("registry and OAuth surfaces stay in parity", () => { @@ -173,6 +180,46 @@ describe("Command Code provider", () => { expect(built.body).not.toContain("secret-command-key"); }); + test("keeps project context empty by default even when the cwd has AGENTS.md", async () => { + for (const configured of [provider, { ...provider, projectContext: "off" as const }]) { + const built = await createCommandCodeAdapter(configured).buildRequest(parsed()); + expect(JSON.parse(built.body)).toMatchObject({ + memory: "", + taste: null, + skills: null, + }); + } + }); + + test("loads all opt-in project context fields from the current working directory", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-cc-provider-context-")); + const previousCwd = process.cwd(); + try { + writeFileSync(join(root, "AGENTS.md"), "provider project memory", "utf8"); + mkdirSync(join(root, ".commandcode", "taste"), { recursive: true }); + writeFileSync(join(root, ".commandcode", "taste", "taste.md"), "provider taste", "utf8"); + mkdirSync(join(root, ".commandcode", "skills", "provider-skill"), { recursive: true }); + writeFileSync( + join(root, ".commandcode", "skills", "provider-skill", "SKILL.md"), + "---\nname: Provider Skill\n---\nprovider skill body", + "utf8", + ); + process.chdir(root); + + const built = await createCommandCodeAdapter({ ...provider, projectContext: "on" }).buildRequest(parsed()); + expect(JSON.parse(built.body)).toMatchObject({ + memory: "provider project memory", + taste: "provider taste", + skills: '\n provider skill body\n', + }); + expect(built.headers["x-taste-learning"]).toBe("false"); + } finally { + process.chdir(previousCwd); + projectContextCache.clear(); + rmSync(root, { recursive: true, force: true }); + } + }); + test("passes every canonical Command Code id through unchanged", async () => { const built = await builtRequest(parsed("xai/grok-4.5")); expect(JSON.parse(built.body).params.model).toBe("xai/grok-4.5"); diff --git a/tests/config.test.ts b/tests/config.test.ts index dd1f17be6c2..efab3c101ef 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -937,6 +937,32 @@ describe("opencodex config defaults", () => { expect(readConfigDiagnostics().error).toContain("codexToolMode"); }); + test("accepts both projectContext values and rejects non-enum spellings", () => { + for (const projectContext of ["off", "on"] as const) { + writeConfig({ + port: 12345, + providers: { + custom: { adapter: "command-code", baseUrl: "https://example.test", projectContext }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().config.providers.custom.projectContext).toBe(projectContext); + expect(readConfigDiagnostics().error).toBeNull(); + } + + for (const projectContext of ["true", true, "yes"] as const) { + writeConfig({ + port: 12345, + providers: { + custom: { adapter: "command-code", baseUrl: "https://example.test", projectContext }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("projectContext"); + } + }); + test("accepts the exact responsesItemIdRepair shape and rejects the old nested placeholderIds proposal", () => { writeConfig({ port: 12345, diff --git a/tests/cursor-native-exec-bridge.test.ts b/tests/cursor-native-exec-bridge.test.ts new file mode 100644 index 00000000000..be7e32eb540 --- /dev/null +++ b/tests/cursor-native-exec-bridge.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, test } from "bun:test"; +import { create } from "@bufbuild/protobuf"; +import { createLiveCursorTransport } from "../src/adapters/cursor/live-transport"; +import { + advertisedBareCodexShellBridgeName, + planNativeExecBridge, +} from "../src/adapters/cursor/native-exec-bridge"; +import { nativeShellDisabledMessage } from "../src/adapters/cursor/native-exec-shell"; +import { createCursorProtobufEventState } from "../src/adapters/cursor/protobuf-events"; +import type { CursorServerMessage } from "../src/adapters/cursor/types"; +import type { OcxProviderConfig } from "../src/types"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import { + AgentServerMessageSchema, + BackgroundShellSpawnArgsSchema, + DeleteArgsSchema, + ExecServerMessageSchema, + FetchArgsSchema, + GrepArgsSchema, + LsArgsSchema, + ReadArgsSchema, + ShellArgsSchema, + WriteArgsSchema, +} from "../src/adapters/cursor/gen/agent_pb"; + +function execMessage(message: Parameters>[1]["message"]) { + return create(ExecServerMessageSchema, { + id: 7, + execId: "exec-test", + message, + }); +} + +describe("planNativeExecBridge", () => { + test("shellArgs bridges to advertised shell_command with workdir", () => { + const plan = planNativeExecBridge(execMessage({ + case: "shellArgs", + value: create(ShellArgsSchema, { + command: "echo hi", + workingDirectory: "/tmp", + toolCallId: "call_shell", + }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + }); + expect(plan).toEqual({ + bridge: true, + toolName: "shell_command", + toolCallId: "call_shell", + args: { command: "echo hi", workdir: "/tmp" }, + }); + }); + + test("selector prefers exec_command regardless of catalog order", () => { + for (const clientToolNames of [ + ["other_tool", "shell_command", "exec_command"], + ["exec_command", "shell_command", "other_tool"], + ]) { + const advertisedShellBridgeName = advertisedBareCodexShellBridgeName(clientToolNames); + expect(advertisedShellBridgeName).toBe("exec_command"); + const plan = planNativeExecBridge(execMessage({ + case: "shellArgs", + value: create(ShellArgsSchema, { command: "pwd", toolCallId: "call_exec" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName, + }); + expect(plan.bridge).toBe(true); + if (plan.bridge) expect(plan.toolName).toBe("exec_command"); + } + }); + + test("does not invent cmd on native shell proto", () => { + const plan = planNativeExecBridge(execMessage({ + case: "shellArgs", + value: create(ShellArgsSchema, { command: "echo hi", toolCallId: "call_shell" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + }); + expect(plan.bridge).toBe(true); + if (plan.bridge) { + expect(plan.args).toEqual({ command: "echo hi" }); + expect(Object.keys(plan.args)).not.toContain("cmd"); + } + }); + + test("shellStreamArgs collapses to one-shot command", () => { + const plan = planNativeExecBridge(execMessage({ + case: "shellStreamArgs", + value: create(ShellArgsSchema, { + command: "echo stream", + workingDirectory: "/work", + toolCallId: "call_stream", + }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + }); + expect(plan).toEqual({ + bridge: true, + toolName: "shell_command", + toolCallId: "call_stream", + args: { command: "echo stream", workdir: "/work" }, + }); + }); + + test("backgroundShellSpawnArgs collapses to one-shot command", () => { + const plan = planNativeExecBridge(execMessage({ + case: "backgroundShellSpawnArgs", + value: create(BackgroundShellSpawnArgsSchema, { + command: "sleep 1", + workingDirectory: "/bg", + toolCallId: "call_bg", + }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + }); + expect(plan).toEqual({ + bridge: true, + toolName: "shell_command", + toolCallId: "call_bg", + args: { command: "sleep 1", workdir: "/bg" }, + }); + }); + + test("readArgs maps to cat with quoted path", () => { + const plan = planNativeExecBridge(execMessage({ + case: "readArgs", + value: create(ReadArgsSchema, { path: "foo/bar.txt", toolCallId: "call_read" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + }); + expect(plan.bridge).toBe(true); + if (plan.bridge) expect(plan.args.command).toBe("cat -- 'foo/bar.txt'"); + }); + + test("readArgs quotes embedded single quotes", () => { + const plan = planNativeExecBridge(execMessage({ + case: "readArgs", + value: create(ReadArgsSchema, { path: "it's", toolCallId: "call_read" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + }); + expect(plan.bridge).toBe(true); + if (plan.bridge) expect(plan.args.command).toBe("cat -- 'it'\\''s'"); + }); + + test("lsArgs maps to ls -la", () => { + const plan = planNativeExecBridge(execMessage({ + case: "lsArgs", + value: create(LsArgsSchema, { path: "/tmp", ignore: [], toolCallId: "call_ls" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + }); + expect(plan.bridge).toBe(true); + if (plan.bridge) expect(plan.args.command).toBe("ls -la -- '/tmp'"); + }); + + test("grepArgs with path maps to rg with pattern and path", () => { + const plan = planNativeExecBridge(execMessage({ + case: "grepArgs", + value: create(GrepArgsSchema, { pattern: "pattern", path: "src", toolCallId: "call_grep" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + }); + expect(plan.bridge).toBe(true); + if (plan.bridge) expect(plan.args.command).toBe("rg -n -- 'pattern' 'src'"); + }); + + test("grepArgs without path maps to rg pattern only", () => { + const plan = planNativeExecBridge(execMessage({ + case: "grepArgs", + value: create(GrepArgsSchema, { pattern: "pattern", toolCallId: "call_grep" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + }); + expect(plan.bridge).toBe(true); + if (plan.bridge) expect(plan.args.command).toBe("rg -n -- 'pattern'"); + }); + + test("fetchArgs maps to curl", () => { + const plan = planNativeExecBridge(execMessage({ + case: "fetchArgs", + value: create(FetchArgsSchema, { url: "https://example.com", toolCallId: "call_fetch" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + }); + expect(plan.bridge).toBe(true); + if (plan.bridge) expect(plan.args.command).toBe("curl -fsSL -- 'https://example.com'"); + }); + + test("writeArgs does not bridge", () => { + expect(planNativeExecBridge(execMessage({ + case: "writeArgs", + value: create(WriteArgsSchema, { path: "x.txt", fileText: "data" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + })).toEqual({ bridge: false }); + }); + + test("deleteArgs does not bridge", () => { + expect(planNativeExecBridge(execMessage({ + case: "deleteArgs", + value: create(DeleteArgsSchema, { path: "x.txt" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + })).toEqual({ bridge: false }); + }); + + test("nativeLocalExecEnabled blocks bridging", () => { + expect(planNativeExecBridge(execMessage({ + case: "shellArgs", + value: create(ShellArgsSchema, { command: "echo hi", toolCallId: "call_shell" }), + }), { + nativeLocalExecEnabled: true, + advertisedShellBridgeName: "shell_command", + })).toEqual({ bridge: false }); + }); + + test("missing advertised shell bridge name blocks bridging", () => { + expect(planNativeExecBridge(execMessage({ + case: "shellArgs", + value: create(ShellArgsSchema, { command: "echo hi", toolCallId: "call_shell" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: undefined, + })).toEqual({ bridge: false }); + }); + + test("empty shell command does not bridge", () => { + expect(planNativeExecBridge(execMessage({ + case: "shellArgs", + value: create(ShellArgsSchema, { command: " ", toolCallId: "call_shell" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + })).toEqual({ bridge: false }); + }); + + test("empty read path does not bridge", () => { + expect(planNativeExecBridge(execMessage({ + case: "readArgs", + value: create(ReadArgsSchema, { path: " ", toolCallId: "call_read" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + })).toEqual({ bridge: false }); + }); + + test("empty fetch url does not bridge", () => { + expect(planNativeExecBridge(execMessage({ + case: "fetchArgs", + value: create(FetchArgsSchema, { url: " ", toolCallId: "call_fetch" }), + }), { + nativeLocalExecEnabled: false, + advertisedShellBridgeName: "shell_command", + })).toEqual({ bridge: false }); + }); +}); + +const NGHTTP2_CANCEL = 8; +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +function nativeExecFrame( + execCase: "shellArgs" | "writeArgs", + value: ReturnType> | ReturnType>, +) { + return create(AgentServerMessageSchema, { + message: { + case: "execServerMessage", + value: create(ExecServerMessageSchema, { + id: 42, + execId: "exec-native", + message: { case: execCase, value }, + }), + }, + }); +} + +interface BridgeHarness { + feed(frame: ReturnType): Promise; + events: CursorServerMessage[]; + writeCalls: unknown[]; + closeCodes: number[]; +} + +function makeBridgeHarness(opts: { + graceMs?: number; + clientToolNames: string[]; + provider?: Partial; +}): BridgeHarness { + const writeCalls: unknown[] = []; + const closeCodes: number[] = []; + const transport = createLiveCursorTransport({ + provider: { + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + apiKey: "test-token", + ...opts.provider, + }, + translatorBudget: createTestTranslatorBudget(), + headers: new Headers(), + clientToolFinalizeGraceMs: opts.graceMs ?? 20, + }) as unknown as { + stream: unknown; + handleServerMessage: (m: unknown, s: unknown, p: (e: CursorServerMessage) => void) => Promise; + }; + const events: CursorServerMessage[] = []; + transport.stream = { + close: (code?: number) => { closeCodes.push(code ?? 0); }, + destroy: () => {}, + write: (chunk: unknown) => { writeCalls.push(chunk); return true; }, + closed: false, + destroyed: false, + }; + const state = createCursorProtobufEventState({ clientToolNames: opts.clientToolNames }); + const push = (e: CursorServerMessage) => { events.push(e); }; + return { + feed: (frame) => transport.handleServerMessage(frame, state, push), + events, + writeCalls, + closeCodes, + }; +} + +describe("live transport native exec bridge", () => { + test("native shellArgs bridges to shell_command tool_call without Connect writes", async () => { + const h = makeBridgeHarness({ clientToolNames: ["shell_command"] }); + await h.feed(nativeExecFrame("shellArgs", create(ShellArgsSchema, { + command: "echo hi", + workingDirectory: "/tmp", + toolCallId: "call_shell", + }))); + + const types = h.events.map(e => e.type); + expect(types).toContain("tool_call_start"); + expect(types).toContain("tool_call_delta"); + expect(types).toContain("tool_call_end"); + expect(h.events.find(e => e.type === "tool_call_start")).toMatchObject({ name: "shell_command", id: "call_shell" }); + expect(h.events.find(e => e.type === "tool_call_delta")?.arguments).toContain("command"); + expect(h.writeCalls).toHaveLength(0); + expect(JSON.stringify(h.events)).not.toContain(nativeShellDisabledMessage()); + + await sleep(60); + expect(h.events.filter(e => e.type === "done")).toHaveLength(1); + expect(h.closeCodes).toEqual([NGHTTP2_CANCEL]); + }); + + test("nativeLocalExec on does not plan a Codex tool_call bridge", () => { + expect(planNativeExecBridge(execMessage({ + case: "shellArgs", + value: create(ShellArgsSchema, { command: "echo hi", toolCallId: "call_shell" }), + }), { + nativeLocalExecEnabled: true, + advertisedShellBridgeName: "shell_command", + })).toEqual({ bridge: false }); + }); + + test("writeArgs with shell in catalog stays on native policy path", async () => { + const h = makeBridgeHarness({ clientToolNames: ["shell_command"] }); + await h.feed(nativeExecFrame("writeArgs", create(WriteArgsSchema, { + path: "blocked.txt", + fileText: "secret", + }))); + + expect(h.events.map(e => e.type)).not.toContain("tool_call_start"); + expect(h.writeCalls.length).toBeGreaterThan(0); + const serialized = JSON.stringify(h.events); + expect(serialized).not.toContain("rm "); + expect(serialized).not.toContain("cat >"); + }); +}); diff --git a/tests/gemini-web-search.test.ts b/tests/gemini-web-search.test.ts index 0cc71cca4cd..cad3b01f02a 100644 --- a/tests/gemini-web-search.test.ts +++ b/tests/gemini-web-search.test.ts @@ -9,12 +9,21 @@ mock.module("../src/oauth/store", () => ({ })); import { mapCcaGroundedResponse } from "../src/web-search/gemini-executor"; -import { findGeminiSidecarProvider, planWebSearch } from "../src/web-search"; +import { findGeminiSidecarProvider, mediaBridgeWillRun, planWebSearch, resolveCcaInTurnGrounding } from "../src/web-search"; +import { planImageBridge, planVideoBridge } from "../src/images"; import { parseRequest } from "../src/responses/parser"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; const routed: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "k" }; const cca: OcxProviderConfig = { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authMode: "oauth" }; +const antigravityRouted: OcxProviderConfig = { + adapter: "google", + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + project: "proj-1", + apiKey: "token", +}; +const openAiSidecar = { provider: routed, headers: { Authorization: "Bearer chatgpt" } }; function config(overrides: Partial = {}): OcxConfig { return { port: 10100, defaultProvider: "routed", providers: { routed, "google-antigravity": cca }, ...overrides }; @@ -67,6 +76,317 @@ describe("mapCcaGroundedResponse (002 live capture shape)", () => { expect(mapCcaGroundedResponse(null).error).toBeDefined(); expect(mapCcaGroundedResponse({}).error).toContain("no candidates"); }); + + test("grounding sources are sanitized: non-http URLs, control chars, and oversized values are dropped", () => { + const out = mapCcaGroundedResponse({ response: { candidates: [{ + content: { parts: [{ text: "answer" }] }, + groundingMetadata: { groundingChunks: [ + { web: { uri: "javascript:alert(1)", title: "xss" } }, + { web: { uri: "https://good.example/a", title: "A" } }, + { web: { uri: "https://good.example/b", title: "B\u0000control" } }, + { web: { uri: "https://good.example/c", title: "C".repeat(10_000) } }, + { web: { uri: "ftp://bad.example/d" } }, + { web: { uri: " https://whitespace.example/e " } }, + ] }, + }] } }); + // Non-http(s) schemes, javascript:, and whitespace-padded URLs are dropped entirely. + // Valid URLs with a control-char or oversized title keep the URL but drop the bad title + // (matches the shared safe-source validator used across the web-search subsystem). + expect(out.sources).toEqual([ + { url: "https://good.example/a", title: "A" }, + { url: "https://good.example/b" }, + { url: "https://good.example/c" }, + ]); + expect(out.error).toBeUndefined(); + }); +}); + +describe("resolveCcaInTurnGrounding / planWebSearch in-turn gate", () => { + test("Antigravity Gemini 3 with default backend -> in-turn grounding, no sidecar plan", () => { + const parsed = parsedWithWebSearch(); + expect(resolveCcaInTurnGrounding(config(), parsed, false, antigravityRouted, "gemini-3.7-flash")).toEqual({ + search: true, + urlContext: false, + }); + expect(planWebSearch(config(), parsed, false, antigravityRouted, "gemini-3.7-flash", openAiSidecar)).toBeUndefined(); + }); + + test("explicit gemini backend on Antigravity Gemini 3 still uses in-turn", () => { + const parsed = parsedWithWebSearch(); + expect(resolveCcaInTurnGrounding( + config({ webSearchSidecar: { backend: "gemini" } }), + parsed, + false, + antigravityRouted, + "gemini-3.7-flash", + )).toEqual({ + search: true, + urlContext: false, + }); + expect(planWebSearch( + config({ webSearchSidecar: { backend: "gemini" } }), + parsed, + false, + antigravityRouted, + "gemini-3.7-flash", + openAiSidecar, + )).toBeUndefined(); + }); + + test("explicit openai backend -> sidecar path, not in-turn", () => { + const parsed = parsedWithWebSearch(); + expect(resolveCcaInTurnGrounding( + config({ webSearchSidecar: { backend: "openai" } }), + parsed, + false, + antigravityRouted, + "gemini-3.7-flash", + )).toBeUndefined(); + expect(planWebSearch( + config({ webSearchSidecar: { backend: "openai" } }), + parsed, + false, + antigravityRouted, + "gemini-3.7-flash", + openAiSidecar, + )?.backend).toBe("openai"); + }); + + test("Claude-on-CCA never selects in-turn grounding", () => { + expect(resolveCcaInTurnGrounding(config(), parsedWithWebSearch(), false, antigravityRouted, "claude-sonnet-4-6")).toBeUndefined(); + }); + + test("Gemini 2.x with Codex tools stays on sidecar path", () => { + const parsed = parseRequest({ + model: "gemini-2.0-flash", + input: "q", + stream: true, + tools: [{ type: "web_search" }, { type: "function", name: "shell", parameters: { type: "object" } }], + }); + expect(resolveCcaInTurnGrounding(config(), parsed, false, antigravityRouted, "gemini-2.0-flash")).toBeUndefined(); + }); + + test("Gemini 2.x with only hosted web_search uses in-turn grounding", () => { + const parsed = parseRequest({ + model: "gemini-2.0-flash", + input: "q", + stream: true, + tools: [{ type: "web_search" }], + }); + expect(resolveCcaInTurnGrounding(config(), parsed, false, antigravityRouted, "gemini-2.0-flash")).toEqual({ + search: true, + urlContext: false, + }); + expect(planWebSearch(config(), parsed, false, antigravityRouted, "gemini-2.0-flash", openAiSidecar)).toBeUndefined(); + }); + + test("Gemini 2.x with an active image bridge does not use in-turn grounding", async () => { + const parsed = parseRequest({ + model: "gemini-2.0-flash", + input: "q", + stream: true, + tools: [ + { type: "web_search" }, + { type: "image_generation", size: "1024x1024" }, + ], + }); + const mediaConfig = config({ + images: { bridgeEnabled: true }, + providers: { + ...config().providers, + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "xai-key" }, + }, + }); + expect(resolveCcaInTurnGrounding(config(), parsed, false, antigravityRouted, "gemini-2.0-flash")).toEqual({ + search: true, + urlContext: false, + }); + const imgPlan = await planImageBridge(mediaConfig, parsed, antigravityRouted); + expect(imgPlan).toBeDefined(); + const wsPlan = planWebSearch(mediaConfig, parsed, false, antigravityRouted, "gemini-2.0-flash", openAiSidecar, true); + expect(wsPlan).toBeDefined(); + const mediaWillRun = mediaBridgeWillRun(!!imgPlan, !!wsPlan, true); + expect(mediaWillRun).toBe(true); + expect(resolveCcaInTurnGrounding(mediaConfig, parsed, false, antigravityRouted, "gemini-2.0-flash", mediaWillRun)).toBeUndefined(); + }); + + test("Gemini 2.x uses in-turn grounding when web search wins over the planned image bridge", async () => { + const parsed = parseRequest({ + model: "gemini-2.0-flash", + input: "q", + stream: true, + tools: [ + { type: "web_search" }, + { type: "image_generation", size: "1024x1024" }, + ], + }); + const mediaConfig = config({ + images: { bridgeEnabled: true }, + providers: { + ...config().providers, + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "xai-key" }, + }, + }); + const imgPlan = await planImageBridge(mediaConfig, parsed, antigravityRouted); + expect(imgPlan).toBeDefined(); + const wsPlan = planWebSearch(mediaConfig, parsed, false, antigravityRouted, "gemini-2.0-flash", openAiSidecar, true); + expect(wsPlan).toBeDefined(); + const mediaWillRun = mediaBridgeWillRun(!!imgPlan, !!wsPlan, false); + expect(mediaWillRun).toBe(false); + expect(resolveCcaInTurnGrounding(mediaConfig, parsed, false, antigravityRouted, "gemini-2.0-flash", mediaWillRun)).toEqual({ + search: true, + urlContext: false, + }); + }); + + test("Gemini 2.x keeps in-turn grounding for non-streaming video-only requests", async () => { + const parsed = parseRequest({ + model: "gemini-2.0-flash", + input: "q", + stream: false, + tools: [{ type: "web_search" }], + }); + const mediaConfig = config({ + images: { videoBridgeEnabled: true }, + providers: { + ...config().providers, + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "xai-key" }, + }, + }); + const videoPlan = await planVideoBridge(mediaConfig, parsed, antigravityRouted); + expect(videoPlan).toBeDefined(); + const mediaCanInject = mediaBridgeWillRun(!!videoPlan, false, true, parsed.stream); + expect(mediaCanInject).toBe(false); + const wsPlan = planWebSearch( + mediaConfig, + parsed, + false, + antigravityRouted, + "gemini-2.0-flash", + openAiSidecar, + mediaCanInject, + ); + expect(wsPlan).toBeUndefined(); + expect(resolveCcaInTurnGrounding( + mediaConfig, + parsed, + false, + antigravityRouted, + "gemini-2.0-flash", + mediaCanInject, + )).toEqual({ search: true, urlContext: false }); + }); + + test("Gemini 2.x suppresses in-turn grounding for streaming video injection", async () => { + const parsed = parseRequest({ + model: "gemini-2.0-flash", + input: "q", + stream: true, + tools: [{ type: "web_search" }], + }); + const mediaConfig = config({ + images: { videoBridgeEnabled: true }, + providers: { + ...config().providers, + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "xai-key" }, + }, + }); + const videoPlan = await planVideoBridge(mediaConfig, parsed, antigravityRouted); + expect(videoPlan).toBeDefined(); + const mediaCanInject = mediaBridgeWillRun(!!videoPlan, false, true, parsed.stream); + expect(mediaCanInject).toBe(true); + const wsPlan = planWebSearch( + mediaConfig, + parsed, + false, + antigravityRouted, + "gemini-2.0-flash", + openAiSidecar, + mediaCanInject, + ); + expect(wsPlan).toBeDefined(); + const mediaWillRun = mediaBridgeWillRun(!!videoPlan, !!wsPlan, true, parsed.stream); + expect(mediaWillRun).toBe(true); + expect(resolveCcaInTurnGrounding( + mediaConfig, + parsed, + false, + antigravityRouted, + "gemini-2.0-flash", + mediaWillRun, + )).toBeUndefined(); + }); + + test("Gemini 3.x keeps in-turn grounding when an active media bridge is present", async () => { + const parsed = parseRequest({ + model: "gemini-3.7-flash", + input: "q", + stream: true, + tools: [ + { type: "web_search" }, + { type: "image_generation", size: "1024x1024" }, + ], + }); + const mediaConfig = config({ + images: { bridgeEnabled: true }, + providers: { + ...config().providers, + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "xai-key" }, + }, + }); + const imgPlan = await planImageBridge(mediaConfig, parsed, antigravityRouted); + expect(imgPlan).toBeDefined(); + const wsPlan = planWebSearch(mediaConfig, parsed, false, antigravityRouted, "gemini-3.7-flash", openAiSidecar, true); + expect(wsPlan).toBeUndefined(); + const mediaWillRun = mediaBridgeWillRun(!!imgPlan, !!wsPlan, true); + expect(mediaWillRun).toBe(true); + expect(resolveCcaInTurnGrounding(mediaConfig, parsed, false, antigravityRouted, "gemini-3.7-flash", mediaWillRun)).toEqual({ + search: true, + urlContext: false, + }); + }); + + test("Gemini 3.x keeps in-turn grounding when web search wins over the planned image bridge", async () => { + const parsed = parseRequest({ + model: "gemini-3.7-flash", + input: "q", + stream: true, + tools: [ + { type: "web_search" }, + { type: "image_generation", size: "1024x1024" }, + ], + }); + const mediaConfig = config({ + images: { bridgeEnabled: true }, + providers: { + ...config().providers, + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "xai-key" }, + }, + }); + const imgPlan = await planImageBridge(mediaConfig, parsed, antigravityRouted); + expect(imgPlan).toBeDefined(); + const wsPlan = planWebSearch(mediaConfig, parsed, false, antigravityRouted, "gemini-3.7-flash", openAiSidecar); + expect(wsPlan).toBeUndefined(); + const mediaWillRun = mediaBridgeWillRun(!!imgPlan, !!wsPlan, false); + expect(mediaWillRun).toBe(true); + expect(resolveCcaInTurnGrounding(mediaConfig, parsed, false, antigravityRouted, "gemini-3.7-flash", mediaWillRun)).toEqual({ + search: true, + urlContext: false, + }); + }); + + test("url_context when the turn carries an http(s) URL", () => { + const parsed = parseRequest({ + model: "gemini-3.7-flash", + input: "read https://example.com/docs", + stream: true, + tools: [{ type: "web_search" }], + }); + expect(resolveCcaInTurnGrounding(config(), parsed, false, antigravityRouted, "gemini-3.7-flash")).toEqual({ + search: true, + urlContext: true, + }); + }); }); describe("planWebSearch gemini arm (L8)", () => { diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 5b3eba8fda7..c92aec90096 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -1121,4 +1121,159 @@ describe("antigravity structured output", () => { expect(envelope.request.generationConfig?.responseMimeType).toBeUndefined(); }); + + test("in-turn grounding attaches google_search alongside functionDeclarations on Gemini CCA", async () => { + const request = await createGoogleAdapter(provider).buildRequest({ + ...parsed("search", true, "gemini-3.7-flash"), + _ccaInTurnGrounding: { search: true, urlContext: false }, + context: { + messages: [{ role: "user", content: "find news" }], + systemPrompt: [], + tools: [{ name: "shell", description: "run", parameters: { type: "object" } }], + }, + } as unknown as OcxParsedRequest); + const envelope = JSON.parse(request.body); + expect(envelope.request.tools).toEqual([ + { functionDeclarations: [{ name: "shell", description: "run", parameters: { type: "object", properties: {} } }] }, + { google_search: {} }, + ]); + }); + + test("Claude CCA never attaches google_search even when _ccaInTurnGrounding is set", async () => { + const request = await createGoogleAdapter(provider).buildRequest({ + ...parsed("x", false, "claude-sonnet-4-6"), + _ccaInTurnGrounding: { search: true, urlContext: true }, + context: { + messages: [{ role: "user", content: "https://example.com" }], + systemPrompt: [], + tools: [{ name: "shell", description: "run", parameters: { type: "object" } }], + }, + } as unknown as OcxParsedRequest); + const envelope = JSON.parse(request.body); + expect(envelope.request.tools).toEqual([ + { functionDeclarations: [{ name: "shell", description: "run", parameters: { type: "object", properties: {} } }] }, + ]); + }); + + test("parseStream appends grounding sources and drops search-suggestion HTML widgets", async () => { + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest({ + ...parsed("x", true, "gemini-3.7-flash"), + _ccaInTurnGrounding: { search: true, urlContext: false }, + } as unknown as OcxParsedRequest); + const sse = [ + "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"\"}]}}]}}\n", + "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Grounded answer.\"}]},\"groundingMetadata\":{\"groundingChunks\":[{\"web\":{\"uri\":\"https://a.example\",\"title\":\"A\"}}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":2}}}\n", + ].join(""); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream( + new Response(sse, { headers: { "content-type": "text/event-stream" } }), + createTestTranslatorBudget(), + )) { + events.push(event); + } + const text = events.filter((event): event is Extract => event.type === "text_delta") + .map(event => event.text) + .join(""); + expect(text).toContain("Grounded answer."); + expect(text).toContain("Sources:"); + expect(text).toContain("https://a.example"); + expect(text).not.toContain(" event.type === "done")).toBe(true); + }); + + test("parseStream preserves search-suggestion-looking HTML when CCA grounding is off", async () => { + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest(parsed("x", true, "gemini-3.7-flash")); + const marker = "legitimate text"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream( + sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: marker }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]), + createTestTranslatorBudget(), + )) { + events.push(event); + } + + expect(events).toContainEqual({ type: "text_delta", text: marker }); + }); + + test("keeps grounding-source emission aligned when requests are built consecutively", async () => { + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest({ + ...parsed("grounded", true, "gemini-3.7-flash"), + _ccaInTurnGrounding: { search: true, urlContext: false }, + } as unknown as OcxParsedRequest); + await adapter.buildRequest(parsed("plain", true, "gemini-3.7-flash")); + + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream( + sseResponse([ + { + response: { + candidates: [{ + content: { parts: [{ text: "grounded answer" }] }, + groundingMetadata: { + groundingChunks: [{ web: { uri: "https://grounded.example", title: "Grounded" } }], + }, + finishReason: "STOP", + }], + }, + }, + ]), + createTestTranslatorBudget(), + )) { + events.push(event); + } + + const text = events + .filter((event): event is Extract => event.type === "text_delta") + .map(event => event.text) + .join(""); + expect(text).toContain("grounded answer"); + expect(text).toContain("Sources:"); + expect(text).toContain("https://grounded.example"); + }); + + test("does not emit grounding sources after a grounded request fails to build", async () => { + const providerThatRecovers = { ...provider, project: undefined } as OcxProviderConfig; + const adapter = createGoogleAdapter(providerThatRecovers); + await expect(adapter.buildRequest({ + ...parsed("failed grounded request", true, "gemini-3.7-flash"), + _ccaInTurnGrounding: { search: true, urlContext: false }, + } as unknown as OcxParsedRequest)).rejects.toThrow(/project id/); + + providerThatRecovers.project = provider.project; + await adapter.buildRequest(parsed("plain", true, "gemini-3.7-flash")); + + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream( + sseResponse([ + { + response: { + candidates: [{ + content: { parts: [{ text: "plain answer" }] }, + groundingMetadata: { + groundingChunks: [{ web: { uri: "https://stale.example", title: "Stale" } }], + }, + finishReason: "STOP", + }], + }, + }, + ]), + createTestTranslatorBudget(), + )) { + events.push(event); + } + + const text = events + .filter((event): event is Extract => event.type === "text_delta") + .map(event => event.text) + .join(""); + expect(text).toContain("plain answer"); + expect(text).not.toContain("Sources:"); + expect(text).not.toContain("https://stale.example"); + }); }); diff --git a/tests/google-vertex-http.test.ts b/tests/google-vertex-http.test.ts index 9c0cdb206a4..5a09d17a32e 100644 --- a/tests/google-vertex-http.test.ts +++ b/tests/google-vertex-http.test.ts @@ -166,6 +166,141 @@ describe("vertex retry fetch", () => { }); }); + test("repairs schema instead of stripping builtin when the 400 only echoes both tool names", async () => { + const mixedSchemaRequest: AdapterRequest = { + ...request, + body: JSON.stringify({ + request: { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [ + { functionDeclarations: [{ name: "shell", parameters: { type: "object", properties: { path: { type: "string" } } } }] }, + { google_search: {} }, + ], + }, + }), + }; + const mock = mockFetch([ + new Response( + vertexError(400, "INVALID_ARGUMENT", "google_search is enabled but function_declarations.0: JSON schema is invalid"), + { status: 400 }, + ), + new Response("ok", { status: 200 }), + ]); + + const res = await fetchAntigravityWithRetry(mixedSchemaRequest, { timeoutMs: 5_000 }); + + expect(res.status).toBe(200); + expect(mock.calls).toHaveLength(2); + const replay = JSON.parse(mock.calls[1].body as string); + expect(replay.request.tools).toEqual([ + { functionDeclarations: [{ name: "shell", parameters: { type: "object", properties: {} } }] }, + { google_search: {} }, + ]); + }); + + test("replays once stripping google_search after a mixed-tool 400", async () => { + const mixedRequest: AdapterRequest = { + ...request, + body: JSON.stringify({ + request: { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [ + { functionDeclarations: [{ name: "shell", parameters: { type: "object", properties: {} } }] }, + { google_search: {} }, + { url_context: {} }, + ], + }, + }), + }; + const mock = mockFetch([ + new Response( + vertexError(400, "INVALID_ARGUMENT", "cannot mix google_search with function_declarations"), + { status: 400 }, + ), + new Response("ok", { status: 200 }), + ]); + + const res = await fetchAntigravityWithRetry(mixedRequest, { timeoutMs: 5_000 }); + + expect(res.status).toBe(200); + expect(mock.calls).toHaveLength(2); + const replay = JSON.parse(mock.calls[1].body as string); + expect(replay.request.tools).toEqual([ + { functionDeclarations: [{ name: "shell", parameters: { type: "object", properties: {} } }] }, + ]); + }); + + test.each([ + ["cannot use `google_search` and `function_declarations` together", { google_search: {} }], + ["google_search cannot be combined with other tools", { google_search: {} }], + ["google_search alongside function_declarations is not supported", { google_search: {} }], + ["code_execution and function declarations cannot coexist", { code_execution: {} }], + ])("strips the named built-in after a mixed-tool 400: %s", async (message, builtin) => { + const mixedRequest: AdapterRequest = { + ...request, + body: JSON.stringify({ + request: { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [ + { functionDeclarations: [{ name: "shell", parameters: { type: "object" } }] }, + builtin, + ], + }, + }), + }; + const mock = mockFetch([ + new Response(vertexError(400, "INVALID_ARGUMENT", message), { status: 400 }), + new Response("ok", { status: 200 }), + ]); + + const res = await fetchAntigravityWithRetry(mixedRequest, { timeoutMs: 5_000 }); + + expect(res.status).toBe(200); + expect(mock.calls).toHaveLength(2); + const replay = JSON.parse(mock.calls[1].body as string); + expect(replay.request.tools).toEqual([ + { functionDeclarations: [{ name: "shell", parameters: { type: "object" } }] }, + ]); + }); + + test("falls through to schema repair when mixed-tool language has no built-in to strip", async () => { + const schemaRequest: AdapterRequest = { + ...request, + body: JSON.stringify({ + request: { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [{ + functionDeclarations: [{ + name: "shell", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }], + }], + }, + }), + }; + const mock = mockFetch([ + new Response( + vertexError( + 400, + "INVALID_ARGUMENT", + "cannot mix google_search with function_declarations; tools.0.custom.input_schema: JSON schema is invalid", + ), + { status: 400 }, + ), + new Response("ok", { status: 200 }), + ]); + + const res = await fetchAntigravityWithRetry(schemaRequest, { timeoutMs: 5_000 }); + + expect(res.status).toBe(200); + expect(mock.calls).toHaveLength(2); + const replay = JSON.parse(mock.calls[1].body as string); + expect(replay.request.tools[0].functionDeclarations[0].parameters).toEqual({ + type: "object", + properties: {}, + }); + }); + test("raw mode preserves final error body and headers without normalization", async () => { const raw = vertexError(400, "INVALID_ARGUMENT", "provider-private-detail"); const mock = mockFetch([new Response(raw, { status: 400, headers: { "x-provider-error": "raw" } })]); diff --git a/tests/google-wire-compiler.test.ts b/tests/google-wire-compiler.test.ts index ca9a873bedb..1e801dffc06 100644 --- a/tests/google-wire-compiler.test.ts +++ b/tests/google-wire-compiler.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; -import { compileGoogleWireBody, repairGoogleInvalidRequestBody } from "../src/adapters/google-wire-compiler"; +import { compileGoogleWireBody, isGoogleMixedBuiltinToolError, repairGoogleInvalidRequestBody, stripGoogleBuiltinToolsFromWireBody } from "../src/adapters/google-wire-compiler"; import type { OcxParsedRequest } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -99,6 +99,32 @@ describe("Google wire compiler", () => { }); }); + test("passthrough google_search and url_context siblings alongside functionDeclarations", () => { + const compiled = compileGoogleWireBody({ + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [ + { functionDeclarations: [{ name: "shell", parameters: { type: "object" } }] }, + { google_search: {} }, + { urlContext: {} }, + { futureBuiltin: true, google_search: {} }, + ], + }); + + expect(compiled.body.tools).toEqual([ + { functionDeclarations: [{ name: "shell", parameters: { type: "object", properties: {} } }] }, + { google_search: {} }, + { url_context: {} }, + ]); + }); + + test("strips unknown extra keys from builtin tool siblings", () => { + const compiled = compileGoogleWireBody({ + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [{ googleSearch: {}, rogue: true }], + }); + expect(compiled.body.tools).toBeUndefined(); + }); + test("drops non-json responseMimeType and unknown schema types", () => { const compiled = compileGoogleWireBody({ contents: [{ role: "user", parts: [{ text: "hi" }] }], @@ -184,4 +210,70 @@ describe("Google wire compiler", () => { const repaired = JSON.parse(repairGoogleInvalidRequestBody(body, error)!); expect(repaired.request.generationConfig).toEqual({ maxOutputTokens: 4096 }); }); + + test("schema repair keeps google_search siblings on the wire body", () => { + const body = JSON.stringify({ + request: { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [ + { functionDeclarations: [{ name: "shell", parameters: { type: "object", properties: {} } }] }, + { google_search: {} }, + ], + }, + }); + const error = "function_declarations.0: JSON schema is invalid"; + const repaired = JSON.parse(repairGoogleInvalidRequestBody(body, error)!); + expect(repaired.request.tools.map((tool: Record) => Object.keys(tool))).toEqual([ + ["functionDeclarations"], + ["google_search"], + ]); + }); + + test("mixed-tool 400 replay strips built-in siblings only", () => { + const body = JSON.stringify({ + request: { + tools: [ + { functionDeclarations: [{ name: "shell", parameters: { type: "object" } }] }, + { google_search: {} }, + { url_context: {} }, + ], + }, + }); + expect(isGoogleMixedBuiltinToolError("cannot mix google_search with function_declarations")).toBe(true); + const stripped = JSON.parse(stripGoogleBuiltinToolsFromWireBody(body)!); + expect(stripped.request.tools).toEqual([ + { functionDeclarations: [{ name: "shell", parameters: { type: "object" } }] }, + ]); + }); + + test("classifies only explicit mixed built-in-tool incompatibility language", () => { + for (const message of [ + "cannot mix google_search with function_declarations", + "mixed built-in tools and custom function declarations are unsupported", + "incompatible coexistence of url_context and function tools", + "cannot use `google_search` and `function_declarations` together", + "google_search cannot be combined with other tools", + "google_search alongside function_declarations is not supported", + "code_execution and function declarations cannot coexist", + "google_search cannot be used with function_declarations", + "google_search is not supported with function_declarations", + "google_search and function_declarations are mutually exclusive", + ]) { + expect(isGoogleMixedBuiltinToolError(message)).toBe(true); + } + }); + + test("does not classify built-in-tool mentions without coexistence language", () => { + for (const message of [ + "google_search tool is unavailable", + "url_context returned an invalid result", + "builtin tool schema is invalid", + "google_search is enabled but function_declarations.0: JSON schema is invalid", + "google_search is incompatible with a function schema", + "function_declarations are incompatible with this model", + "the schema fields coexist and google_search is enabled", + ]) { + expect(isGoogleMixedBuiltinToolError(message)).toBe(false); + } + }); }); diff --git a/tests/images/z-handler-activation.test.ts b/tests/images/z-handler-activation.test.ts index 324b8773284..d3f3360982c 100644 --- a/tests/images/z-handler-activation.test.ts +++ b/tests/images/z-handler-activation.test.ts @@ -99,6 +99,9 @@ beforeAll(async () => { }, planWebSearch: () => mockWsPlan, shouldResolveOpenAiWebSearchSidecar: () => false, + mediaBridgeWillRun: (hasMediaPlan: boolean, hasWebSearchPlan: boolean, adapterRunsTurn: boolean, isStreaming = true) => + hasMediaPlan && isStreaming && (!hasWebSearchPlan || adapterRunsTurn), + resolveCcaInTurnGrounding: () => undefined, })); ({ handleResponses } = await import("../../src/server/responses")); diff --git a/tests/oauth-upsert-preserves-api-key.test.ts b/tests/oauth-upsert-preserves-api-key.test.ts index a15e4d20faa..32b31855f2e 100644 --- a/tests/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth-upsert-preserves-api-key.test.ts @@ -60,6 +60,27 @@ describe("upsertOAuthProvider credential preservation", () => { expect(config.providers.xai!.modelCosts).toEqual(costs); }); + test("carries Command Code projectContext across a re-login upsert", () => { + const config = { + port: 10100, + defaultProvider: "command-code", + providers: { + "command-code": { + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode: "oauth", + commandCodeVersion: "0.52.1", + projectContext: "on", + }, + }, + } as unknown as OcxConfig; + + upsertOAuthProvider(config, "command-code"); + + expect(config.providers["command-code"]?.commandCodeVersion).toBe("0.52.1"); + expect(config.providers["command-code"]?.projectContext).toBe("on"); + }); + test("carries the key over without changing oauth billing when the user did not pick key mode", () => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); config.providers.xai!.authMode = "oauth"; diff --git a/tests/routing-compatibility-auth-identity.test.ts b/tests/routing-compatibility-auth-identity.test.ts index 9a32447dcb3..27a2e65d3a2 100644 --- a/tests/routing-compatibility-auth-identity.test.ts +++ b/tests/routing-compatibility-auth-identity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { buildBehaviorFingerprintV1 } from "../src/lab/subject/behavior-fingerprint"; import { PROVIDER_REGISTRY } from "../src/providers/registry"; import { resolveProductionBehaviorValues } from "../src/routing/compatibility/behavior"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; @@ -40,4 +41,45 @@ describe("CL-06 registry auth compatibility identity", () => { expect(registryDerived?.["auth.transport"]?.value).toBe("oauth_bearer"); expect(registryDerived).toEqual(explicit); }); + + test("fingerprints Command Code project-context behavior only for its native adapter", () => { + const commandCode: OcxProviderConfig = { + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode: "oauth", + projectContext: "on", + }; + const openAiChat: OcxProviderConfig = { + ...commandCode, + adapter: "openai-chat", + }; + const config: OcxConfig = { + port: 10100, + defaultProvider: "command-code", + providers: { "command-code": commandCode, "openai-chat": openAiChat }, + }; + + const commandCodeValues = resolveProductionBehaviorValues( + config, + "command-code", + "context-model", + commandCode, + "compatibility-test-salt", + ); + const openAiChatValues = resolveProductionBehaviorValues( + config, + "openai-chat", + "context-model", + openAiChat, + "compatibility-test-salt", + ); + + expect(commandCodeValues?.["wire.commandCodeProjectContext"]).toEqual({ + source: "provider_config", + value: "on", + }); + expect(openAiChatValues).not.toHaveProperty("wire.commandCodeProjectContext"); + expect(() => buildBehaviorFingerprintV1(commandCodeValues!)).not.toThrow(); + expect(() => buildBehaviorFingerprintV1(openAiChatValues!)).not.toThrow(); + }); });