From f97612c9567197a4a2e83380e953a533c36819a8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 23:09:48 +0900 Subject: [PATCH 1/2] refactor(devin): retire the ACP adapter and give the shared adapter the tool-catalog nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Devin provider rows already stream Cognition's Connect-RPC api-server on the `devin` adapter. They differ only in where the credential came from: a browser sign-in through RegisterUser, or the `devin-session-token` the installed CLI already wrote to its own credentials.toml. A second adapter registered under the id `devin-cli` still spawned `devin acp` and drove the child over Agent Client Protocol on stdio. Nothing routed to it: `routedProviderConfig` pins the adapter from the registry for any registry id, so only a custom-named row such as `"devin-acp"` could select it. It is removed rather than kept, because the premise that justified it was false. The CLI's credential is the ordinary cloud token, so importing it does everything the child did without a placeholder `buildRequest`, a disabled `parseStream`, an identity-only `baseUrl` no request may connect to, and a subprocess running in the operator's own tree. `projectDevinCliAuthMode` used to warn and change nothing when a saved row still named that adapter, reasoning that routing already pinned the transport. That held only for the registry id. With the adapter gone a custom-named row has nothing pinning it and would throw `Unknown adapter: devin-cli` on every request, so the migration now rewrites every row naming the retired id whatever the row is called, and repoints a row still carrying the identity-only `cli.devin.ai` host at the api-server in the same pass. The nudge is the other half. Every non-OpenAI adapter that advertises a client tool catalog injects `buildNonOpenAIToolCatalogNudgeForTools` into its system prompt; Devin advertises a real catalog on proto field #10 and was the only one without the paragraph. It goes into `mapOcxMessagesToDevin`, which covers both provider rows at once. The wire-name callback is `tool => tool.name` rather than the default namespaced form, because `mapOcxToolsToDevin` writes the bare name — a nudge listing names the model is never offered is worse than none. The ACP wire could never have carried it: `session/prompt` takes prompt text only, with `capabilities: {}` and `mcpServers: []`. --- .../260912_devin_acp_removal/000_plan.md | 76 ++++ .../src/content/docs/fr/guides/providers.md | 2 +- .../src/content/docs/guides/providers.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../src/content/docs/reference/adapters.md | 25 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../src/content/docs/tr/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 2 +- gui/src/provider-icons.ts | 8 +- scripts/test-layout/layout.json | 1 - src/adapters/devin-cli/acp.ts | 204 ----------- src/adapters/devin-cli/adapter.ts | 345 ------------------ src/adapters/devin-cli/binary.ts | 69 ---- src/adapters/devin-cli/models.ts | 57 --- src/adapters/devin.ts | 16 +- src/adapters/registry.ts | 7 - src/oauth/devin-cli.ts | 12 +- src/providers/devin-cli-authmode-migration.ts | 85 +++-- src/providers/registry.ts | 10 +- src/routing/compatibility/behavior.ts | 1 - structure/adapters/registry.md | 28 +- .../adapter-registry-authority.test.ts | 1 - .../adapters/adapter-tool-conformance.test.ts | 7 +- tests/fixtures/test-layout-expected.json | 3 +- tests/providers/devin-adapter.test.ts | 43 ++- tests/providers/devin-cli-adapter.test.ts | 342 ----------------- .../devin-cli-authmode-migration.test.ts | 36 +- 29 files changed, 274 insertions(+), 1118 deletions(-) create mode 100644 devlog/_plan/260912_devin_acp_removal/000_plan.md delete mode 100644 src/adapters/devin-cli/acp.ts delete mode 100644 src/adapters/devin-cli/adapter.ts delete mode 100644 src/adapters/devin-cli/binary.ts delete mode 100644 src/adapters/devin-cli/models.ts delete mode 100644 tests/providers/devin-cli-adapter.test.ts diff --git a/devlog/_plan/260912_devin_acp_removal/000_plan.md b/devlog/_plan/260912_devin_acp_removal/000_plan.md new file mode 100644 index 0000000000..5733dba051 --- /dev/null +++ b/devlog/_plan/260912_devin_acp_removal/000_plan.md @@ -0,0 +1,76 @@ +# 260912 — Retire the Devin ACP adapter and give Devin the tool-catalog nudge + +## Why this unit exists + +Two Devin provider rows exist, `devin` and `devin-cli`, and both stream Cognition's +`ApiServerService/GetChatMessage` over Connect-RPC on the `devin` adapter. They differ only in +where the credential came from: a browser sign-in through `RegisterUser`, or the +`devin-session-token` the installed CLI already wrote to its own `credentials.toml`. + +A second adapter registered under the id `devin-cli` still existed. It spawned `devin acp` and +drove the child over Agent Client Protocol on stdio. It was unreachable under the `devin-cli` +provider id — `routedProviderConfig` pins the adapter from the registry for any registry id — and +reachable only through a custom-named row such as `"devin-acp"`. Nobody was routed to it. + +It is being removed rather than kept, because the premise that justified it turned out to be +false. The design assumed OpenCodex could not hold a credential for the installed CLI, so a child +process was the only way to use it. The CLI's `windsurf_api_key` is an ordinary +`devin-session-token$`, the same credential the cloud client already speaks. Importing the +token does everything the child did, without a placeholder `buildRequest`, a disabled +`parseStream`, an identity-only `baseUrl` that no request may connect to, and a subprocess +running in the operator's own tree. + +The nudge is the second half. Every non-OpenAI adapter that advertises a client tool catalog +injects `buildNonOpenAIToolCatalogNudgeForTools` into its system prompt — Anthropic, Google, +non-OpenAI `openai-chat` hosts, Kiro, Command Code. The Devin adapter does advertise a real +catalog (proto field #10 via `mapOcxToolsToDevin`) and was the only one left without the +paragraph. Adding it in `mapOcxMessagesToDevin` covers both provider rows at once, because they +share the adapter. The retired ACP wire could never have used it: `session/prompt` carries prompt +text only, with `capabilities: {}` and `mcpServers: []`, so a catalog nudge there would have +described a contract that does not exist on that wire. + +## Work phases + +### wp1 — land PR #4411 + +Unrelated in subject, but it is the open PR blocking this branch's base from being clean. Its +`test 3/4`, `gates` and `macos 2/2` failures were one cause: `privacy:scan` flagged a maintainer +email address quoted inside a carried devlog record. The address was incidental to the note. + +Done when: exact-head CI is green and the PR is merged into `dev`. + +### wp2 — retire ACP, migrate, nudge + +Removals: + +- `src/adapters/devin-cli/{acp,adapter,binary,models}.ts` +- `tests/providers/devin-cli-adapter.test.ts`, and its rows in `scripts/test-layout/layout.json` + and `tests/fixtures/test-layout-expected.json` +- the `devin-cli` import, `AdapterWire` member and registry entry in `src/adapters/registry.ts` +- the `devin-cli` case in `upstreamProtocolForAdapter` +- the `devin-cli` row in the adapter-registry authority map, and the wire from + `RUN_TURN_ONLY_WIRES` + +Migration. `projectDevinCliAuthMode` previously warned and changed nothing when a saved row still +named the ACP adapter, on the reasoning that routing already pinned the transport. That reasoning +held only for the registry id. With the adapter gone, a custom-named row has nothing pinning it +and would throw `Unknown adapter: devin-cli` on every request, so the migration now rewrites +**every** row naming the retired id, whatever the row is called. A row still carrying the +identity-only `cli.devin.ai` host is repointed at the api-server in the same pass, because that +URL was never a destination and leaving it would trade an unconstructible adapter for an +unresolvable host. + +Nudge. `mapOcxMessagesToDevin` appends the shared paragraph to the system content. The wire name +callback is `tool => tool.name`, not the default namespaced form, because `mapOcxToolsToDevin` +writes the bare name; a nudge listing names the model is never offered is worse than none. + +Done when: no adapter id `devin-cli` remains anywhere, saved rows migrate with regression +coverage, the nudge is covered by a regression test, structure/ and docs-site agree, exact-head CI +is green and the PR is merged. + +## Verification policy for this unit + +Local product suite runs are prohibited by the maintainer. Local checks are limited to +`bun run structure:check`, `bun run privacy:scan`, and explicitly named focused test files. +Everything else is hosted exact-head CI. Skipped local checks are labelled NOT RUN in the PR. + diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 20cd9a79f5..fd696660b7 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -127,7 +127,7 @@ ocx logout | `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` | `devin` | `https://server.codeium.com` | Passerelle Cognition/Devin non officielle et expérimentale. La connexion ouvre l'authentification Auth0 dans le navigateur, puis échange le jeton via `RegisterUser` contre une clé d'API durable. Les modèles sont découverts par compte avec `GetCascadeModelConfigs` ; le streaming passe uniquement par `runTurn` sur Connect-RPC. Absente du préréglage du tableau de bord par défaut. | -| `devin-cli` | `devin` | `https://server.codeium.com` | Importe l'identifiant que votre Devin CLI installé détient déjà (`devin auth login` l'écrit dans son propre `credentials.toml`), puis diffuse via l'api-server Connect-RPC de Cognition comme le fournisseur `devin` — sans connexion navigateur ni clé à coller. La liste des modèles et les fenêtres de contexte proviennent du catalogue de votre compte. Pour la boucle d'agent locale du CLI (ACP stdio), utilisez une entrée nommée différemment avec `"adapter": "devin-cli"`. | +| `devin-cli` | `devin` | `https://server.codeium.com` | Importe l'identifiant que votre Devin CLI installé détient déjà (`devin auth login` l'écrit dans son propre `credentials.toml`), puis diffuse via l'api-server Connect-RPC de Cognition comme le fournisseur `devin` — sans connexion navigateur ni clé à coller. La liste des modèles et les fenêtres de contexte proviennent du catalogue de votre compte. | | `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 a093f22dc6..c50ef934d2 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -193,7 +193,7 @@ ocx logout | `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` | `devin` | `https://server.codeium.com` | Experimental unofficial Cognition/Devin bridge. Login opens Auth0 browser sign-in, then exchanges the token via Cognition's `RegisterUser` for a long-lived API key; models are discovered per account with `GetCascadeModelConfigs`. Not shown in the dashboard preset by default. Chat and usage reporting are verified against a live account across three models. | -| `devin-cli` | `devin` | `https://server.codeium.com` | Imports the credential your installed Devin CLI already holds (`devin auth login` writes it to its own `credentials.toml`), then streams over Cognition's Connect-RPC api-server like the `devin` provider — no browser sign-in and no key to paste. Model discovery and context windows come from your account's own catalog. For the CLI's local agent loop over ACP stdio instead, use a custom-named row with `"adapter": "devin-cli"`. | +| `devin-cli` | `devin` | `https://server.codeium.com` | Imports the credential your installed Devin CLI already holds (`devin auth login` writes it to its own `credentials.toml`), then streams over Cognition's Connect-RPC api-server like the `devin` provider — no browser sign-in and no key to paste. Model discovery and context windows come from your account's own catalog. | | `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 d67f53e15e..cf2bacebe9 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -116,7 +116,7 @@ ocx logout | `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` | `devin` | `https://server.codeium.com` | 実験的な非公式 Cognition/Devin ブリッジ。ログインは Auth0 のブラウザサインインを開き、取得したトークンを `RegisterUser` で長期 API キーに交換します。モデル一覧は `GetCascadeModelConfigs` でアカウントごとに取得し、ストリーミングは Connect-RPC 上の `runTurn` 経路のみを使います。ダッシュボードのプリセットには既定で含まれません。 | -| `devin-cli` | `devin` | `https://server.codeium.com` | インストール済み Devin CLI がすでに保持している認証情報を取り込みます(`devin auth login` が自身の `credentials.toml` に書き込みます)。以降は `devin` プロバイダと同じく Cognition の Connect-RPC api-server へストリーミングします。ブラウザサインインも貼り付けるキーも不要です。モデル一覧とコンテキストウィンドウはアカウントのカタログから取得します。CLI 自身のローカルエージェントループ(ACP stdio)を使う場合は、別名の行に `"adapter": "devin-cli"` を指定してください。| +| `devin-cli` | `devin` | `https://server.codeium.com` | インストール済み Devin CLI がすでに保持している認証情報を取り込みます(`devin auth login` が自身の `credentials.toml` に書き込みます)。以降は `devin` プロバイダと同じく Cognition の Connect-RPC api-server へストリーミングします。ブラウザサインインも貼り付けるキーも不要です。モデル一覧とコンテキストウィンドウはアカウントのカタログから取得します。 | | `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 4f35396cff..887fdc8b0f 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -114,7 +114,7 @@ ocx logout | `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` | `devin` | `https://server.codeium.com` | 실험적인 비공식 Cognition/Devin 브리지. 로그인은 Auth0 브라우저 사인인을 열고, 받은 토큰을 `RegisterUser`로 교환해 장기 API 키를 얻습니다. 모델 목록은 `GetCascadeModelConfigs`로 계정마다 조회하며, 스트리밍은 Connect-RPC 위에서 `runTurn` 경로만 씁니다. 대시보드 프리셋에는 기본으로 없으니 직접 추가하세요. | -| `devin-cli` | `devin` | `https://server.codeium.com` | 설치된 Devin CLI가 이미 들고 있는 자격증명을 가져옵니다(`devin auth login`이 자기 `credentials.toml`에 씁니다). 그다음은 `devin` 프로바이더와 똑같이 Cognition의 Connect-RPC api-server로 스트리밍합니다. 브라우저 로그인도, 붙여넣을 키도 없습니다. 모델 목록과 컨텍스트 윈도우는 계정 카탈로그에서 실시간으로 옵니다. CLI 자체의 로컬 에이전트 루프(ACP stdio)를 쓰려면 이름이 다른 행에 `"adapter": "devin-cli"`를 지정하세요. | +| `devin-cli` | `devin` | `https://server.codeium.com` | 설치된 Devin CLI가 이미 들고 있는 자격증명을 가져옵니다(`devin auth login`이 자기 `credentials.toml`에 씁니다). 그다음은 `devin` 프로바이더와 똑같이 Cognition의 Connect-RPC api-server로 스트리밍합니다. 브라우저 로그인도, 붙여넣을 키도 없습니다. 모델 목록과 컨텍스트 윈도우는 계정 카탈로그에서 실시간으로 옵니다. | | `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 af7e3055c0..8f524e39c1 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -463,23 +463,14 @@ api-server URL beside it, and never the file's other fields. - Uses `runTurn` on the shared cloud-direct client, so it inherits that adapter's live catalog, per-account context windows and tool-description handling. -- For the CLI's own local agent loop over ACP stdio instead, configure a **custom-named** provider - row with `"adapter": "devin-cli"` — for example `"devin-acp"`. A row named `devin-cli` cannot - select it, because the router pins the adapter from the registry for any registry id. -- 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`. +- Only the credential is local. The turn itself goes to Cognition, exactly as `devin` does, so the + two rows differ in nothing but which account signed in. Install the CLI with + `curl -fsSL https://cli.devin.ai/install.sh | bash` or `brew install --cask devin-cli`, run + `devin auth login` once, then add the provider. +- An earlier build shipped a second adapter under the id `devin-cli` that ran the turn as an + Agent Client Protocol session against a local `devin acp` child process. It is gone. A saved + configuration that still names that adapter is rewritten to `devin` at startup, including a + custom-named row such as `"devin-acp"`. ## `azure-openai` (alias: `azure`) diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index a2c156865e..36b94e5cea 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -125,7 +125,7 @@ ocx logout | `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` | `devin` | `https://server.codeium.com` | Экспериментальный неофициальный мост к Cognition/Devin. Вход открывает страницу Auth0 в браузере, затем токен обменивается через `RegisterUser` на долгоживущий API-ключ. Список моделей запрашивается для каждой учётной записи через `GetCascadeModelConfigs`; потоковая передача идёт только по пути `runTurn` поверх Connect-RPC. В пресете панели по умолчанию отсутствует. | -| `devin-cli` | `devin` | `https://server.codeium.com` | Импортирует учётные данные, которые установленный Devin CLI уже хранит (`devin auth login` записывает их в свой `credentials.toml`), а затем передаёт потоком через Connect-RPC api-server Cognition, как и провайдер `devin` — без входа в браузере и без ключа для вставки. Список моделей и контекстные окна берутся из каталога вашей учётной записи. Для собственного локального цикла агента CLI (ACP stdio) используйте строку с другим именем и `"adapter": "devin-cli"`. | +| `devin-cli` | `devin` | `https://server.codeium.com` | Импортирует учётные данные, которые установленный Devin CLI уже хранит (`devin auth login` записывает их в свой `credentials.toml`), а затем передаёт потоком через Connect-RPC api-server Cognition, как и провайдер `devin` — без входа в браузере и без ключа для вставки. Список моделей и контекстные окна берутся из каталога вашей учётной записи. | | `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 36e0ccbc1d..e4bd33bb6f 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -140,7 +140,7 @@ ocx logout | `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` | `devin` | `https://server.codeium.com` | Deneysel, resmi olmayan Cognition/Devin köprüsü. Giriş tarayıcıda Auth0 oturumunu açar, ardından belirteci `RegisterUser` ile uzun ömürlü bir API anahtarına dönüştürür. Modeller hesaba göre `GetCascadeModelConfigs` ile keşfedilir; akış yalnızca Connect-RPC üzerindeki `runTurn` yolunu kullanır. Panel ön ayarında varsayılan olarak yer almaz. | -| `devin-cli` | `devin` | `https://server.codeium.com` | Kurulu Devin CLI'nin zaten tuttuğu kimlik bilgisini içe aktarır (`devin auth login` bunu kendi `credentials.toml` dosyasına yazar), ardından `devin` sağlayıcısı gibi Cognition'ın Connect-RPC api-server'ı üzerinden akış yapar — tarayıcı girişi ve yapıştırılacak anahtar yok. Model listesi ve bağlam pencereleri hesabınızın kendi kataloğundan gelir. CLI'nin kendi yerel ajan döngüsü (ACP stdio) için farklı adlı bir satırda `"adapter": "devin-cli"` kullanın. | +| `devin-cli` | `devin` | `https://server.codeium.com` | Kurulu Devin CLI'nin zaten tuttuğu kimlik bilgisini içe aktarır (`devin auth login` bunu kendi `credentials.toml` dosyasına yazar), ardından `devin` sağlayıcısı gibi Cognition'ın Connect-RPC api-server'ı üzerinden akış yapar — tarayıcı girişi ve yapıştırılacak anahtar yok. Model listesi ve bağlam pencereleri hesabınızın kendi kataloğundan gelir. | | `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 94e8f4f99f..b2aafbcd3a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -108,7 +108,7 @@ ocx logout | `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` | `devin` | `https://server.codeium.com` | 实验性的非官方 Cognition/Devin 桥接。登录会打开 Auth0 浏览器页面,再用 `RegisterUser` 把令牌换成长期 API 密钥。模型列表按账号通过 `GetCascadeModelConfigs` 实时获取,流式仅走 Connect-RPC 上的 `runTurn` 路径。默认不在仪表盘预设中,需要手动启用。 | -| `devin-cli` | `devin` | `https://server.codeium.com` | 导入本地已安装 Devin CLI 已持有的凭据(`devin auth login` 会写入它自己的 `credentials.toml`),随后与 `devin` 提供方一样通过 Cognition 的 Connect-RPC api-server 流式传输。无需浏览器登录,也无需粘贴密钥。模型列表与上下文窗口来自账号自身的目录。若要改用 CLI 自带的本地 agent 循环(ACP stdio),请用另取名称的条目并设置 `"adapter": "devin-cli"`。| +| `devin-cli` | `devin` | `https://server.codeium.com` | 导入本地已安装 Devin CLI 已持有的凭据(`devin auth login` 会写入它自己的 `credentials.toml`),随后与 `devin` 提供方一样通过 Cognition 的 Connect-RPC api-server 流式传输。无需浏览器登录,也无需粘贴密钥。模型列表与上下文窗口来自账号自身的目录。 | | `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 63b2eeaeb8..72d2ae214a 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -113,7 +113,7 @@ ocx logout | `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` | `devin` | `https://server.codeium.com` | 實驗性的非官方 Cognition/Devin 橋接。登入會開啟 Auth0 瀏覽器頁面,再以 `RegisterUser` 將權杖換成長期 API 金鑰。模型清單依帳號透過 `GetCascadeModelConfigs` 即時取得,串流僅走 Connect-RPC 上的 `runTurn` 路徑。預設不在儀表板預設集內,需手動啟用。 | -| `devin-cli` | `devin` | `https://server.codeium.com` | 匯入本機已安裝 Devin CLI 已持有的憑證(`devin auth login` 會寫入它自己的 `credentials.toml`),接著與 `devin` 提供者一樣透過 Cognition 的 Connect-RPC api-server 串流。不需瀏覽器登入,也不需貼上金鑰。模型清單與內容視窗來自帳號自身的目錄。若要改用 CLI 自帶的本機 agent 迴圈(ACP stdio),請使用另取名稱的項目並設定 `"adapter": "devin-cli"`。| +| `devin-cli` | `devin` | `https://server.codeium.com` | 匯入本機已安裝 Devin CLI 已持有的憑證(`devin auth login` 會寫入它自己的 `credentials.toml`),接著與 `devin` 提供者一樣透過 Cognition 的 Connect-RPC api-server 串流。不需瀏覽器登入,也不需貼上金鑰。模型清單與內容視窗來自帳號自身的目錄。 | | `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/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index 606723a368..671a357e36 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -14,10 +14,10 @@ const PROVIDER_ICON_ALIASES: Record = { cursor: "cursor-color.svg", deepseek: "deepseek-color.svg", /* - * One mark for both Devin providers. `devin` is Cognition's cloud, reached - * through the Windsurf sign-in, and `devin-cli` drives the installed Devin - * CLI; they are two transports into the same product, the meta-model/meta-muse - * shape. Windsurf still publishes its own `W` app icon, but showing it next + * One mark for both Devin providers. They are one product reached two ways: + * `devin` signs in through Windsurf in a browser, `devin-cli` imports the + * credential the installed CLI already holds, and both stream over the same + * Cognition adapter. Windsurf still publishes its own `W` app icon, but showing it next * to a row labelled Cognition would name the retired brand. */ devin: "devin.svg", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b2eabcbcc9..33b960ac70 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -632,7 +632,6 @@ "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", - "devin-cli-adapter.test.ts": "providers", "devin-hardening.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", diff --git a/src/adapters/devin-cli/acp.ts b/src/adapters/devin-cli/acp.ts deleted file mode 100644 index 28bd4fc6b0..0000000000 --- a/src/adapters/devin-cli/acp.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * 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 deleted file mode 100644 index f31bf77373..0000000000 --- a/src/adapters/devin-cli/adapter.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * 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", - // "normal" is the CLI's own refuse-by-default mode. "ask" reads like - // the right name for it but is not a value the binary accepts: Devin - // CLI 3000.10.21 exits 2 with - // invalid value 'ask' for '--permission-mode ' - // Valid options: normal (auto), accept-edits, dangerous (yolo, - // bypass), autonomous (requires --sandbox) - // before answering a single prompt, so every turn on this provider - // failed with the default (tools not allowed) configuration — the - // one path most operators are on. Found by running a real turn - // against an installed, signed-in CLI; no unit test could see it, - // because the spawn is injected and the fake child accepts anything. - DEVIN_PERMISSION_MODE: toolsAllowed ? (process.env.DEVIN_PERMISSION_MODE ?? "bypass") : "normal", - }, - }); - } 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 deleted file mode 100644 index 7435e262f1..0000000000 --- a/src/adapters/devin-cli/binary.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * 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 deleted file mode 100644 index 5d22a049b0..0000000000 --- a/src/adapters/devin-cli/models.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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; - -/** - * Context windows for the CLI roster, in the same effort-suffixed ids the CLI - * accepts. - * - * Without this the picker fell back to the 128k default for every Devin CLI - * model, including `swe-2`, which is the roster's own default — so the one - * model most sessions ran reported less than half its real window. - * - * The numbers come from Cognition's `GetCascadeModelConfigs` catalog - * (`ClientModelConfig` field #18), which is the only first-party source: the - * Devin CLI and Desktop model pages, the SWE-2 announcement, and the Windsurf - * model reference all list these models without a window. The CLI is a separate - * product from the cloud, but Cognition documents the same models on both and - * describes no per-surface difference — the SWE-2 announcement ships it to - * Desktop, CLI, Web, and Fusion in one sentence — so the catalog's figure is - * used for both rather than inventing a second table. - * - * ACP has no discovery call, so unlike the cloud provider this cannot be - * refreshed live; it needs updating when the roster above does. - */ -export const DEVIN_CLI_MODEL_CONTEXT_WINDOWS: Record = { - "swe-2": 262_000, - "swe-2-high": 262_000, - "claude-opus-5-medium": 1_000_000, - "claude-fable-5-1-medium": 1_000_000, - "claude-sonnet-5-medium": 1_000_000, - "gpt-6-astra-medium": 1_000_000, - "gpt-5-6-sol-medium": 1_000_000, - "gemini-3-8-flash-medium": 1_048_576, - "glm-5-3-high": 1_048_576, - "glm-5-3-low": 1_048_576, - "kimi-k3-high": 1_048_576, -}; diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index a95d6de5e2..12d9ab7ee2 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -10,6 +10,7 @@ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, Ocx import type { IncomingMeta, ProviderAdapter } from "./base"; import { streamChatEvents, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct"; import { getCachedCatalog } from "./devin/cloud-direct/catalog"; +import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiServer } from "../oauth/devin"; export const DEVIN_API_SERVER = DEVIN_DEFAULT_API_SERVER; @@ -119,7 +120,20 @@ function assistantText(message: OcxAssistantMessage): string { export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem[] { const items: ChatHistoryItem[] = []; - const system = parsed.context.systemPrompt?.filter((line) => line.trim().length > 0).join("\n"); + // Cognition is not an OpenAI host, and this adapter does advertise a real + // client tool catalog (proto #10 via `mapOcxToolsToDevin`), so the same + // contract paragraph the other non-OpenAI adapters inject belongs here. The + // wire name is the bare `tool.name` that encoder writes, not the namespaced + // form, so the nudge names exactly what the model is offered. + const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools( + parsed.context.tools, + parsed.options.toolChoice, + (tool) => tool.name, + ); + const systemPrompt = parsed.context.systemPrompt?.filter((line) => line.trim().length > 0).join("\n"); + const system = [systemPrompt, toolCatalogNudge] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join("\n\n"); if (system) items.push({ role: "system", content: system }); for (const message of parsed.context.messages) { diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 69cb11ad13..f9398b4fa8 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -6,7 +6,6 @@ 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 { createDevinAdapter } from "./devin"; import { createGoogleAdapter } from "./google"; import { createKiroAdapter } from "./kiro"; @@ -43,7 +42,6 @@ export type AdapterWire = | "google" | "kiro" | "cursor" - | "devin-cli" | "devin"; export type AdapterMutationContract = @@ -126,11 +124,6 @@ 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), - }, devin: { wire: "devin", mutation: "codex-owned", diff --git a/src/oauth/devin-cli.ts b/src/oauth/devin-cli.ts index 14a5466b53..abb3cd14ab 100644 --- a/src/oauth/devin-cli.ts +++ b/src/oauth/devin-cli.ts @@ -19,11 +19,21 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { posix, win32 } from "node:path"; -import { DEVIN_CLI_INSTALL_HINT } from "../adapters/devin-cli/binary"; import { identityFromApiKey } from "./devin"; import { resolveDevinApiBaseUrl } from "./devin/api-base"; import type { OAuthController, OAuthCredentials } from "./types"; +/** + * How to get a signed-in CLI, for the one error that needs to say so. + * + * This flow reads `credentials.toml` and never executes the CLI, so it does not + * resolve the binary. The constant used to live beside the discovery helper the + * retired ACP adapter needed; that adapter is gone and this sentence is all that + * outlived it. + */ +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`."; + /** * Structurally the `LoginOpts` from `./index`, restated here rather than imported. * `index.ts` imports this module to register the provider, so importing the type diff --git a/src/providers/devin-cli-authmode-migration.ts b/src/providers/devin-cli-authmode-migration.ts index 1dba274e37..e9eaa2a26f 100644 --- a/src/providers/devin-cli-authmode-migration.ts +++ b/src/providers/devin-cli-authmode-migration.ts @@ -1,21 +1,29 @@ /** - * Repair a saved `devin-cli` row that still claims `authMode: "local"`. + * Repair saved Devin rows written while `devin-cli` was a local ACP provider. * - * `derive.ts` seeds `authMode` from the registry's `authKind`, so every config - * written while `devin-cli` was a local provider carries `"local"`. The registry - * now classifies it as an account provider, and the management write boundary - * fails closed on that mismatch: `auth-cors.ts` rejects `authMode: "local"` when - * the registry entry is not local. Without this, an existing install can no - * longer save provider changes from the dashboard. + * Two repairs, both from the same history: `devin-cli` used to be a local + * provider whose adapter spawned `devin acp`, and it is now an account provider + * that streams over Cognition's api-server on the shared `devin` adapter. * - * It rewrites exactly that one field, on exactly that mismatch. A row the user - * retargeted at another adapter is left alone, and so is any other value. + * `authMode` — `derive.ts` seeds it from the registry's `authKind`, so every + * config written while `devin-cli` was local carries `"local"`. The management + * write boundary fails closed on that mismatch: `auth-cors.ts` rejects + * `authMode: "local"` when the registry entry is not local. Without this, an + * existing install can no longer save provider changes from the dashboard. * - * It also WARNS, without changing anything, when the saved row still names the - * ACP adapter. That field no longer selects the transport — `routedProviderConfig` - * pins the adapter from the registry for any row whose name is a registry id — so - * an operator who deliberately chose ACP would otherwise switch transports - * silently. The warning names the custom-id row that still gets them ACP. + * `adapter` — the ACP adapter has been removed, so `"devin-cli"` is no longer a + * constructible adapter id and `createRegisteredAdapter` would throw + * `Unknown adapter: devin-cli`. The registry-id row survives that removal on its + * own because `routedProviderConfig` pins the adapter from the registry, but a + * custom-named row such as `"devin-acp"` has nothing pinning it and would fail + * every request. So every row naming the retired adapter is rewritten to + * `devin`, whatever the row is called, and each rewrite is reported. + * + * A row carrying the retired ACP identity URL is repointed at the api-server in + * the same pass. That URL was never a destination — it existed only so provider + * validation would accept an `http(s)` scheme for a child process — so leaving + * it in place would turn a constructible adapter into a request that cannot + * resolve a host. */ import { PROVIDER_REGISTRY } from "./registry"; import type { OcxConfig } from "../types"; @@ -26,27 +34,50 @@ export interface DevinCliAuthModeProjection { warnings: string[]; } -const ACP_ESCAPE_HATCH = - 'a custom-named row still reaches it, e.g. "devin-acp": { "adapter": "devin-cli", "baseUrl": "https://cli.devin.ai" }'; +/** The adapter id the removed ACP transport was registered under. */ +const RETIRED_ACP_ADAPTER = "devin-cli"; +/** Identity-only URL the ACP rows carried; never a destination. */ +const RETIRED_ACP_IDENTITY_HOST = "cli.devin.ai"; +const DEVIN_API_SERVER = "https://server.codeium.com"; + +function retiredIdentityUrl(baseUrl: string | undefined): boolean { + if (typeof baseUrl !== "string") return false; + try { + return new URL(baseUrl).hostname.toLowerCase() === RETIRED_ACP_IDENTITY_HOST; + } catch { + return false; + } +} export function projectDevinCliAuthMode(config: OcxConfig): DevinCliAuthModeProjection { const warnings: string[] = []; - const prov = config.providers?.["devin-cli"]; - if (!prov) return { config, changed: false, warnings }; + let changed = false; - const entry = PROVIDER_REGISTRY.find(row => row.id === "devin-cli"); - if (!entry || entry.authKind !== "oauth") return { config, changed: false, warnings }; - - // Non-mutating: the saved adapter no longer decides the transport, so leaving - // it in place preserves nothing except a signal worth reporting once. - if (prov.adapter === "devin-cli") { + // Every row, not only the registry id: a custom-named row had no registry pin, + // so after the ACP removal it is the one that cannot construct an adapter. + for (const [name, row] of Object.entries(config.providers ?? {})) { + if (!row || row.adapter !== RETIRED_ACP_ADAPTER) continue; + row.adapter = "devin"; + changed = true; + let detail = ""; + if (retiredIdentityUrl(row.baseUrl)) { + row.baseUrl = DEVIN_API_SERVER; + detail = ` and repointed its baseUrl at ${DEVIN_API_SERVER}`; + } warnings.push( - 'the saved "devin-cli" row names the ACP adapter, but that provider id now streams over ' - + `Cognition's api-server and the adapter is pinned from the registry; for the CLI's own agent loop, ${ACP_ESCAPE_HATCH}.`, + `rewrote "${name}" adapter ${RETIRED_ACP_ADAPTER} -> devin${detail}: the local ACP transport ` + + "was removed, and Devin now streams over Cognition's api-server with the credential the " + + "installed CLI already holds.", ); } - if (prov.authMode !== "local") return { config, changed: warnings.length > 0 ? false : false, warnings }; + const prov = config.providers?.["devin-cli"]; + if (!prov) return { config, changed, warnings }; + + const entry = PROVIDER_REGISTRY.find(row => row.id === "devin-cli"); + if (!entry || entry.authKind !== "oauth") return { config, changed, warnings }; + + if (prov.authMode !== "local") return { config, changed, warnings }; prov.authMode = "oauth"; warnings.push( 'rewrote "devin-cli" authMode local -> oauth: the registry no longer classifies it as local, ' diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e2189254fc..be51b628ca 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1320,19 +1320,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // The CLI writes a `devin-session-token$` to its own credentials.toml, // which is the same credential RegisterUser hands `ocx login devin` and which // the cloud-direct client already speaks. So this provider imports that token - // and streams over Connect-RPC like its browser-login sibling, rather than - // spawning `devin acp`. + // and streams over Connect-RPC like its browser-login sibling. // // `oauth` classifies the ACCOUNT, not the transport. This is not a local // runtime: unlike Ollama or LM Studio it cannot answer at all until a vendor // account is signed in, and `local` grouped it with things that have no // account. It is also the only classification that reaches the dashboard // Accounts tab, which is built from OAUTH_PROVIDERS. - // - // The ACP adapter stays registered and tested. It is no longer reachable - // under THIS id — `routedProviderConfig` pins the adapter from the registry - // for any row whose name is a registry id — but a custom-named row such as - // `{"devin-acp": {"adapter": "devin-cli", ...}}` is not pinned and still gets it. id: "devin-cli", label: "Devin CLI", adapter: "devin", @@ -1343,7 +1337,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // flag, so leaving it true would draw the row twice: an Accounts login row and // a preset tile. dashboardPreset: false, - note: "Imports the credential your installed Devin CLI already holds (`devin auth login`), then streams over Cognition's Connect-RPC api-server like the `devin` provider. No browser sign-in and no key to paste. For the CLI's own local agent loop over ACP stdio instead, configure a custom-named provider row with \"adapter\": \"devin-cli\".", + note: "Imports the credential your installed Devin CLI already holds (`devin auth login`), then streams over Cognition's Connect-RPC api-server like the `devin` provider. No browser sign-in and no key to paste.", // Degraded-mode seed only; `liveModels` discovers the account's real roster, // which is where `swe-2` and the rest of the current catalog come from. models: ["swe-2", "swe-1-7", "gpt-5-6-sol", "gpt-6-astra", "claude-opus-5", "claude-fable-5-1", "claude-sonnet-5", "glm-5-3", "kimi-k3", "gemini-3-8-flash", "grok-4-6"], diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 700eb333bc..24cbc55e8f 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -14,7 +14,6 @@ export function upstreamProtocolForAdapter(adapter: string): string { case "openai-chat": case "command-code": case "cursor": - case "devin-cli": case "devin": case "azure": case "azure-openai": diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index db26a7cb4d..aba5ec61b9 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -13,20 +13,22 @@ 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. -- `devin` is the cloud half of the same family and is also direct. It streams Cognition's +- `devin` is direct for a related reason. It streams Cognition's `ApiServerService/GetChatMessage` over Connect-RPC from `runTurn` with hand-written protobuf - framing, so like Cursor and `devin-cli` it never travels the `buildRequest`/`parseStream` path. - The `devin-cli` PRESET streams over this same adapter: the installed CLI's own - `credentials.toml` holds an ordinary `devin-session-token`, so that provider imports the token - rather than spawning a child, and the two rows differ only in where the credential came from — - a browser sign-in versus a signed-in local CLI. The ACP adapter above remains registered and is - selected by a custom-named row, never by the `devin-cli` id, because `routedProviderConfig` pins - the adapter from the registry for any registry id. + framing, so like Cursor it never travels the `buildRequest`/`parseStream` path. Both Devin + provider rows share it. The installed CLI's own `credentials.toml` holds an ordinary + `devin-session-token`, the same credential `RegisterUser` mints for a browser sign-in, so + `devin-cli` imports that token and the two rows differ only in where the credential came from. + `AdapterFactoryContext.providerId` is what keeps them apart: the Cognition tenant is recorded on + the credential, not in the registry, so the adapter has to know which row it is serving before it + can resolve a host. + + There is no second Devin transport. An Agent Client Protocol adapter that spawned a local + `devin acp` child once existed under the `devin-cli` adapter id and was removed: the CLI's + credential turned out to be the ordinary cloud token, so the child process bought nothing that + importing the token did not, and it cost a placeholder `buildRequest`, a disabled + `parseStream`, an identity-only `baseUrl`, and a subprocess running in the operator's tree. + `projectDevinCliAuthMode` rewrites any saved row that still names the retired adapter id. 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 08103c43f2..b0b43c8b19 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -21,7 +21,6 @@ const EXPECTED_ADAPTER_NAMES = { azure: "azure-openai", "azure-openai": "azure-openai", cursor: "cursor", - "devin-cli": "devin-cli", devin: "devin", "mimo-free": "mimo-free", qoder: "qoder", diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts index 5b8fa543f7..9471581107 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -420,10 +420,11 @@ describe("registry-derived routed tool conformance", () => { }); const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder"]); - // Both Devin providers are runTurn-only: devin-cli drives a local CLI over ACP - // stdio and devin streams Connect-RPC from runTurn, so for both of them + // The Devin adapter is runTurn-only: it streams Connect-RPC from runTurn, so // buildRequest returns a placeholder and tools never travel the wire path. - const RUN_TURN_ONLY_WIRES = new Set(["devin-cli", "devin"]); + // Both Devin provider rows share it and differ only in where the credential + // came from. + const RUN_TURN_ONLY_WIRES = new Set(["devin"]); test("every registered adapter keeps the nested apply_patch helper in its final request", async () => { for (const [adapterId] of adapterDefinitions()) { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b56ab03905..9ea5f32928 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -464,7 +464,6 @@ "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", - "devin-cli-adapter.test.ts": "providers", "devin-hardening.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", @@ -1204,4 +1203,4 @@ "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "devin-cli-login.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers" -} \ No newline at end of file +} diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index c31e9dda4d..aa22e0edce 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -41,7 +41,10 @@ describe("devin adapter", () => { options: {}, }; const history = mapOcxMessagesToDevin(parsed); - expect(history[0]).toEqual({ role: "system", content: "be brief" }); + // One system item carrying the prompt and, because this request advertises a + // tool, the shared non-OpenAI catalog contract paragraph after it. + expect(history[0]?.role).toBe("system"); + expect(String(history[0]?.content)).toStartWith("be brief\n\nTool contract:"); expect(history[1]).toEqual({ role: "user", content: "hi" }); expect(history[2]?.role).toBe("assistant"); expect(history[2]?.tool_calls?.[0]?.id).toBe("c1"); @@ -49,6 +52,44 @@ describe("devin adapter", () => { expect(mapOcxToolsToDevin(parsed.context.tools)?.[0]?.name).toBe("lookup"); }); + test("the tool catalog nudge names the bare wire names the encoder actually sends", () => { + // Cognition is offered `tool.name` with no namespace prefix (mapOcxToolsToDevin), + // so a nudge built from the default namespaced form would advertise a name the + // model is never given. Both Devin provider rows share this adapter, so this is + // the single place that covers `devin` and `devin-cli` at once. + const parsed: OcxParsedRequest = { + modelId: "swe-1-7", + stream: true, + context: { + systemPrompt: ["be brief"], + messages: [{ role: "user", content: "hi", timestamp: 1 }], + tools: [ + { name: "exec_command", description: "run", parameters: { type: "object" } }, + { namespace: "codex_app", name: "list_threads", description: "list", parameters: { type: "object" } }, + ], + }, + options: {}, + }; + const system = String(mapOcxMessagesToDevin(parsed)[0]?.content); + const wireNames = (mapOcxToolsToDevin(parsed.context.tools) ?? []).map((tool) => tool.name); + expect(wireNames).toEqual(["exec_command", "list_threads"]); + for (const name of wireNames) expect(system).toContain(`\`${name}\``); + expect(system).not.toContain("codex_app__list_threads"); + }); + + test("a request with no tools keeps the system prompt exactly as it was", () => { + const parsed: OcxParsedRequest = { + modelId: "swe-1-7", + stream: true, + context: { + systemPrompt: ["be brief"], + messages: [{ role: "user", content: "hi", timestamp: 1 }], + }, + options: {}, + }; + expect(mapOcxMessagesToDevin(parsed)[0]).toEqual({ role: "system", content: "be brief" }); + }); + test("collapseDevinModelUid strips effort suffixes to base ids", () => { expect(collapseDevinModelUid("swe-1-7")).toBe("swe-1-7"); expect(collapseDevinModelUid("swe-1-7-medium")).toBe("swe-1-7"); diff --git a/tests/providers/devin-cli-adapter.test.ts b/tests/providers/devin-cli-adapter.test.ts deleted file mode 100644 index 565cd08478..0000000000 --- a/tests/providers/devin-cli-adapter.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { join } from "node:path"; -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 { DEVIN_CLI_MODELS, DEVIN_CLI_MODEL_CONTEXT_WINDOWS, DEVIN_CLI_DEFAULT_MODEL } from "../../src/adapters/devin-cli/models"; -import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../../src/adapters/devin/live-models"; -import { formatProviderDisplayName, providerIconSrc } from "../../gui/src/provider-icons"; -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 an account provider sourced from the installed CLI", () => { - const entry = PROVIDER_REGISTRY.find((row) => row.id === "devin-cli"); - // The preset streams over Cognition's api-server now: the CLI's own - // credentials.toml holds an ordinary devin-session-token, so there is no - // reason to spawn a child to reach the same models. - expect(entry?.adapter).toBe("devin"); - // `oauth` classifies the ACCOUNT, not the transport, and is what puts the row - // in the dashboard Accounts tab beside `devin`. - expect(entry?.authKind).toBe("oauth"); - // Off, or the row is drawn twice: an Accounts login row and a preset tile. - expect(entry?.dashboardPreset).toBe(false); - // The ACP adapter is still registered and still constructible — it is simply - // no longer what this provider id resolves to. - expect(createDevinCliAdapter({ adapter: "devin-cli", baseUrl: "https://cli.devin.ai" }).name).toBe("devin-cli"); - }); - - test("both Devin providers render the Devin mark and a readable name", () => { - // Neither id had an icon alias, so the dashboard drew a coloured initial - // tile for both, and the title-cased fallback turned the local one into - // "Devin Cli". - expect(providerIconSrc("devin")).toBe("/provider-icons/devin.svg"); - expect(providerIconSrc("devin-cli")).toBe("/provider-icons/devin.svg"); - const englishT = ((_key: string, fallback?: string) => fallback ?? "") as Parameters[1]; - expect(formatProviderDisplayName("devin", englishT)).toBe("Devin"); - expect(formatProviderDisplayName("devin-cli", englishT)).toBe("Devin CLI"); - }); - - test("every CLI model carries its context window", () => { - // The provider shipped without a window table at all, so the picker used the - // 128k default for the whole roster — including `swe-2`, the default model, - // whose real window is 262k. A model added to the roster without a window - // silently reintroduces that, so the table is checked against the roster - // rather than by spot-checking one id. - for (const model of DEVIN_CLI_MODELS) { - expect(DEVIN_CLI_MODEL_CONTEXT_WINDOWS[model]).toBeGreaterThan(0); - } - expect(Object.keys(DEVIN_CLI_MODEL_CONTEXT_WINDOWS).sort()).toEqual([...DEVIN_CLI_MODELS].sort()); - expect(DEVIN_CLI_MODEL_CONTEXT_WINDOWS[DEVIN_CLI_DEFAULT_MODEL]).toBe(262_000); - - // The PRESET no longer uses this table: it streams over the cloud transport, - // so its windows come from the shared Devin table and, at runtime, from the - // account's own catalog through live discovery. The table above still governs - // the ACP roster for a custom-named row that selects that adapter. - const entry = PROVIDER_REGISTRY.find((row) => row.id === "devin-cli"); - expect(entry?.modelContextWindows).toBe(DEVIN_MODEL_CONTEXT_WINDOWS); - expect(entry?.liveModels).toBe(true); - }); -}); - -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]; - const previousPath = process.env.PATH; - delete process.env[DEVIN_CLI_BIN_ENV]; - process.env.PATH = join("/shadow", "bin"); - try { - const expected = join("/home/u", ".local", "bin", "devin"); - const shadowed = join("/shadow", "bin", "devin"); - const exists = (p: string) => p === expected || p === shadowed; - expect(resolveDevinCliBinary({ exists, home: "/home/u", useCache: false })).toBe(expected); - expect(resolveDevinCliBinary({ exists: p => p === shadowed, home: "/home/u", useCache: false })).toBe(shadowed); - expect(resolveDevinCliBinary({ exists: () => false, home: "/home/u", useCache: false })).toBeUndefined(); - } finally { - if (previous !== undefined) process.env[DEVIN_CLI_BIN_ENV] = previous; - if (previousPath === undefined) delete process.env.PATH; - else process.env.PATH = previousPath; - } - }); -}); - -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/); - }); -}); diff --git a/tests/providers/devin-cli-authmode-migration.test.ts b/tests/providers/devin-cli-authmode-migration.test.ts index 2b7e2edee9..83214a9221 100644 --- a/tests/providers/devin-cli-authmode-migration.test.ts +++ b/tests/providers/devin-cli-authmode-migration.test.ts @@ -23,15 +23,40 @@ describe("devin-cli authMode migration", () => { expect(p.warnings).toEqual([]); }); - test("warns without mutating when the saved row still names the ACP adapter", () => { - // The saved adapter no longer chooses the transport — routing pins it from - // the registry — so an operator who chose ACP must be told, not silently moved. + test("rewrites the registry-id row that still names the removed ACP adapter", () => { + // The ACP adapter is gone, so the saved id is no longer constructible. The + // registry pin already protected this row's requests; the rewrite is what + // keeps the persisted file honest about what it now runs. const p = projectDevinCliAuthMode(cfg({ adapter: "devin-cli", baseUrl: "https://cli.devin.ai", authMode: "oauth" })); - expect(p.changed).toBe(false); - expect(p.config.providers!["devin-cli"]!.adapter).toBe("devin-cli"); + expect(p.changed).toBe(true); + expect(p.config.providers!["devin-cli"]!.adapter).toBe("devin"); + expect(p.config.providers!["devin-cli"]!.baseUrl).toBe("https://server.codeium.com"); + expect(p.warnings.join(" ")).toContain("devin-cli -> devin"); + }); + + test("converts a custom-named ACP row, which no registry pin protects", () => { + // `devin-acp` was the documented escape hatch. Nothing pins a custom name, + // so after the removal this row is the one that would throw + // `Unknown adapter: devin-cli` on every request. + const config = { + providers: { + "devin-acp": { adapter: "devin-cli", baseUrl: "https://cli.devin.ai" }, + }, + } as unknown as Parameters[0]; + const p = projectDevinCliAuthMode(config); + expect(p.changed).toBe(true); + expect(p.config.providers!["devin-acp"]!.adapter).toBe("devin"); + expect(p.config.providers!["devin-acp"]!.baseUrl).toBe("https://server.codeium.com"); expect(p.warnings.join(" ")).toContain("devin-acp"); }); + test("leaves a non-ACP baseUrl alone while still retiring the adapter", () => { + const p = projectDevinCliAuthMode(cfg({ adapter: "devin-cli", baseUrl: "https://eu.windsurf.com/_route/api_server", authMode: "oauth" })); + expect(p.changed).toBe(true); + expect(p.config.providers!["devin-cli"]!.adapter).toBe("devin"); + expect(p.config.providers!["devin-cli"]!.baseUrl).toBe("https://eu.windsurf.com/_route/api_server"); + }); + test("is a no-op when the provider is not configured", () => { const p = projectDevinCliAuthMode(cfg(undefined)); expect(p.changed).toBe(false); @@ -46,4 +71,3 @@ describe("devin-cli authMode migration", () => { expect(p.config.providers!["devin-cli"]!.authMode).toBe("oauth"); }); }); - From 67fe08b5b22d9496db213abcd05af9d5c1c8b5c6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 23:26:47 +0900 Subject: [PATCH 2/2] fix(devin): share the api-server default and drop the out-of-scope GUI comment The migration wrote its own copy of the Cognition host, so a later change to the shared default would have left it writing the old address. It now imports DEVIN_DEFAULT_API_SERVER. The GUI comment rewrite went back to its dev state. It was unrelated cleanup by the PR checklist's own standard, and because the screenshot gate is path-based it was asking a comment-only diff to produce a screenshot of nothing. It can go in on its own. --- gui/src/provider-icons.ts | 8 ++++---- src/providers/devin-cli-authmode-migration.ts | 9 +++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index 671a357e36..606723a368 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -14,10 +14,10 @@ const PROVIDER_ICON_ALIASES: Record = { cursor: "cursor-color.svg", deepseek: "deepseek-color.svg", /* - * One mark for both Devin providers. They are one product reached two ways: - * `devin` signs in through Windsurf in a browser, `devin-cli` imports the - * credential the installed CLI already holds, and both stream over the same - * Cognition adapter. Windsurf still publishes its own `W` app icon, but showing it next + * One mark for both Devin providers. `devin` is Cognition's cloud, reached + * through the Windsurf sign-in, and `devin-cli` drives the installed Devin + * CLI; they are two transports into the same product, the meta-model/meta-muse + * shape. Windsurf still publishes its own `W` app icon, but showing it next * to a row labelled Cognition would name the retired brand. */ devin: "devin.svg", diff --git a/src/providers/devin-cli-authmode-migration.ts b/src/providers/devin-cli-authmode-migration.ts index e9eaa2a26f..f8736c9c8e 100644 --- a/src/providers/devin-cli-authmode-migration.ts +++ b/src/providers/devin-cli-authmode-migration.ts @@ -26,6 +26,7 @@ * resolve a host. */ import { PROVIDER_REGISTRY } from "./registry"; +import { DEVIN_DEFAULT_API_SERVER } from "../oauth/devin/api-base"; import type { OcxConfig } from "../types"; export interface DevinCliAuthModeProjection { @@ -38,7 +39,6 @@ export interface DevinCliAuthModeProjection { const RETIRED_ACP_ADAPTER = "devin-cli"; /** Identity-only URL the ACP rows carried; never a destination. */ const RETIRED_ACP_IDENTITY_HOST = "cli.devin.ai"; -const DEVIN_API_SERVER = "https://server.codeium.com"; function retiredIdentityUrl(baseUrl: string | undefined): boolean { if (typeof baseUrl !== "string") return false; @@ -61,8 +61,10 @@ export function projectDevinCliAuthMode(config: OcxConfig): DevinCliAuthModeProj changed = true; let detail = ""; if (retiredIdentityUrl(row.baseUrl)) { - row.baseUrl = DEVIN_API_SERVER; - detail = ` and repointed its baseUrl at ${DEVIN_API_SERVER}`; + // The shared default, not a second copy of the host: a migration that + // hardcodes it would keep writing the old address after the default moves. + row.baseUrl = DEVIN_DEFAULT_API_SERVER; + detail = ` and repointed its baseUrl at ${DEVIN_DEFAULT_API_SERVER}`; } warnings.push( `rewrote "${name}" adapter ${RETIRED_ACP_ADAPTER} -> devin${detail}: the local ACP transport ` @@ -85,4 +87,3 @@ export function projectDevinCliAuthMode(config: OcxConfig): DevinCliAuthModeProj ); return { config, changed: true, warnings }; } -