diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 7c9f3fc682..eb4f4a0718 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -125,6 +125,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | La connexion initiale importe la session de l'installation locale de `kiro-cli`, déjà authentifiée (sous Unix, installez avec `curl -fsSL https://cli.kiro.dev/install` | `bash`; sous Windows PowerShell, utilisez `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; puis exécutez `kiro-cli login`). **Ajouter un compte** déconnecte `kiro-cli`, lance une nouvelle connexion dans le navigateur qui change le compte utilisé par `kiro-cli`, puis enregistre les métadonnées propres au profil. Les comptes OpenCodex existants sont préservés ; une annulation ou un échec restaure la session `kiro-cli` précédente. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Pilote la CLI Devin installée localement via l'Agent Client Protocol (`devin acp`, JSON-RPC sur stdio). La CLI détient ses propres identifiants issus de `devin auth login`, donc opencodex ne stocke aucune clé. `OPENCODEX_DEVIN_CLI_BIN` désigne l'exécutable ; pour autoriser la CLI à lire et écrire des fichiers, il faut définir explicitement `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`, le refus étant la valeur par défaut. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. | Les vérifications de quota Google Antigravity utilisent des points de terminaison Google fixes, y compris le repli vers la liste des modèles. Elles prennent en charge le DNS Fake-IP transparent pour ces destinations en conservant la vérification TLS, le refus des redirections et les contrôles des adresses privées. Une URL de base personnalisée ne modifie que les requêtes de modèles ; `NO_PROXY` conserve la politique de connexion directe. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7119be8643..2196f1288e 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -191,6 +191,7 @@ ocx logout | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | Browser consent and key exchange use `https://www.orcarouter.ai` with S256 PKCE. The returned user-owned `sk-orca-…` API key is stored in the existing credential store and reused until revoked. | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Drives the locally installed Devin CLI over the Agent Client Protocol (`devin acp`, newline-delimited JSON-RPC on stdio). The CLI holds its own credentials from `devin auth login`, so opencodex stores no key for it. Point `OPENCODEX_DEVIN_CLI_BIN` at a specific build; letting the CLI read and write files requires setting `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` explicitly, because the default is to refuse. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | Google Antigravity account and provider quota probes use fixed Google accounting endpoints, including the models fallback. They support transparent Fake-IP DNS for those destinations while retaining TLS verification, redirect rejection and private-address checks. A custom provider base URL changes model requests, not quota destinations; `NO_PROXY` continues to select the direct-route policy. diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index d0025981aa..6b5bd881e6 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -114,6 +114,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install` | `bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1'` | `iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | ローカルにインストールされた Devin CLI を Agent Client Protocol(`devin acp`、stdio 上の JSON-RPC)で駆動します。CLI が `devin auth login` の資格情報を保持するため、opencodex 側はキーを保存しません。実行ファイルは `OPENCODEX_DEVIN_CLI_BIN` で指定でき、CLI にファイル操作を許可するには `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` の明示が必要です(既定は拒否)。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | Google Antigravity のアカウント・プロバイダーのクォータ確認は、モデル一覧へのフォールバックも含め、固定の Google エンドポイントを使用します。その宛先では透過 Fake-IP DNS に対応し、TLS 検証、リダイレクト拒否、プライベートアドレス検査を維持します。カスタム base URL はモデル要求にのみ適用されます。`NO_PROXY` は直接接続のポリシーを維持します。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 9fb1bf9ed1..6a470c2d89 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -112,6 +112,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install` | `bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | 로컬에 설치된 Devin CLI를 Agent Client Protocol(`devin acp`, stdio 위 JSON-RPC)로 구동합니다. CLI가 `devin auth login` 자격증명을 직접 들고 있어 opencodex는 키를 저장하지 않습니다. 실행 파일은 `OPENCODEX_DEVIN_CLI_BIN`으로 지정할 수 있고, CLI가 파일을 읽고 쓰도록 허용하려면 `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`을 명시해야 합니다. 기본값은 거부입니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | Google Antigravity 계정·제공자 할당량 확인은 모델 목록 폴백을 포함해 고정된 Google 회계 엔드포인트를 사용합니다. 해당 목적지의 투명 Fake-IP DNS를 지원하며 TLS 검증, 리다이렉트 거부, 사설 주소 검사는 유지합니다. 사용자 지정 base URL은 모델 요청에만 적용되며 할당량 목적지는 바꾸지 않습니다. `NO_PROXY`는 기존 직접 연결 정책을 유지합니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 596fc2255f..3afecaa230 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -422,6 +422,30 @@ bare `exec_command` and `shell_command` names are reserved for non-freeform shel bridges. Namespace a custom freeform tool that uses either name. These schema declarations do not grant approval or change execution policy. +## `devin-cli` + +**Targets:** the locally installed Devin CLI, over the Agent Client Protocol — `devin acp` speaking +newline-delimited JSON-RPC on stdin and stdout. +**Auth:** none held by opencodex. The CLI carries its own credentials from `devin auth login`, so +this provider stores no key and asks for none. + +- Uses `runTurn`; a handshake over a child process has no fetch-shaped request for the generic wire + path, so `buildRequest` / `parseStream` are disabled. +- One turn is one ACP session: `initialize`, `session/new`, `session/prompt`, with `session/update` + notifications streaming in between and a unary reply carrying the stop reason and usage. The + conversation is flattened into the single prompt string a session takes, with role labels fenced + so a message body cannot forge one. +- The CLI's own tool calls stay internal. Devin executes them inside its session, so forwarding + them as client tools would either fail the turn — the bridge rejects a tool Codex never declared — + or ask Codex to run something the agent already ran. +- **Permission requests are refused by default.** This provider runs an agent in the operator's own + tree, so `session/request_permission` is answered with `cancelled` unless + `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` is set. The child also gets a scoped environment rather than + the proxy's, and `OPENCODEX_DEVIN_CLI_CWD` chooses where it runs. +- Binary discovery prefers `OPENCODEX_DEVIN_CLI_BIN`, then the paths the official installer and the + Homebrew cask use, then `PATH`. Install with `curl -fsSL https://cli.devin.ai/install.sh | bash` + or `brew install --cask devin-cli`. + ## `azure-openai` (alias: `azure`) **Targets:** **Azure OpenAI**. Wraps `openai-responses` (so also `passthrough: true`). diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 0811958d03..337dbfe8e4 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -123,6 +123,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install` | `bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Управляет локально установленным Devin CLI по Agent Client Protocol (`devin acp`, JSON-RPC поверх stdio). Учётные данные хранит сам CLI после `devin auth login`, поэтому opencodex не сохраняет ключ. Путь к исполняемому файлу задаётся через `OPENCODEX_DEVIN_CLI_BIN`; чтобы разрешить CLI читать и писать файлы, нужно явно выставить `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` — по умолчанию запрос отклоняется. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | Проверки квот аккаунтов и провайдера Google Antigravity используют фиксированные адреса Google, включая резервный запрос списка моделей. Для этих адресов поддерживается прозрачный Fake-IP DNS с сохранением проверки TLS, запрета перенаправлений и проверки частных адресов. Пользовательский base URL меняет только запросы моделей; `NO_PROXY` сохраняет политику прямого подключения. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 9b9b557d24..29ecc83a95 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -138,6 +138,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | İlk oturum açma, kurulu ve oturum açılmış `kiro-cli` oturumunu içe aktarır (Unix'te `curl -fsSL https://cli.kiro.dev/install` | `bash` ile kurun; Windows PowerShell'de `irm 'https://cli.kiro.dev/install.ps1'` | `iex` kullanın; ardından `kiro-cli login` çalıştırın). **Hesap ekle**, `kiro-cli` oturumunu kapatır, `kiro-cli` tarafından kullanılan hesabı değiştiren yeni bir tarayıcı girişi başlatır ve hesap kapsamlı profil meta verilerini saklar. Mevcut OpenCodex hesapları korunur ve iptal veya başarısızlık önceki `kiro-cli` oturumunu geri yükler. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Deneysel PKCE girişi, canlı HTTP/2 aktarımı ve hesap filtreli model keşfi. | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | Yerelde kurulu Devin CLI'yi Agent Client Protocol ile (`devin acp`, stdio üzerinde JSON-RPC) çalıştırır. Kimlik bilgilerini `devin auth login` sonrası CLI'nin kendisi taşır, bu yüzden opencodex hiçbir anahtar saklamaz. Çalıştırılabilir dosya `OPENCODEX_DEVIN_CLI_BIN` ile belirtilir; CLI'nin dosya okuyup yazmasına izin vermek için `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1` açıkça ayarlanmalıdır, varsayılan reddetmektir. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Deneysel. GitHub cihaz akışı + `copilot_internal` değişimi (VS Code OAuth istemcisi). Aktif bir Copilot aboneliği gerektirir; resmi bir üçüncü taraf API değildir. | Google Antigravity hesap ve sağlayıcı kota sorguları, model listesine geri dönüş dahil sabit Google uç noktalarını kullanır. Bu hedefler için şeffaf Fake-IP DNS desteklenirken TLS doğrulaması, yönlendirme reddi ve özel adres kontrolleri korunur. Özel base URL yalnızca model isteklerini değiştirir; `NO_PROXY` doğrudan bağlantı politikasını korur. 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 084f6cd38b..afdc1738a6 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -106,6 +106,7 @@ ocx logout | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | 浏览器授权与密钥交换走 `https://www.orcarouter.ai` + S256 PKCE。交换结果是用户自己的普通 `sk-orca-…` API key,保存在现有凭据库中并持续复用,直到被撤销。 | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | 通过 Agent Client Protocol(`devin acp`,stdio 上的 JSON-RPC)驱动本地安装的 Devin CLI。凭据由 CLI 自己通过 `devin auth login` 持有,opencodex 不保存密钥。可用 `OPENCODEX_DEVIN_CLI_BIN` 指定可执行文件;要允许 CLI 读写文件,必须显式设置 `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`,默认拒绝。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | Google Antigravity 账户和提供方的配额查询(包括模型列表回退)使用固定的 Google 计量端点。这些目标支持透明 Fake-IP DNS,同时保留 TLS 验证、重定向拒绝和私有地址检查。自定义 base URL 仅改变模型请求,不改变配额目标;`NO_PROXY` 仍使用直连策略。 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 6dd562ec93..6a02d43546 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -111,6 +111,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初次登入會匯入已安裝且已登入的 `kiro-cli` session。Unix 可用 `curl -fsSL https://cli.kiro.dev/install` | `bash` 安裝;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`,再執行 `kiro-cli login`。**Add account** 會先登出 `kiro-cli`、啟動新的 browser login,切換 `kiro-cli` 所使用的帳號並保存 account-scoped profile metadata。既有 OpenCodex 帳號會保留;取消或失敗時會恢復先前的 `kiro-cli` session。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 實驗性 PKCE 登入、即時 HTTP/2 transport 與按帳號篩選的模型探索。 | +| `devin-cli` | `devin-cli` | `https://cli.devin.ai` | 透過 Agent Client Protocol(`devin acp`,stdio 上的 JSON-RPC)驅動本機安裝的 Devin CLI。憑證由 CLI 以 `devin auth login` 自行保管,opencodex 不會儲存金鑰。可用 `OPENCODEX_DEVIN_CLI_BIN` 指定執行檔;要允許 CLI 讀寫檔案,必須明確設定 `OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1`,預設為拒絕。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 實驗性。GitHub device flow + `copilot_internal` exchange(VS Code OAuth client)。需要有效 Copilot 訂閱;不是官方第三方 API。 | Google Antigravity 帳戶與供應商的配額查詢(包括模型清單備援)使用固定的 Google 計量端點。這些目標支援透明 Fake-IP DNS,同時保留 TLS 驗證、重新導向拒絕與私有位址檢查。自訂 base URL 只改變模型請求,不改變配額目標;`NO_PROXY` 仍使用直連政策。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7cef263dd1..6f391d8a95 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -619,6 +619,7 @@ "desktop-profile.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", + "devin-cli-adapter.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", diff --git a/src/adapters/devin-cli/acp.ts b/src/adapters/devin-cli/acp.ts new file mode 100644 index 0000000000..28bd4fc6b0 --- /dev/null +++ b/src/adapters/devin-cli/acp.ts @@ -0,0 +1,204 @@ +/** + * Agent Client Protocol framing for the Devin CLI. + * + * `devin acp` speaks newline-delimited JSON-RPC on stdin/stdout. One ACP session + * answers one prompt, so a turn is: initialize -> session/new -> session/prompt, + * with session/update notifications streaming in between and a unary reply to + * the prompt carrying the stop reason and usage. + * + * This module is pure. It never spawns a process and never touches the network, + * so the framing and the event mapping are testable against captured lines, the + * same discipline src/adapters/coding-agent/protocol.ts follows for the + * stream-json CLIs. + */ +import type { AdapterEvent, OcxParsedRequest, OcxToolCall, OcxUsage } from "../../types"; + +/** Hard ceiling on a single buffered stdout line. */ +export const MAX_ACP_LINE_BYTES = 8 * 1024 * 1024; +/** Hard ceiling on total stdout bytes consumed for one turn. */ +export const MAX_ACP_TOTAL_BYTES = 64 * 1024 * 1024; + +export class AcpProtocolError extends Error { + readonly code = "protocol_error"; + readonly status = 502; + constructor(message: string) { + super(message); + this.name = "AcpProtocolError"; + } +} + +export const ACP_INITIALIZE_ID = 1; +export const ACP_SESSION_NEW_ID = 2; +export const ACP_SESSION_PROMPT_ID = 3; + +export function initializeFrame(clientVersion: string): Record { + return { + jsonrpc: "2.0", + id: ACP_INITIALIZE_ID, + method: "initialize", + params: { protocolVersion: 1, clientInfo: { name: "opencodex", version: clientVersion }, capabilities: {} }, + }; +} + +export function sessionNewFrame(cwd: string, modelId?: string): Record { + const params: Record = { cwd, mcpServers: [] }; + // The CLI picks its own default when no model is named, which is what an + // unset or vendor-default selection should do. + if (modelId) params.model = modelId; + return { jsonrpc: "2.0", id: ACP_SESSION_NEW_ID, method: "session/new", params }; +} + +export function sessionPromptFrame(sessionId: string, prompt: string): Record { + return { + jsonrpc: "2.0", + id: ACP_SESSION_PROMPT_ID, + method: "session/prompt", + params: { sessionId, prompt: [{ type: "text", text: prompt }] }, + }; +} + +/** + * Answer a permission request without a human. + * + * A headless turn has nobody to approve a tool call, and an unanswered + * `session/request_permission` stalls the agent until the turn times out. The + * answer is a refusal by default: this provider runs an agent in the operator's + * own tree, and auto-approving whatever it asks for would let any prompt that + * reaches the proxy read, write and execute there. Approval is an explicit + * operator decision, and only then is an allow-shaped option preferred over + * positional guessing — the first option in a real prompt is sometimes the + * rejection. + */ +export function permissionResponseFrame( + id: number | string, + options: Array<{ optionId?: string; name?: string; kind?: string }> | undefined, + allowed = false, +): Record { + if (!allowed) { + return { jsonrpc: "2.0", id, result: { outcome: { outcome: "cancelled" } } }; + } + const list = options ?? []; + const allow = + list.find((o) => typeof o.kind === "string" && /^allow/i.test(o.kind)) ?? + list.find((o) => /allow|accept|yes/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`)); + if (!allow?.optionId) { + // Nothing offered says "allow". Guessing at `list[0]` here is how an + // auto-answer selects a rejection and calls it approval. + return { jsonrpc: "2.0", id, result: { outcome: { outcome: "cancelled" } } }; + } + return { jsonrpc: "2.0", id, result: { outcome: { outcome: "selected", optionId: allow.optionId } } }; +} + +/** + * Flatten an OcxContext into the single prompt string one ACP session takes. + * + * ACP has no multi-message history on session/prompt, so the conversation is + * projected into labelled blocks. Tool calls and results are rendered rather + * than dropped, because a turn that omits them loses the thread of a tool loop. + */ +export function buildAcpPrompt(parsed: OcxParsedRequest): string { + const blocks: string[] = []; + const system = parsed.context.systemPrompt?.filter((line) => line.trim().length > 0).join("\n"); + if (system) blocks.push(fence("System", system)); + for (const message of parsed.context.messages) { + if (message.role === "toolResult") { + const body = typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""); + blocks.push(fence("Tool", `[result id=${message.toolCallId}]\n${body}`)); + continue; + } + const parts = typeof message.content === "string" ? [] : message.content; + let text = typeof message.content === "string" + ? message.content + : parts.map((p) => (p.type === "text" ? p.text : "")).filter(Boolean).join("\n"); + if (message.role === "assistant" && Array.isArray(parts)) { + const calls = parts + .filter((p): p is OcxToolCall => p.type === "toolCall") + .map((c) => `[call ${c.name} id=${c.id}]\n${JSON.stringify(c.arguments ?? {})}`) + .join("\n\n"); + if (calls) text = text ? `${text}\n\n${calls}` : calls; + } + if (!text.trim()) continue; + const label = message.role === "assistant" ? "Assistant" : message.role === "developer" ? "System" : "User"; + blocks.push(fence(label, text)); + } + if (blocks.length === 0) return "(empty)"; + const joined = blocks.join("\n\n"); + // Keep the oldest turns rather than the newest when trimming: the tail is + // what the agent is answering. + return joined.length > MAX_ACP_PROMPT_CHARS + ? `[truncated]\n${joined.slice(joined.length - MAX_ACP_PROMPT_CHARS)}` + : joined; +} + +export type AcpTurnOutcome = { stopReason?: string; usage?: OcxUsage }; + +/** ACP stop reasons that mean the turn ended normally. */ +const NATURAL_STOP = new Set(["end_turn", "stop", "completed"]); + +export function mapAcpStopReason(reason: unknown): string | undefined { + if (typeof reason !== "string" || NATURAL_STOP.has(reason)) return undefined; + if (reason === "max_tokens") return "max_tokens"; + return reason; +} + +export function mapAcpUsage(raw: unknown): OcxUsage | undefined { + if (!raw || typeof raw !== "object") return undefined; + const u = raw as Record; + const input = typeof u.inputTokens === "number" ? u.inputTokens : 0; + const output = typeof u.outputTokens === "number" ? u.outputTokens : 0; + if (input === 0 && output === 0) return undefined; + const total = typeof u.totalTokens === "number" ? u.totalTokens : input + output; + return { inputTokens: input, outputTokens: output, ...(total > 0 ? { totalTokens: total } : {}) }; +} + +function chunkText(content: unknown): string { + if (typeof content === "string") return content; + if (content && typeof content === "object") { + const text = (content as { text?: unknown }).text; + if (typeof text === "string") return text; + } + return ""; +} + +/** + * Translate one session/update notification into adapter events. + * + * Tool lifecycle is explicit in ACP: `tool_call` opens one and + * `tool_call_update` with a terminal status closes it, so the caller does not + * have to infer boundaries from interleaving the way a delta-only wire forces. + */ +export function acpUpdateToEvents(update: Record): AdapterEvent[] { + const kind = update.sessionUpdate; + if (kind === "agent_message_chunk") { + const text = chunkText(update.content); + return text ? [{ type: "text_delta", text }] : []; + } + if (kind === "agent_thought_chunk") { + const text = chunkText(update.content); + return text ? [{ type: "thinking_delta", thinking: text }] : []; + } + // The CLI's own tool calls are NOT client tools. Devin executes them itself + // inside its session, so emitting tool_call_start here would either fail the + // turn — the Responses bridge rejects a tool Codex never declared — or ask + // Codex to run something the agent has already run. Vendor tools stay + // internal and Codex keeps ownership of mutation, which is the same rule the + // CodeBuddy and Qoder adapters follow. + // + // They are not dropped silently, though. A Devin tool operation that runs + // longer than the bridge's stall timeout would otherwise look like upstream + // silence and get the still-working turn aborted, so an internal update + // becomes a heartbeat: proof of life without a client-visible tool. + if (kind === "tool_call" || kind === "tool_call_update" || kind === "plan" || kind === "current_mode_update") { + return [{ type: "heartbeat" }]; + } + return []; +} +/** Ceiling on the flattened conversation handed to one ACP prompt. */ +export const MAX_ACP_PROMPT_CHARS = 200_000; + +/** Fence a block label so a message body cannot forge one. */ +function fence(label: string, body: string): string { + // A user or tool result that contains a line reading `[System]` would + // otherwise appear to open a system block in the flattened prompt. + return `[${label}]\n${body.replace(/^\[(System|User|Assistant|Tool)\]/gm, " $&")}`; +} diff --git a/src/adapters/devin-cli/adapter.ts b/src/adapters/devin-cli/adapter.ts new file mode 100644 index 0000000000..1304521648 --- /dev/null +++ b/src/adapters/devin-cli/adapter.ts @@ -0,0 +1,334 @@ +/** + * Devin CLI adapter: one ACP session per turn over stdio. + * + * This is the local half of Devin support. The cloud-direct `devin` adapter + * talks to Cognition's api-server; this one drives the installed `devin` CLI, + * which carries its own credentials from `devin auth login`, so the proxy never + * sees a token for this provider. + * + * runTurn-only, like the Cursor and cloud Devin adapters: a JSON-RPC handshake + * over a child process has no fetch-shaped request to hand to the generic wire + * path. + * + * The child is treated as untrusted and unprivileged. It gets a scoped + * environment rather than the proxy's, its permission requests are refused + * unless an operator opted in, and it is reaped rather than merely signalled, + * because a Devin grandchild that ignores SIGTERM would otherwise keep writing + * in the operator's tree after the turn returned. + */ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../../types"; +import type { IncomingMeta, ProviderAdapter } from "../base"; +import { baseScopedEnv } from "../coding-agent/turn"; +import { + ACP_INITIALIZE_ID, + ACP_SESSION_NEW_ID, + ACP_SESSION_PROMPT_ID, + MAX_ACP_LINE_BYTES, + MAX_ACP_TOTAL_BYTES, + acpUpdateToEvents, + buildAcpPrompt, + initializeFrame, + mapAcpStopReason, + mapAcpUsage, + permissionResponseFrame, + sessionNewFrame, + sessionPromptFrame, +} from "./acp"; +import { DEVIN_CLI_INSTALL_HINT, resolveDevinCliBinary } from "./binary"; + +/** A turn that has not produced a prompt reply by this point is abandoned. */ +const DEVIN_CLI_TURN_TIMEOUT_MS = 10 * 60 * 1000; +/** Grace between SIGTERM and SIGKILL when reaping the child. */ +const DEVIN_CLI_KILL_GRACE_MS = 2_000; +/** How long to wait for the child to actually exit before giving up on it. */ +const DEVIN_CLI_REAP_MS = 5_000; + +/** + * Identity URL for the provider. The CLI does the real transport over stdio; + * this is only what the configuration records as the destination, and it has to + * be an http(s) URL because provider config validation rejects other schemes. + */ +export const DEVIN_CLI_IDENTITY_URL = "https://cli.devin.ai"; + +/** + * Opt-in for letting the CLI act on the machine. + * + * Off by default: this provider runs an agent in the operator's own tree, and a + * proxy that auto-approves whatever a prompt asks for is a remote shell. + */ +const DEVIN_CLI_ALLOW_TOOLS_ENV = "OPENCODEX_DEVIN_CLI_ALLOW_TOOLS"; + +export type DevinCliSpawn = (binary: string, args: string[], options: { cwd: string; env: Record }) => ChildProcessWithoutNullStreams; + +export function devinCliToolsAllowed(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = env[DEVIN_CLI_ALLOW_TOOLS_ENV]?.trim().toLowerCase(); + return raw === "1" || raw === "true" || raw === "yes"; +} + +export function createDevinCliAdapter(provider: OcxProviderConfig, deps?: { spawn?: DevinCliSpawn }): ProviderAdapter { + const spawnChild: DevinCliSpawn = deps?.spawn + ?? ((binary, args, options) => spawn(binary, args, { + ...options, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + // Give the child its own process group on POSIX so the reap below can + // signal the whole tree. Devin spawns shells and tools of its own when + // the operator allows them, and signalling only the direct pid leaves + // those descendants writing in the operator's tree after the turn ended. + detached: process.platform !== "win32", + }) as ChildProcessWithoutNullStreams); + + return { + name: "devin-cli", + + buildRequest() { + // Placeholder: this adapter never travels the fetch path. The URL is the + // provider's identity, not a destination anything connects to. + return { url: provider.baseUrl || DEVIN_CLI_IDENTITY_URL, method: "POST", headers: {}, body: "" }; + }, + + async *parseStream(): AsyncGenerator { + yield { type: "error", message: "Devin CLI adapter uses runTurn; the fetch/parseStream path is disabled." }; + }, + + async runTurn(parsed: OcxParsedRequest, incoming: IncomingMeta, emit: (event: AdapterEvent) => void) { + if (incoming.abortSignal?.aborted) { + emit({ type: "error", message: "Devin CLI turn was aborted before start." }); + return; + } + const binary = resolveDevinCliBinary(); + if (!binary) { + emit({ type: "error", message: `Devin CLI not found. ${DEVIN_CLI_INSTALL_HINT}` }); + return; + } + + const modelId = parsed.modelId.includes("/") + ? parsed.modelId.slice(parsed.modelId.lastIndexOf("/") + 1) + : parsed.modelId; + const cwd = process.env.OPENCODEX_DEVIN_CLI_CWD?.trim() || process.cwd(); + const toolsAllowed = devinCliToolsAllowed(); + + await new Promise((resolve) => { + let child: ChildProcessWithoutNullStreams; + try { + child = spawnChild(binary, ["acp"], { + cwd, + env: { + // A scoped environment, not the proxy's. The child would + // otherwise inherit every credential this process holds. + ...baseScopedEnv(), + NO_COLOR: "1", + DEVIN_PERMISSION_MODE: toolsAllowed ? (process.env.DEVIN_PERMISSION_MODE ?? "bypass") : "ask", + }, + }); + } catch (error) { + emit({ type: "error", message: `Devin CLI failed to start (${binary}): ${(error as Error).message}. ${DEVIN_CLI_INSTALL_HINT}` }); + return resolve(); + } + + let settled = false; + let closed = false; + let sawProtocolFrame = false; + let sawPromptReply = false; + let buffer = ""; + let totalBytes = 0; + let usage: OcxUsage | undefined; + let stopReason: string | undefined; + let stderrTail = ""; + + const turnTimer = setTimeout( + () => finish(`Devin CLI turn exceeded ${DEVIN_CLI_TURN_TIMEOUT_MS}ms`), + DEVIN_CLI_TURN_TIMEOUT_MS, + ); + const onAbort = () => finish("Devin CLI turn was aborted."); + + /** + * Reap the child rather than just signalling it, then resolve. + * + * `child.killed` only records that a signal was sent. Resolving on that + * lets a grandchild keep running in the operator's tree after runTurn + * returned, which is why this waits for `close` and escalates. + */ + function reapAndResolve(): void { + if (closed || child.exitCode !== null || child.signalCode !== null) return resolve(); + let done = false; + const settle = () => { + if (done) return; + done = true; + clearTimeout(killTimer); + clearTimeout(reapTimer); + resolve(); + }; + child.once("close", settle); + signalTree("SIGTERM"); + const killTimer = setTimeout(() => signalTree("SIGKILL"), DEVIN_CLI_KILL_GRACE_MS); + const reapTimer = setTimeout(settle, DEVIN_CLI_REAP_MS); + } + + /** + * Signal the child's whole process group where the platform has one. + * Devin launches shells and tools of its own once the operator allows + * them, and those descendants do not receive a signal aimed at the + * direct pid. Falls back to the single process when the group send is + * unavailable or the group is already gone. + */ + function signalTree(signal: NodeJS.Signals): void { + const pid = child.pid; + if (pid !== undefined && process.platform !== "win32") { + try { + process.kill(-pid, signal); + return; + } catch { /* no group, or already reaped - fall through */ } + } + try { child.kill(signal); } catch { /* already gone */ } + } + + /** Terminate the turn exactly once, with an error when given a reason. */ + function finish(errorMessage?: string): void { + if (settled) return; + settled = true; + clearTimeout(turnTimer); + incoming.abortSignal?.removeEventListener("abort", onAbort); + child.stdout.destroy(); + if (errorMessage) emit({ type: "error", message: errorMessage, ...(usage ? { usage } : {}) }); + else emit({ type: "done", ...(usage ? { usage } : {}), ...(stopReason ? { stopReason } : {}) }); + reapAndResolve(); + } + + incoming.abortSignal?.addEventListener("abort", onAbort, { once: true }); + // The signal can fire between the pre-spawn check and this listener. + if (incoming.abortSignal?.aborted) return finish("Devin CLI turn was aborted."); + + const send = (frame: Record): void => { + if (!child.stdin.destroyed) child.stdin.write(`${JSON.stringify(frame)}\n`); + }; + // EPIPE after the child is killed is an ordinary race, not a crash. + child.stdin.on("error", () => {}); + + child.on("error", (err) => finish(`Devin CLI failed to start (${binary}): ${err.message}. ${DEVIN_CLI_INSTALL_HINT}`)); + + child.on("close", (code) => { + closed = true; + if (settled) return; + // Flush a final frame that arrived without a trailing newline before + // deciding the turn failed: the prompt reply carrying usage and the + // stop reason is often the last line written. + flush(buffer); + buffer = ""; + if (settled) return; + // A close without a prompt reply is a failure, not an empty success. + const detail = stderrTail.trim().slice(-400); + finish( + `Devin CLI exited (code ${code ?? "null"}) before answering the prompt` + + (detail ? `: ${detail}` : "."), + ); + }); + + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + // Bounded: diagnostics are for the error message, not a buffer to grow. + stderrTail = (stderrTail + chunk).slice(-4096); + }); + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + if (settled) return; + totalBytes += Buffer.byteLength(chunk, "utf8"); + if (totalBytes > MAX_ACP_TOTAL_BYTES) return finish("Devin CLI produced more output than one turn may consume."); + buffer += chunk; + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + flush(line); + if (settled) return; + } + if (Buffer.byteLength(buffer, "utf8") > MAX_ACP_LINE_BYTES) { + finish("Devin CLI emitted a single line larger than the frame cap."); + } + }); + + // The prompt reply carrying usage and the stop reason is often the last + // thing written, and it is not guaranteed to end with a newline. Flush + // the remainder when the stream ends rather than waiting for the child + // to exit and then calling a complete turn a failure. + child.stdout.on("end", () => { + const tail = buffer; + buffer = ""; + flush(tail); + }); + + function flush(raw: string): void { + const line = raw.trim(); + if (!line || settled) return; + let frame: Record; + try { + frame = JSON.parse(line) as Record; + } catch { + // The CLI prints a banner before the protocol starts, so plain text + // is expected up to the first valid frame. After that the stream is + // protocol, and a line that is shaped like a frame but does not + // parse is corruption: dropping it silently loses a session/update + // or lets the turn wait out the timeout for a reply that already + // arrived damaged. + if (sawProtocolFrame || line.startsWith("{")) { + finish("Devin CLI emitted a malformed ACP frame."); + } + return; + } + sawProtocolFrame = true; + handle(frame); + } + + /** JSON-RPC ids are allowed to come back as strings. */ + const idOf = (value: unknown): number | undefined => { + if (typeof value === "number") return value; + if (typeof value === "string" && /^\d+$/.test(value)) return Number(value); + return undefined; + }; + + function handle(frame: Record): void { + if (settled) return; + const id = idOf(frame.id); + const error = frame.error as { message?: string } | undefined; + + if (id === ACP_INITIALIZE_ID) { + if (error) return finish(`Devin CLI initialize failed: ${error.message ?? "unknown error"}`); + send(sessionNewFrame(cwd, modelId)); + return; + } + if (id === ACP_SESSION_NEW_ID) { + if (error) return finish(`Devin CLI session/new failed: ${error.message ?? "unknown error"}`); + const sessionId = (frame.result as { sessionId?: string } | undefined)?.sessionId; + if (!sessionId) return finish("Devin CLI session/new returned no sessionId."); + send(sessionPromptFrame(sessionId, buildAcpPrompt(parsed))); + return; + } + if (frame.method === "session/request_permission" && frame.id != null) { + const params = frame.params as { options?: Array<{ optionId?: string; name?: string; kind?: string }> } | undefined; + send(permissionResponseFrame(frame.id as number | string, params?.options, toolsAllowed)); + return; + } + if (frame.method === "session/update") { + const update = (frame.params as { update?: Record } | undefined)?.update; + if (!update) return; + for (const event of acpUpdateToEvents(update)) emit(event); + return; + } + if (id === ACP_SESSION_PROMPT_ID) { + if (error) return finish(`Devin CLI session/prompt failed: ${error.message ?? "unknown error"}`); + sawPromptReply = true; + const result = frame.result as { stopReason?: unknown; usage?: unknown } | undefined; + usage = mapAcpUsage(result?.usage) ?? usage; + stopReason = mapAcpStopReason(result?.stopReason); + finish(); + } + } + + void sawPromptReply; + send(initializeFrame(process.env.OPENCODEX_VERSION ?? "0.0.0")); + }); + }, + }; +} diff --git a/src/adapters/devin-cli/binary.ts b/src/adapters/devin-cli/binary.ts new file mode 100644 index 0000000000..7435e262f1 --- /dev/null +++ b/src/adapters/devin-cli/binary.ts @@ -0,0 +1,69 @@ +/** + * Locate the Devin CLI. + * + * The official installer (`curl -fsSL https://cli.devin.ai/install.sh | bash`) + * and the Homebrew cask both drop the binary in one of a small set of places. + * The environment override comes first so an operator can point at a specific + * build without touching PATH, and PATH is the last resort rather than the + * first so a shadowed name cannot silently win. + */ +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { homedir } from "node:os"; + +export const DEVIN_CLI_BIN_ENV = "OPENCODEX_DEVIN_CLI_BIN"; + +export const DEVIN_CLI_INSTALL_HINT = + "Install the Devin CLI with `curl -fsSL https://cli.devin.ai/install.sh | bash` or `brew install --cask devin-cli`, then run `devin auth login`."; + +let cached: string | undefined; + +/** Reset the discovery cache (tests, or an explicit re-check after an install). */ +export function clearDevinCliBinaryCache(): void { + cached = undefined; +} + +function candidatePaths(home: string): string[] { + return [ + join(home, "AppData", "Local", "Microsoft", "WinGet", "Links", "devin.exe"), + join(home, ".local", "share", "devin", "bin", "devin"), + join(home, ".devin", "bin", "devin"), + join(home, ".local", "bin", "devin"), + "/opt/homebrew/bin/devin", + "/usr/local/bin/devin", + "/usr/bin/devin", + ]; +} + +function fromPath(exists: (p: string) => boolean): string | undefined { + const pathVar = process.env.PATH ?? ""; + for (const dir of pathVar.split(delimiter)) { + if (!dir) continue; + for (const name of ["devin", "devin.exe"]) { + const full = join(dir, name); + if (exists(full)) return full; + } + } + return undefined; +} + +/** + * Resolve the executable, or undefined when it is not installed. + * + * `exists` and `home` are seams so the resolution order can be tested without + * depending on what happens to be installed on the machine running the tests. + */ +export function resolveDevinCliBinary(opts?: { exists?: (p: string) => boolean; home?: string; useCache?: boolean }): string | undefined { + const exists = opts?.exists ?? existsSync; + const useCache = opts?.useCache ?? opts === undefined; + if (useCache && cached) return cached; + const override = process.env[DEVIN_CLI_BIN_ENV]?.trim(); + if (override) { + if (useCache) cached = override; + return override; + } + const home = opts?.home ?? homedir(); + const found = candidatePaths(home).find((p) => exists(p)) ?? fromPath(exists); + if (found && useCache) cached = found; + return found; +} diff --git a/src/adapters/devin-cli/models.ts b/src/adapters/devin-cli/models.ts new file mode 100644 index 0000000000..ea614c5d66 --- /dev/null +++ b/src/adapters/devin-cli/models.ts @@ -0,0 +1,23 @@ +/** + * Models the Devin CLI accepts on `session/new`. + * + * The CLI picks its own default when no model is named, so this roster exists + * for the picker rather than as a gate. It is a static list on purpose: ACP has + * no discovery call, and the vendor roster moves faster than a pinned copy + * would, so an unknown id is passed through to the CLI to accept or refuse. + */ +export const DEVIN_CLI_DEFAULT_MODEL = "swe-2"; + +export const DEVIN_CLI_MODELS = [ + "swe-2", + "swe-2-high", + "claude-opus-5-medium", + "claude-fable-5-1-medium", + "claude-sonnet-5-medium", + "gpt-6-astra-medium", + "gpt-5-6-sol-medium", + "gemini-3-8-flash-medium", + "glm-5-3-high", + "glm-5-3-low", + "kimi-k3-high", +] as const; diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index d8edbead92..6ed9674c9f 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -6,6 +6,7 @@ import { createCodeBuddyAdapter } from "./codebuddy/adapter"; import { createQoderAdapter } from "./qoder/adapter"; import { createCommandCodeAdapter } from "./command-code"; import { createCursorAdapter } from "./cursor"; +import { createDevinCliAdapter } from "./devin-cli/adapter"; import { createGoogleAdapter } from "./google"; import { createKiroAdapter } from "./kiro"; import { createMimoFreeAdapter } from "./mimo-free"; @@ -30,7 +31,8 @@ export type AdapterWire = | "openai-responses" | "google" | "kiro" - | "cursor"; + | "cursor" + | "devin-cli"; export type AdapterMutationContract = | "codex-owned" @@ -112,6 +114,11 @@ export const ADAPTER_REGISTRY = { mutation: "codex-owned-with-gated-native-fallback", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createCursorAdapter(provider), }, + "devin-cli": { + wire: "devin-cli", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createDevinCliAdapter(provider), + }, "mimo-free": { contractParent: "openai-chat", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index fb9db46101..f72bb7650b 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,6 +1,7 @@ import type { CodexAccountMode, FastWire, OcxProviderConfig } from "../types"; import { fastWireDeclarationError } from "./fastwire"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; +import { DEVIN_CLI_DEFAULT_MODEL, DEVIN_CLI_MODELS } from "../adapters/devin-cli/models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "./antigravity-models"; import type { ProviderBaseUrlChoice } from "./base-url-choices"; import { @@ -1276,6 +1277,27 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // still advertises image for noVision members so Codex can attach (sidecar option B). noVisionModels: [...CURSOR_NO_VISION_MODELS], }, + { + // Drives the locally installed Devin CLI. The CLI owns its own credentials + // from `devin auth login`, so this provider takes no key and the proxy never + // holds one. Inference happens in the child process, which is why the + // destination is a stdio scheme rather than a URL. + id: "devin-cli", + label: "Devin CLI (local)", + adapter: "devin-cli", + // A canonical identity URL, not a transport. The CLI performs the real + // transport over stdio; this is the destination the config records, and it + // has to be an http(s) URL because providerBaseUrlConfigError rejects any + // other scheme — a `devin://` destination made the generated config + // unloadable. Same shape as the other CLI-backed providers. + baseUrl: "https://cli.devin.ai", + authKind: "local", + featured: false, + dashboardPreset: false, + note: "Drives the locally installed Devin CLI over the Agent Client Protocol (`devin acp`, newline-delimited JSON-RPC on stdio). Requires the CLI on PATH and a completed `devin auth login`; no API key is stored by opencodex. Set OPENCODEX_DEVIN_CLI_BIN to point at a specific build, and OPENCODEX_DEVIN_CLI_ALLOW_TOOLS=1 to let the CLI read and write files — the default is to refuse.", + models: [...DEVIN_CLI_MODELS], + defaultModel: DEVIN_CLI_DEFAULT_MODEL, + }, { id: "xai", label: "xAI Grok", diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 2677864454..3ce81accbf 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -14,6 +14,7 @@ export function upstreamProtocolForAdapter(adapter: string): string { case "openai-chat": case "command-code": case "cursor": + case "devin-cli": case "azure": case "azure-openai": case "kiro": diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 864e14dc78..1ae7440a76 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -13,6 +13,11 @@ Some adapters share another adapter's routed-tool semantics while retaining inde - `azure` and `azure-openai` inherit the `openai-responses` contract. - `mimo-free` inherits the `openai-chat` contract. - `cursor` stays direct because its `runTurn` transport and gated native-file fallback are distinct. +- `devin-cli` stays direct for the same reason, one layer further out: it has no HTTP transport at + all. The turn runs as an Agent Client Protocol session against a local `devin acp` child process, + so `buildRequest` returns a placeholder and `parseStream` is disabled. Its registry `baseUrl` is a + canonical identity URL rather than a destination anything connects to, which is what keeps the + generated configuration loadable: `providerBaseUrlConfigError` accepts only `http(s)` schemes. The registry records those relationships with `contractParent`. A parent relationship does **not** mean the registry recursively constructs a parent adapter and injects it into the child. Azure and MiMo keep owning their existing internal composition. This avoids making production constructors depend on test/conformance needs and keeps this authority refactor behavior-neutral. diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index d7bac03afe..e7e8f60b4f 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -21,6 +21,7 @@ const EXPECTED_ADAPTER_NAMES = { azure: "azure-openai", "azure-openai": "azure-openai", cursor: "cursor", + "devin-cli": "devin-cli", "mimo-free": "mimo-free", qoder: "qoder", } as const; diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts index 91fd5a399b..284add7992 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -420,10 +420,14 @@ describe("registry-derived routed tool conformance", () => { }); const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder"]); + // devin-cli drives a local CLI over ACP stdio: buildRequest returns a + // placeholder and tools never travel the wire path. + const RUN_TURN_ONLY_WIRES = new Set(["devin-cli"]); test("every registered adapter keeps the nested apply_patch helper in its final request", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const body = await outbound(adapterId, codeModeParsed(contract.wire)); const advertised = advertisedToolNames(contract.wire, body); @@ -438,6 +442,7 @@ describe("registry-derived routed tool conformance", () => { test("tool_choice none disables every registered adapter's callable tool surface", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const enabledBody = await outbound(adapterId, toolChoiceParsed(contract.wire)); expect(advertisedToolNames(contract.wire, enabledBody).length, `${adapterId}:enabled`).toBeGreaterThan(0); @@ -449,12 +454,13 @@ describe("registry-derived routed tool conformance", () => { test("every parsed streaming wire restores hostile freeform input exactly", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; if (!driver.streamingToolCall) { // OpenAI Responses is a normal passthrough here and only parses routed compaction; // Cursor's proprietary runTurn stream has focused parser coverage elsewhere. - expect(["openai-responses", "cursor"]).toContain(contract.wire); + expect(["openai-responses", "cursor", "devin-cli"]).toContain(contract.wire); continue; } expect(await restoredStreamInput(adapterId, contract.wire), adapterId).toBe(PATCH); @@ -464,8 +470,9 @@ describe("registry-derived routed tool conformance", () => { test("every buffered adapter preserves same-name tools from different namespaces", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); - if (contract.wire === "openai-responses" || contract.wire === "cursor") { + if (contract.wire === "openai-responses" || contract.wire === "cursor" || contract.wire === "devin-cli") { // Native Responses passthrough and Cursor's protobuf transport do not use the routed // adapter tool declaration surface exercised by this registry-wide check. continue; @@ -479,8 +486,9 @@ describe("registry-derived routed tool conformance", () => { test("every routed adapter fails closed for an ambiguous bare selector", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); - if (contract.wire === "openai-responses" || contract.wire === "cursor") continue; + if (contract.wire === "openai-responses" || contract.wire === "cursor" || contract.wire === "devin-cli") continue; const parsed = namespacedCollisionParsed(contract.wire); // parseRequest rejects this shape for real inbound traffic; keeping the policy mutation here // also proves each adapter remains fail-closed when a caller reaches it with a prebuilt AST. @@ -505,10 +513,11 @@ describe("registry-derived routed tool conformance", () => { test("every streaming adapter restores namespaced custom/function collisions distinctly", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; if (!driver.streamingToolCall || !driver.extractWireToolName) { - expect(["openai-responses", "cursor"]).toContain(contract.wire); + expect(["openai-responses", "cursor", "devin-cli"]).toContain(contract.wire); continue; } @@ -548,6 +557,7 @@ describe("registry-derived routed tool conformance", () => { test("every registered adapter replays the exact apply_patch input on continuation", async () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; + if (RUN_TURN_ONLY_WIRES.has(effectiveAdapterContract(adapterId).wire)) continue; const contract = effectiveAdapterContract(adapterId); const body = await outbound(adapterId, continuationParsed(contract.wire)); expect(continuationInput(contract.wire, body), adapterId).toBe(PATCH); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 39bcb31f19..90b71a5483 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -454,6 +454,7 @@ "desktop-profile.test.ts": "clients", "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", + "devin-cli-adapter.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", diff --git a/tests/providers/devin-cli-adapter.test.ts b/tests/providers/devin-cli-adapter.test.ts new file mode 100644 index 0000000000..270832cfa1 --- /dev/null +++ b/tests/providers/devin-cli-adapter.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test } from "bun:test"; +import { + ACP_SESSION_NEW_ID, + acpUpdateToEvents, + buildAcpPrompt, + initializeFrame, + mapAcpStopReason, + mapAcpUsage, + permissionResponseFrame, + sessionNewFrame, + sessionPromptFrame, +} from "../../src/adapters/devin-cli/acp"; +import { DEVIN_CLI_BIN_ENV, resolveDevinCliBinary } from "../../src/adapters/devin-cli/binary"; +import { createDevinCliAdapter } from "../../src/adapters/devin-cli/adapter"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import type { AdapterEvent, OcxParsedRequest } from "../../src/types"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; + +describe("devin-cli registration", () => { + test("is a local provider that stores no credential", () => { + const entry = PROVIDER_REGISTRY.find((row) => row.id === "devin-cli"); + expect(entry?.adapter).toBe("devin-cli"); + // The installed CLI carries its own credentials from `devin auth login`, + // so the proxy must never ask for or hold a key for this provider. + expect(entry?.authKind).toBe("local"); + expect(entry?.dashboardPreset).toBe(false); + expect(createDevinCliAdapter({ adapter: "devin-cli", baseUrl: "https://cli.devin.ai" }).name).toBe("devin-cli"); + }); +}); + +describe("acp handshake frames", () => { + test("initialize declares protocol 1 and session/new carries cwd", () => { + expect(initializeFrame("1.2.3")).toMatchObject({ + jsonrpc: "2.0", + method: "initialize", + params: { protocolVersion: 1, clientInfo: { name: "opencodex", version: "1.2.3" } }, + }); + const withModel = sessionNewFrame("/repo", "swe-2") as { id: number; params: Record }; + expect(withModel.id).toBe(ACP_SESSION_NEW_ID); + expect(withModel.params).toEqual({ cwd: "/repo", mcpServers: [], model: "swe-2" }); + // No model named means the CLI picks its own default, so the key is absent + // rather than present and empty. + expect((sessionNewFrame("/repo") as { params: Record }).params).toEqual({ cwd: "/repo", mcpServers: [] }); + expect(sessionPromptFrame("s1", "hi")).toMatchObject({ + method: "session/prompt", + params: { sessionId: "s1", prompt: [{ type: "text", text: "hi" }] }, + }); + }); + + test("a permission request is refused unless the operator opted in", () => { + // This provider runs an agent in the operator's own tree. Auto-approving + // whatever a prompt asks for would make the proxy a remote shell. + const options = [ + { optionId: "no", name: "Reject", kind: "reject_once" }, + { optionId: "yes", name: "Approve", kind: "allow_once" }, + ]; + expect(permissionResponseFrame(9, options)).toMatchObject({ result: { outcome: { outcome: "cancelled" } } }); + const allowed = permissionResponseFrame(9, options, true) as { result: { outcome: { optionId: string } } }; + // Positional guessing would have taken the reject here. + expect(allowed.result.outcome.optionId).toBe("yes"); + expect( + (permissionResponseFrame(9, [{ optionId: "accept-all", name: "Accept" }], true) as { result: { outcome: { optionId: string } } }) + .result.outcome.optionId, + ).toBe("accept-all"); + // Nothing on offer says allow, so approving would mean selecting a + // rejection and calling it approval. + expect(permissionResponseFrame(9, [{ optionId: "no", kind: "reject_once" }], true)).toMatchObject({ + result: { outcome: { outcome: "cancelled" } }, + }); + expect(permissionResponseFrame(9, undefined, true)).toMatchObject({ result: { outcome: { outcome: "cancelled" } } }); + }); +}); + +describe("acp prompt projection", () => { + test("system, tool calls and tool results all survive the flattening", () => { + const parsed = { + modelId: "swe-2", + stream: true, + context: { + systemPrompt: ["be brief"], + messages: [ + { role: "user", content: "hi", timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "text", text: "looking" }, + { type: "toolCall", id: "c1", name: "lookup", arguments: { q: "x" } }, + ], + timestamp: 2, + }, + { role: "toolResult", toolCallId: "c1", toolName: "lookup", content: "ok", isError: false, timestamp: 3 }, + ], + tools: [], + }, + options: {}, + } as unknown as OcxParsedRequest; + const prompt = buildAcpPrompt(parsed); + expect(prompt).toContain("[System]\nbe brief"); + expect(prompt).toContain("[User]\nhi"); + // ACP takes one string, so a dropped tool loop would lose the thread. + expect(prompt).toContain("[call lookup id=c1]"); + expect(prompt).toContain('{"q":"x"}'); + expect(prompt).toContain("[result id=c1]"); + expect(buildAcpPrompt({ ...parsed, context: { ...parsed.context, systemPrompt: [], messages: [] } })).toBe("(empty)"); + }); +}); + +describe("acp update mapping", () => { + test("message and thought chunks map to their own channels", () => { + expect(acpUpdateToEvents({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "a" } })).toEqual([ + { type: "text_delta", text: "a" }, + ]); + expect(acpUpdateToEvents({ sessionUpdate: "agent_thought_chunk", content: "why" })).toEqual([ + { type: "thinking_delta", thinking: "why" }, + ]); + expect(acpUpdateToEvents({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "" } })).toEqual([]); + }); + + test("the CLI's own tool calls become heartbeats, never Codex client tools", () => { + // Devin executes these itself inside its session. Emitting tool_call_start + // would either fail the turn, because the bridge rejects a tool Codex never + // declared, or ask Codex to run something the agent already ran. Dropping + // them outright is not right either: a long internal tool operation would + // read as upstream silence and get the working turn stall-aborted. + expect(acpUpdateToEvents({ sessionUpdate: "tool_call", toolCallId: "t1", title: "read", rawInput: { path: "a" } })).toEqual([ + { type: "heartbeat" }, + ]); + expect(acpUpdateToEvents({ sessionUpdate: "tool_call_update", toolCallId: "t1", status: "completed" })).toEqual([ + { type: "heartbeat" }, + ]); + expect(acpUpdateToEvents({ sessionUpdate: "plan", entries: [] })).toEqual([{ type: "heartbeat" }]); + expect(acpUpdateToEvents({ sessionUpdate: "something_new" })).toEqual([]); + }); + +}); + +describe("acp turn outcome", () => { + test("a natural end carries no stopReason", () => { + // The bridge reads any truthy stopReason as "this turn did not finish", so + // reporting end_turn would cost every clean turn its final_answer phase. + expect(mapAcpStopReason("end_turn")).toBeUndefined(); + expect(mapAcpStopReason(undefined)).toBeUndefined(); + expect(mapAcpStopReason("max_tokens")).toBe("max_tokens"); + expect(mapAcpStopReason("refusal")).toBe("refusal"); + }); + + test("usage is reported only when the agent actually counted something", () => { + expect(mapAcpUsage({ inputTokens: 10, outputTokens: 4 })).toEqual({ inputTokens: 10, outputTokens: 4, totalTokens: 14 }); + expect(mapAcpUsage({ inputTokens: 1, outputTokens: 2, totalTokens: 9 })).toEqual({ + inputTokens: 1, + outputTokens: 2, + totalTokens: 9, + }); + expect(mapAcpUsage({ inputTokens: 0, outputTokens: 0 })).toBeUndefined(); + expect(mapAcpUsage(undefined)).toBeUndefined(); + }); +}); + +describe("devin cli discovery", () => { + test("the environment override wins over every install path", () => { + const previous = process.env[DEVIN_CLI_BIN_ENV]; + process.env[DEVIN_CLI_BIN_ENV] = "/custom/devin"; + try { + expect(resolveDevinCliBinary({ exists: () => true, home: "/home/u", useCache: false })).toBe("/custom/devin"); + } finally { + if (previous === undefined) delete process.env[DEVIN_CLI_BIN_ENV]; + else process.env[DEVIN_CLI_BIN_ENV] = previous; + } + }); + + test("known install paths are preferred over a shadowed PATH entry, and absence is undefined", () => { + const previous = process.env[DEVIN_CLI_BIN_ENV]; + delete process.env[DEVIN_CLI_BIN_ENV]; + try { + const only = (p: string) => p === "/home/u/.local/bin/devin"; + expect(resolveDevinCliBinary({ exists: only, home: "/home/u", useCache: false })).toBe("/home/u/.local/bin/devin"); + expect(resolveDevinCliBinary({ exists: () => false, home: "/home/u", useCache: false })).toBeUndefined(); + } finally { + if (previous !== undefined) process.env[DEVIN_CLI_BIN_ENV] = previous; + } + }); +}); + +describe("devin-cli runTurn", () => { + // A fake ACP child: stdin collects the frames the adapter sends, stdout is a + // script the test pushes. This is the seam the coding-agent family uses, and + // without it the abort, crash and post-terminal paths cannot fail CI. + function fakeChild() { + const stdinWrites: string[] = []; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as unknown as ChildProcessWithoutNullStreams & { exitCode: number | null; signalCode: string | null; killed: boolean }; + Object.assign(child, { + stdout, + stderr, + stdin: Object.assign(new PassThrough(), { + write: (chunk: string) => { stdinWrites.push(String(chunk)); return true; }, + destroyed: false, + }), + exitCode: null, + signalCode: null, + killed: false, + // A real child emits close after being signalled; the adapter waits for + // that rather than trusting `killed`, so the fake has to as well. + kill: () => { + (child as { killed: boolean }).killed = true; + queueMicrotask(() => child.emit("close", null)); + return true; + }, + }); + return { child, stdout, stdinWrites }; + } + + const parsed = { + modelId: "swe-2", + stream: true, + context: { systemPrompt: [], messages: [{ role: "user", content: "hi", timestamp: 1 }], tools: [] }, + options: {}, + } as unknown as OcxParsedRequest; + + async function run(script: (stdout: PassThrough, child: EventEmitter) => void) { + const { child, stdout, stdinWrites } = fakeChild(); + const events: AdapterEvent[] = []; + process.env[DEVIN_CLI_BIN_ENV] = "/fake/devin"; + const adapter = createDevinCliAdapter({ adapter: "devin-cli", baseUrl: "https://cli.devin.ai" }, { + spawn: () => { queueMicrotask(() => script(stdout, child as unknown as EventEmitter)); return child; }, + }); + await adapter.runTurn!(parsed, {} as never, (e) => events.push(e)); + delete process.env[DEVIN_CLI_BIN_ENV]; + return { events, stdinWrites }; + } + + test("a complete handshake produces exactly one terminal event, carrying usage", async () => { + const { events, stdinWrites } = await run((stdout) => { + stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\n'); + queueMicrotask(() => { + stdout.write('{"jsonrpc":"2.0","id":2,"result":{"sessionId":"s1"}}\n'); + queueMicrotask(() => { + stdout.write('{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}\n'); + // The prompt reply arrives WITHOUT a trailing newline, and more + // output follows it. Both used to break this adapter. + stdout.write('{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn","usage":{"inputTokens":3,"outputTokens":1}}}'); + stdout.end(); + }); + }); + }); + const terminals = events.filter((e) => e.type === "done" || e.type === "error"); + expect(terminals).toHaveLength(1); + expect(terminals[0]).toMatchObject({ type: "done", usage: { inputTokens: 3, outputTokens: 1, totalTokens: 4 } }); + // A natural end reports no stopReason. + expect((terminals[0] as { stopReason?: string }).stopReason).toBeUndefined(); + expect(events.filter((e) => e.type === "text_delta")).toEqual([{ type: "text_delta", text: "PONG" }]); + expect(stdinWrites.join("")).toContain('"method":"session/prompt"'); + }); + + test("a crash before the prompt reply is an error, not an empty success", async () => { + const { events } = await run((stdout, child) => { + stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\n'); + queueMicrotask(() => { + child.emit("close", 1); + }); + }); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("error"); + expect((events[0] as { message: string }).message).toMatch(/exited \(code 1\) before answering/); + }); + + test("a session/new failure reports the CLI's reason", async () => { + const { events } = await run((stdout) => { + stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\n'); + queueMicrotask(() => { + stdout.write('{"jsonrpc":"2.0","id":2,"error":{"message":"not authenticated"}}\n'); + }); + }); + expect(events).toHaveLength(1); + expect((events[0] as { message: string }).message).toMatch(/session\/new failed: not authenticated/); + }); + + test("a malformed frame after the protocol starts fails the turn instead of vanishing", async () => { + // A banner line before the first frame is expected noise. A broken frame + // afterwards is corruption: swallowing it loses output, or waits out the + // ten-minute timeout for a reply that already arrived damaged. + const { events } = await run((stdout) => { + stdout.write("Devin CLI v3000.10.21\n"); + stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\n'); + queueMicrotask(() => stdout.write('{"jsonrpc":"2.0","id":2,"result":{"sessionId"\n')); + }); + expect(events).toHaveLength(1); + expect((events[0] as { message: string }).message).toMatch(/malformed ACP frame/); + }); +});