diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 4615c0726f5..b751294bb11 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -37,7 +37,7 @@ labels local presets separately; those normally omit both `authMode` and `apiKey | --- | --- | --- | | `key` | Sends your API key (`Authorization: Bearer …`, or `x-api-key` / `api-key` per adapter). The key may be a literal or an `${ENV_VAR}` reference. | Most providers. | | `forward` | Relays **your incoming Codex auth headers** verbatim to the provider — no key stored. This is the ChatGPT-login passthrough. | OpenAI (`openai-responses` adapter). | -| `oauth` | Resolves a stored OAuth access token (auto-refreshed before expiry) and uses it as the bearer key. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot. | +| `oauth` | Resolves a stored access token (refreshed or re-imported according to provider policy) and uses it as the bearer key. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot, WorkBuddy. | ## 1. ChatGPT login (forward / passthrough) @@ -63,9 +63,9 @@ The ChatGPT passthrough catalog also layers in the bare GPT-5.6 Sol/Terra/Luna s ## 2. Account login (OAuth) -Six provider presets use OAuth login — plus GitHub Copilot via an experimental unofficial -device-flow bridge. opencodex stores their credentials in -`~/.opencodex/auth.json` and refreshes them automatically. `chatgpt` is also accepted by the login +Eight provider presets use account login. GitHub Copilot uses an experimental unofficial +device-flow bridge, while WorkBuddy imports its signed-in desktop session. opencodex stores their credentials in +`~/.opencodex/auth.json` and follows each provider's refresh policy. `chatgpt` is also accepted by the login CLI; it acquires a ChatGPT credential while creating a `forward`-mode provider entry. ```bash @@ -76,6 +76,7 @@ ocx login kiro # import kiro-cli credentials (or token fallback) ocx login google-antigravity ocx login cursor # standalone Cursor PKCE login ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) +ocx login workbuddy # import the current WorkBuddy desktop session (macOS) ocx login chatgpt # standalone ChatGPT OAuth login ocx logout ``` @@ -89,6 +90,7 @@ ocx logout | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | +| `workbuddy` | `openai-chat` | `https://copilot.tencent.com/v2` | Experimental. Imports the current WorkBuddy desktop session and sends WorkBuddy's required client headers. | For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key), opencodex forwards only a caller-supplied stable `prompt_cache_key` to the Chat Completions request; @@ -105,7 +107,8 @@ OAuth providers whose credentials include a stable account id or email can keep login. The Providers page shows those accounts in a dropdown, lets you add another, and switches the active account without logging the others out. Only identity-less Kimi credentials replace the active slot; Kiro accounts are keyed by profile ARN. `chatgpt` is always single-slot because Codex -pool accounts have a separate ledger. +pool accounts have a separate ledger. WorkBuddy is also single-slot because its current desktop +session is the credential authority. Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked metadata only. ### OAuth reliability @@ -183,10 +186,22 @@ from the live CLI store, or when an existing primary CLI database has no recogni Repair or remove the unreadable database under the normal `kiro-cli` data path, unset those import selectors, then retry. Signing in from a machine with no existing `kiro-cli` session is unaffected. +### WorkBuddy desktop session import + +Open WorkBuddy and sign in before running `ocx login workbuddy`. On macOS, opencodex reads +`~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop.info`; set +`WORKBUDDY_AUTH_FILE` when the session file is stored elsewhere. + +The import reads only the current access token, expiry, account id, and domain. WorkBuddy keeps +ownership of refresh rotation: opencodex stores an external-session sentinel instead of copying the +desktop refresh token, and re-imports the current access token before routed requests and after an +upstream `401`. The integration is unofficial and may require updates when WorkBuddy changes its +desktop session or request format. + ## 3. API-key catalog -opencodex ships 53 built-in presets: 42 key-based, seven OAuth, three local, and the default -ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, +opencodex ships a built-in catalog of key-based, OAuth, local, and default ChatGPT-forward presets. +The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it. Notable entries: | Provider | Base URL | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index ef2dd1d5486..1037aa038bd 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -33,7 +33,7 @@ max input 922,000 で `*-pro` virtual ID は公開状態を維持し、wire で --- | --- | --- | | `key` | API キーを送信します(`Authorization: Bearer …`、またはアダプターにより `x-api-key` / `api-key`)。キーはリテラルまたは `${ENV_VAR}` 参照です。 | 大半のプロバイダー。 | | `forward` | **受け取った Codex 認証ヘッダーを**プロバイダーにそのまま中継します — キーを保存しません。ChatGPT ログインのパススルーです。 | OpenAI(`openai-responses` アダプター)。 | -| `oauth` | 保存された OAuth アクセストークンを読み込み bearer キーとして使い、期限切れ前に自動更新します。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor。 | +| `oauth` | 保存されたアクセストークンを読み込み、プロバイダーのポリシーに従って更新または再取り込みし、bearer キーとして使います。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor、GitHub Copilot、WorkBuddy。 | ## 1. ChatGPT ログイン(forward / パススルー) @@ -60,8 +60,8 @@ ChatGPT パススルーカタログには GPT-5.6 Sol/Terra/Luna の名前空間 ## 2. アカウントログイン(OAuth) -OAuth ログインを使うプロバイダープリセットは 6 つです。認証情報は -`~/.opencodex/auth.json` に保存され、自動更新されます。ログイン CLI は `chatgpt` も受け付けます。 +アカウントログインを使うプロバイダープリセットは 8 つです。認証情報は +`~/.opencodex/auth.json` に保存され、各プロバイダーの更新ポリシーに従います。ログイン CLI は `chatgpt` も受け付けます。 このコマンドは ChatGPT 認証情報を発行し `forward` モードのプロバイダーエントリを作成します。 ```bash @@ -71,6 +71,8 @@ ocx login kimi # Moonshot Kimi ocx login kiro # kiro-cli 認証情報の取り込み(トークンフォールバック対応) ocx login google-antigravity ocx login cursor # Cursor 専用 PKCE ログイン +ocx login github-copilot # GitHub device flow → Copilot トークン +ocx login workbuddy # 現在の WorkBuddy デスクトップセッションを取り込む (macOS) ocx login chatgpt # 別途 ChatGPT OAuth ログイン ocx logout ``` @@ -83,6 +85,8 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは Kiro CLI をインストール(`curl -fsSL https://cli.kiro.dev/install | bash`)し、`kiro-cli login` でサインインした既存セッションを取り込みます。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | +| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的な非公式 device-flow ブリッジ。 | +| `workbuddy` | `openai-chat` | `https://copilot.tencent.com/v2` | 実験的。現在の WorkBuddy デスクトップセッションを取り込み、必要なクライアントヘッダーを送信します。 | 正規の Kimi Coding Plan プリセット(`kimi` アカウントログインと `kimi-code` API key)では、 opencodex は呼び出し元が指定した安定した `prompt_cache_key` だけを Chat Completions リクエストへ @@ -98,7 +102,7 @@ opt-in した上流がこのフィールドを拒否しても、opencodex はフ 認証情報に固定アカウント ID やメールがある OAuth プロバイダーはログインを複数保持できます。 Providers ページでアカウントを追加し、別アカウントをログアウトせずにアクティブアカウントだけを切り替えられます。 アカウント識別情報がない Kimi 認証情報だけがアクティブスロットを差し替え、Kiro アカウントはプロファイル ARN をキーに保存されます。 -`chatgpt` は Codex アカウントプールに別の保存場所があり、常に単一スロットのみ書き込みます。トークンは `~/.opencodex/auth.json` に保存され、 +`chatgpt` は Codex アカウントプールに別の保存場所があり、常に単一スロットのみ書き込みます。WorkBuddy も現在のデスクトップセッションを正として単一スロットを使います。トークンは `~/.opencodex/auth.json` に保存され、 `/api/oauth/accounts` はマスク済みメタデータのみを返します。 ### Kiro 認証情報の取り込み @@ -114,10 +118,18 @@ Kiro のログインには Kiro CLI が必要です。`curl -fsSL https://cli.ki ロールバックはスナップショットがある場合にのみ可能なため、セッションストアが存在するのに取得できない場合(ファイルが読めない、スキーマの不一致、トークン選択があいまい)、`KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` が実際の CLI ストアと異なるインポート先を指す場合、またはプライマリ CLI データベースに認識できるトークン行がない場合、**アカウントを追加**は `kiro-cli` のログアウトを拒否します。通常の `kiro-cli` データパス上の壊れたデータベースを修復または削除し、インポート専用セレクタが設定されていれば解除してから再試行してください。既存の `kiro-cli` セッションがまったくない環境には影響しません。 +### WorkBuddy デスクトップセッションの取り込み + +`ocx login workbuddy` の前に WorkBuddy を開いてサインインしてください。macOS では +`~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop.info` を読みます。 +別の場所にある場合は `WORKBUDDY_AUTH_FILE` を設定します。アクセストークン、有効期限、アカウント ID、 +ドメインだけを読み取り、デスクトップのリフレッシュトークンはコピーしません。この非公式連携は +WorkBuddy のセッション形式やリクエスト形式が変わると更新が必要になる場合があります。 + ## 3. API キーカタログ -opencodex v2.7.1 には組み込みプリセットが 50 個含まれています。キー方式 40、OAuth 6、ローカル 3、 -デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 +opencodex にはキー方式、OAuth、ローカル、デフォルト ChatGPT 転送の組み込みカタログがあります。 +ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します。主な項目は以下のとおりです: | プロバイダー | ベース URL | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 208839d8107..4bbd52544ed 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -33,7 +33,7 @@ shipped v1 config는 marker 2의 단일 옵션 행으로 자동 이관됩니다. | --- | --- | --- | | `key` | API 키를 전송합니다(`Authorization: Bearer …`, 또는 어댑터에 따라 `x-api-key` / `api-key`). 키는 리터럴이거나 `${ENV_VAR}` 참조일 수 있습니다. | 대부분의 프로바이더. | | `forward` | **수신된 Codex 인증 헤더를** 프로바이더에 그대로 중계합니다 — 키를 저장하지 않습니다. ChatGPT 로그인 패스스루입니다. | OpenAI (`openai-responses` 어댑터). | -| `oauth` | 저장된 OAuth 액세스 토큰을 불러와 bearer 키로 사용하며, 만료 전에 자동 갱신합니다. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor. | +| `oauth` | 저장된 액세스 토큰을 불러와 프로바이더 정책에 따라 갱신하거나 다시 가져온 뒤 bearer 키로 사용합니다. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot, WorkBuddy. | ## 1. ChatGPT 로그인 (forward / 패스스루) @@ -60,8 +60,8 @@ ChatGPT 패스스루 카탈로그에는 GPT-5.6 Sol/Terra/Luna의 네임스페 ## 2. 계정 로그인 (OAuth) -OAuth 로그인을 사용하는 프로바이더 프리셋은 여섯 개입니다. 자격 증명은 -`~/.opencodex/auth.json`에 저장되고 자동으로 갱신됩니다. 로그인 CLI는 `chatgpt`도 받습니다. +계정 로그인을 사용하는 프로바이더 프리셋은 여덟 개입니다. 자격 증명은 +`~/.opencodex/auth.json`에 저장되고 각 프로바이더의 갱신 정책을 따릅니다. 로그인 CLI는 `chatgpt`도 받습니다. 이 명령은 ChatGPT 자격 증명을 발급받고 `forward` 모드 프로바이더 항목을 만듭니다. ```bash @@ -71,6 +71,8 @@ ocx login kimi # Moonshot Kimi ocx login kiro # kiro-cli 자격 증명 가져오기(토큰 폴백 지원) ocx login google-antigravity ocx login cursor # Cursor 전용 PKCE 로그인 +ocx login github-copilot # GitHub device flow → Copilot 토큰 +ocx login workbuddy # 현재 WorkBuddy 데스크톱 세션 가져오기 (macOS) ocx login chatgpt # 별도 ChatGPT OAuth 로그인 ocx logout ``` @@ -83,6 +85,8 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 Kiro CLI를 설치(`curl -fsSL https://cli.kiro.dev/install | bash`)하고 `kiro-cli login`으로 로그인한 기존 세션을 가져옵니다. **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | +| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적인 비공식 device-flow 브리지입니다. | +| `workbuddy` | `openai-chat` | `https://copilot.tencent.com/v2` | 실험적입니다. 현재 WorkBuddy 데스크톱 세션을 가져와 필요한 클라이언트 헤더를 전송합니다. | 정식 Kimi Coding Plan 프리셋(`kimi` 계정 로그인과 `kimi-code` API key)의 경우, opencodex는 호출자가 제공한 안정적인 `prompt_cache_key`만 Chat Completions 요청으로 전달하며 직접 생성하지 @@ -98,7 +102,7 @@ deny-by-default 상태로 유지됩니다. 자격 증명에 고정된 계정 id나 이메일이 있는 OAuth 프로바이더는 로그인을 여러 개 보관할 수 있습니다. Providers 페이지에서 계정을 추가하고, 다른 계정을 로그아웃하지 않은 채 활성 계정만 바꿀 수 있습니다. 계정 식별 정보가 없는 Kimi 자격 증명만 활성 슬롯을 교체하며, Kiro 계정은 프로필 ARN을 키로 저장됩니다. -`chatgpt`는 Codex 계정 풀에 별도 저장소가 있어 항상 단일 슬롯만 씁니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, +`chatgpt`는 Codex 계정 풀에 별도 저장소가 있어 항상 단일 슬롯만 씁니다. WorkBuddy도 현재 데스크톱 세션을 기준으로 단일 슬롯을 사용합니다. 토큰은 `~/.opencodex/auth.json`에 저장되고, `/api/oauth/accounts`는 마스킹된 메타데이터만 반환합니다. ### Kiro 자격 증명 가져오기 @@ -114,10 +118,18 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. `curl -fsSL https://cli.kiro.d 롤백은 스냅샷이 있을 때만 가능하므로, 세션 저장소가 존재하지만 캡처할 수 없는 경우(파일을 읽을 수 없음, 스키마 불일치, 토큰 선택 모호), `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE`이 실제 CLI 저장소와 다른 가져오기 경로를 가리키는 경우, 또는 기본 CLI 데이터베이스에 인식 가능한 토큰 행이 없는 경우 **계정 추가**는 `kiro-cli` 로그아웃을 거부합니다. 일반 `kiro-cli` 데이터 경로의 손상된 데이터베이스를 수리하거나 제거하고, 가져오기 전용 선택자가 설정돼 있으면 해제한 뒤 다시 시도하세요. 기존 `kiro-cli` 세션이 아예 없는 환경에서는 영향이 없습니다. +### WorkBuddy 데스크톱 세션 가져오기 + +`ocx login workbuddy`를 실행하기 전에 WorkBuddy를 열고 로그인하세요. macOS에서는 +`~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop.info`를 읽습니다. +파일 위치가 다르면 `WORKBUDDY_AUTH_FILE`을 설정하세요. 액세스 토큰, 만료 시각, 계정 ID, 도메인만 +읽으며 데스크톱 refresh token은 복사하지 않습니다. 이 비공식 연동은 WorkBuddy의 세션 또는 요청 +형식이 바뀌면 업데이트가 필요할 수 있습니다. + ## 3. API 키 카탈로그 -opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방식 40개, OAuth 6개, 로컬 3개, -기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, +opencodex에는 키 방식, OAuth, 로컬, 기본 ChatGPT 포워드 프리셋의 빌트인 카탈로그가 있습니다. +대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다. 주요 항목은 다음과 같습니다: | 프로바이더 | 베이스 URL | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index d6df82506d2..0ece06c8a6b 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -39,7 +39,7 @@ description: Все способы, которыми opencodex аутентиф | --- | --- | --- | | `key` | Отправляет ваш API-ключ (`Authorization: Bearer …` либо `x-api-key` / `api-key` в зависимости от адаптера). Ключ может быть литералом или ссылкой вида `${ENV_VAR}`. | Большинство провайдеров. | | `forward` | Передаёт провайдеру **входящие заголовки аутентификации Codex** без изменений — ключ не хранится. Это сквозной режим (passthrough) входа через ChatGPT. | OpenAI (адаптер `openai-responses`). | -| `oauth` | Берёт сохранённый OAuth-токен доступа (автоматически обновляется до истечения срока) и использует его как bearer-ключ. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot. | +| `oauth` | Берёт сохранённый токен доступа, обновляет или повторно импортирует его согласно политике провайдера и использует как bearer-ключ. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot, WorkBuddy. | ## 1. Вход через ChatGPT (forward / passthrough) @@ -66,9 +66,9 @@ account id, OpenAI beta/originator/session — см. [Адаптеры](/ru/refe ## 2. Вход по аккаунту (OAuth) -Шесть пресетов провайдеров используют вход через OAuth — плюс GitHub Copilot через -экспериментальный неофициальный мост device flow. opencodex хранит их учётные данные в -`~/.opencodex/auth.json` и обновляет их автоматически. CLI входа также принимает `chatgpt`: эта +Восемь пресетов провайдеров используют вход через аккаунт. GitHub Copilot использует +экспериментальный неофициальный мост device flow, а WorkBuddy импортирует сессию настольного приложения. opencodex хранит их учётные данные в +`~/.opencodex/auth.json` и следует политике обновления каждого провайдера. CLI входа также принимает `chatgpt`: эта команда получает учётные данные ChatGPT и одновременно создаёт запись провайдера в режиме `forward`. ```bash @@ -79,6 +79,7 @@ ocx login kiro # импорт учётных данных kiro-cli (с ocx login google-antigravity ocx login cursor # отдельный PKCE-вход Cursor ocx login github-copilot # device flow GitHub → токен Copilot (Copilot Pro/Business) +ocx login workbuddy # импорт текущей сессии WorkBuddy (macOS) ocx login chatgpt # отдельный OAuth-вход ChatGPT ocx logout ``` @@ -92,6 +93,7 @@ ocx logout | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | +| `workbuddy` | `openai-chat` | `https://copilot.tencent.com/v2` | Экспериментально. Импортирует текущую сессию WorkBuddy и отправляет необходимые клиентские заголовки. | Для канонических пресетов Kimi Coding Plan (вход через аккаунт `kimi` и API-ключ `kimi-code`) opencodex передаёт в запрос Chat Completions только стабильный `prompt_cache_key`, предоставленный @@ -108,7 +110,7 @@ OAuth-провайдеры, чьи учётные данные содержат несколько входов. Страница Providers показывает эти аккаунты в выпадающем списке, позволяет добавить ещё один и переключает активный аккаунт, не выполняя выход из остальных. Учётные данные Только учётные данные Kimi без идентификатора заменяют активный слот; аккаунты Kiro сохраняются по ARN профиля. -`chatgpt` всегда занимает один слот, поскольку у пула аккаунтов Codex отдельный реестр. Токены остаются в `~/.opencodex/auth.json`; +`chatgpt` всегда занимает один слот, поскольку у пула аккаунтов Codex отдельный реестр. WorkBuddy также использует один слот, поскольку источником истины служит текущая сессия настольного приложения. Токены остаются в `~/.opencodex/auth.json`; `/api/oauth/accounts` возвращает только маскированные метаданные. ### Импорт учётных данных Kiro @@ -124,10 +126,18 @@ OAuth-провайдеры, чьи учётные данные содержат Поскольку откат возможен только при наличии снимка, **Добавить аккаунт** откажется выходить из `kiro-cli`, если хранилище сессии существует, но его нельзя захватить (файл не читается, несовпадение схемы, неоднозначный выбор токена), если `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` направляют импорт не на активное хранилище CLI, или если в основной базе CLI нет распознаваемой строки токена. Исправьте или удалите повреждённую базу по обычному пути данных `kiro-cli`, снимите селекторы только для импорта и повторите попытку. На машины без существующей сессии `kiro-cli` это не влияет. +### Импорт сессии WorkBuddy + +Перед `ocx login workbuddy` откройте WorkBuddy и войдите в аккаунт. В macOS opencodex читает +`~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop.info`; +для другого расположения задайте `WORKBUDDY_AUTH_FILE`. Импортируются только токен доступа, срок +действия, ID аккаунта и домен; refresh token настольного приложения не копируется. Это неофициальная +интеграция, которую может потребоваться обновить при изменении формата сессии или запросов WorkBuddy. + ## 3. Каталог API-ключей -opencodex поставляется с 53 встроенными пресетами: 42 на основе ключей, семь OAuth, три локальных и -пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу +opencodex поставляется со встроенным каталогом пресетов на основе ключей, OAuth, локальных серверов +и ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его. Наиболее заметные записи: | Провайдер | Базовый URL | 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 357ddbcacf2..fde4fdd5472 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -32,7 +32,7 @@ shipped v1 配置自动迁移到 marker 2 的单一选项行。原配置只保 | --- | --- | --- | | `key` | 发送你的 API 密钥(`Authorization: Bearer …`,或按 adapter 使用 `x-api-key` / `api-key`)。密钥可以是字面值,也可以是 `${ENV_VAR}` 引用。 | 大多数提供商。 | | `forward` | 将**你传入的 Codex 认证请求头**原样转发给提供商——不存储任何密钥。这就是 ChatGPT 登录的透传方式。 | OpenAI(`openai-responses` adapter)。 | -| `oauth` | 读取已存储的 OAuth 访问令牌(过期前自动刷新),并将其用作 bearer 密钥。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor。 | +| `oauth` | 读取已存储的访问令牌,按提供商策略刷新或重新导入,并将其用作 bearer 密钥。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor、GitHub Copilot、WorkBuddy。 | ## 1. ChatGPT 登录(forward / 透传) @@ -55,7 +55,7 @@ ChatGPT 透传目录也会加入 GPT-5.6 Sol/Terra/Luna 的裸 slug(`gpt-5.6-s ## 2. 账号登录(OAuth) -有六个提供商预设使用 OAuth 登录。opencodex 会把凭据存入 `~/.opencodex/auth.json` 并自动刷新。 +有八个提供商预设使用账号登录。GitHub Copilot 使用实验性的非官方设备流桥接,WorkBuddy 则导入已登录的桌面会话。opencodex 会把凭据存入 `~/.opencodex/auth.json`,并遵循各提供商的刷新策略。 登录 CLI 也接受 `chatgpt`:它会获取一份 ChatGPT 凭据,并创建一个 `forward` 模式的提供商条目。 ```bash @@ -65,6 +65,8 @@ ocx login kimi # Moonshot Kimi ocx login kiro # 导入 kiro-cli 凭据(支持令牌回退) ocx login google-antigravity ocx login cursor # 独立的 Cursor PKCE 登录 +ocx login github-copilot # GitHub 设备流 → Copilot 令牌 +ocx login workbuddy # 导入当前 WorkBuddy 桌面会话(macOS) ocx login chatgpt # 独立的 ChatGPT OAuth 登录 ocx logout ``` @@ -77,6 +79,8 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(使用 `curl -fsSL https://cli.kiro.dev/install | bash` 安装,然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 | +| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性的非官方设备流桥接。 | +| `workbuddy` | `openai-chat` | `https://copilot.tencent.com/v2` | 实验性。导入当前 WorkBuddy 桌面会话并发送必需的客户端请求头。 | 对于规范的 Kimi Coding Plan 预设(`kimi` 账号登录和 `kimi-code` API key),opencodex 只会把调用方提供的稳定 `prompt_cache_key` 转发到 Chat Completions 请求,绝不自行生成。Kimi @@ -90,7 +94,7 @@ provider 仍保持 deny-by-default。 OAuth 凭据中带有稳定账号 id 或邮箱的提供商可以保存多个登录。Providers 页面会在下拉列表中显示这些 账号,允许继续添加,并在不登出其他账号的情况下切换当前账号。只有没有身份信息的 Kimi 凭据会替换 -当前 active slot;Kiro 账户以配置文件 ARN 为键。`chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。令牌仍保存在 +当前 active slot;Kiro 账户以配置文件 ARN 为键。`chatgpt` 始终只有一个 slot,因为 Codex 账号池使用独立存储。WorkBuddy 也只有一个 slot,以当前桌面会话为准。令牌仍保存在 `~/.opencodex/auth.json` 中;`/api/oauth/accounts` 只返回脱敏后的 metadata。 ### Kiro 凭据导入 @@ -106,10 +110,18 @@ Kiro 登录需要 Kiro CLI:使用 `curl -fsSL https://cli.kiro.dev/install | b 由于回滚依赖快照,当会话存储已存在但无法捕获时(文件不可读、架构不匹配、令牌选择有歧义),当 `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` 将导入路径指向与活动 CLI 存储不同的位置时,或当主 CLI 数据库没有可识别的令牌行时,**添加账户**会拒绝将 `kiro-cli` 登出。请修复或删除常规 `kiro-cli` 数据路径下的损坏数据库,并取消仅用于导入的选择器后重试。对于完全没有现有 `kiro-cli` 会话的机器,不受影响。 +### WorkBuddy 桌面会话导入 + +运行 `ocx login workbuddy` 前,请先打开 WorkBuddy 并登录。macOS 上默认读取 +`~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/workbuddy-desktop.info`; +若文件位于其他位置,可设置 `WORKBUDDY_AUTH_FILE`。导入过程只读取访问令牌、过期时间、账号 ID +和域名,不会复制桌面端的 refresh token。这是一项非官方集成;如果 WorkBuddy 更改会话或请求格式, +可能需要同步更新。 + ## 3. API 密钥目录 -opencodex v2.7.1 内置 50 个预设:40 个密钥预设、6 个 OAuth 预设、3 个本地预设,以及默认的 -ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 +opencodex 内置了密钥、OAuth、本地以及默认 ChatGPT 转发预设目录。仪表盘的 **Add provider** +选择器会打开密钥提供商的控制台,验证并保存密钥。 主要条目包括: | 提供商 | 基础 URL | diff --git a/gui/src/oauth-tos-risk.ts b/gui/src/oauth-tos-risk.ts index d894d1f121b..995c118f994 100644 --- a/gui/src/oauth-tos-risk.ts +++ b/gui/src/oauth-tos-risk.ts @@ -8,7 +8,7 @@ export type OAuthTosRiskLevel = "high" | "elevated"; const HIGH_RISK = new Set(["anthropic", "google-antigravity"]); -const ELEVATED_RISK = new Set(["github-copilot", "cursor"]); +const ELEVATED_RISK = new Set(["github-copilot", "cursor", "workbuddy"]); export function oauthTosRisk(providerId: string): OAuthTosRiskLevel | null { const id = providerId.trim().toLowerCase(); diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index 34ebbc825fa..ba4edb5fefc 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -48,6 +48,7 @@ const OAUTH_LABELS: Record = { kimi: "Kimi (Moonshot)", "google-antigravity": "Google Antigravity", "github-copilot": "GitHub Copilot", + workbuddy: "WorkBuddy", cursor: "Cursor", }; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 5ebaa320c66..87c205998c8 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -12,6 +12,7 @@ import { loginChatGPT, refreshChatGPTToken } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; +import { buildWorkBuddyRequestHeaders, loginWorkBuddy, readWorkBuddyCredential, readWorkBuddySession, refreshWorkBuddyToken } from "./workbuddy"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; import { effectiveGoogleMode, getProviderRegistryEntry } from "../providers/registry"; @@ -46,6 +47,8 @@ export interface OAuthAccessSnapshot { accountId: string; generation: string; accessToken: string; + /** Provider-scoped, non-secret headers derived from the same credential generation. */ + requestHeaders?: Record; /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */ kiro?: Pick; } @@ -145,6 +148,14 @@ export const OAUTH_PROVIDERS: Record = { // Unofficial Copilot bridge — keep proactive traffic lazy-only (no background guardian spam). defaultRefreshPolicy: "lazy-only", }, + workbuddy: { + login: (ctrl) => loginWorkBuddy(ctrl), + refresh: (rt, signal) => refreshWorkBuddyToken(rt, signal), + providerConfig: oauthConfig("workbuddy"), + defaultModel: oauthDefaultModel("workbuddy"), + // WorkBuddy owns refresh rotation; OpenCodex only re-imports its desktop session. + defaultRefreshPolicy: "disabled", + }, chatgpt: { login: loginChatGPT, refresh: (rt) => refreshChatGPTToken(rt), @@ -206,7 +217,20 @@ export class OAuthLoginRequiredError extends Error { } } -function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot { +function workBuddyRequestHeadersForCredential(cred: OAuthCredentials): Record { + const session = readWorkBuddySession(); + if (session.accessToken !== cred.access || session.userId !== cred.accountId) { + throw new Error("The WorkBuddy desktop login changed while preparing the request. Retry the request."); + } + return buildWorkBuddyRequestHeaders(session); +} + +function accessSnapshot( + provider: string, + accountId: string, + cred: OAuthCredentials, + includeRequestHeaders = true, +): OAuthAccessSnapshot { const storedKiroRouting = { ...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}), ...(cred.kiro?.apiRegion ? { apiRegion: cred.kiro.apiRegion } : {}), @@ -217,6 +241,9 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti accountId, generation: credentialGeneration(cred), accessToken: cred.access, + ...(provider === "workbuddy" && includeRequestHeaders + ? { requestHeaders: workBuddyRequestHeadersForCredential(cred) } + : {}), // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials // may use explicit environment routing, but never borrow the currently signed-in local CLI account. ...(provider === "kiro" @@ -238,8 +265,10 @@ async function resolveAccessSnapshotForAccount( if (!def) throw new UnsupportedOAuthProviderError(provider); const cred = getAccountCredential(provider, accountId); if (!cred) throw new OAuthLoginRequiredError(provider); - const current = accessSnapshot(provider, accountId, cred); - if (rejectedGeneration !== undefined && current.generation !== rejectedGeneration) return current; + const current = accessSnapshot(provider, accountId, cred, rejectedGeneration === undefined); + if (rejectedGeneration !== undefined && current.generation !== rejectedGeneration) { + return accessSnapshot(provider, accountId, cred); + } if (rejectedGeneration === undefined && cred.expires > Date.now() + REFRESH_SKEW_MS) return current; const key = `${provider}\u0000${accountId}`; @@ -265,13 +294,20 @@ async function resolveAccessSnapshotForAccount( } export async function getValidAccessTokenSnapshot(provider: string): Promise { + if (provider === "workbuddy") { + const disk = readWorkBuddyCredential(); + const stored = getCredential(provider); + if (!stored || credentialGeneration(stored) !== credentialGeneration(disk)) { + await saveCredential(provider, disk); + } + } const set = getAccountSet(provider); if (!set) throw new OAuthLoginRequiredError(provider); return resolveAccessSnapshotForAccount(provider, set.activeAccountId); } /** Providers whose upstream-401 replay path may force a snapshot refresh. */ -const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro"]); +const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro", "workbuddy"]); export async function forceRefreshOAuthAccessSnapshot( rejected: OAuthAccessSnapshot, diff --git a/src/oauth/store.ts b/src/oauth/store.ts index ad761d6a004..494738e19ce 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -25,7 +25,7 @@ import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, Provider type AuthStore = Record; /** Providers whose account set is pinned to a single slot (see module doc). */ -const SINGLE_SLOT_PROVIDERS = new Set(["chatgpt"]); +const SINGLE_SLOT_PROVIDERS = new Set(["chatgpt", "workbuddy"]); export function getAuthStorePath(): string { return join(getConfigDir(), "auth.json"); diff --git a/src/oauth/workbuddy.ts b/src/oauth/workbuddy.ts new file mode 100644 index 00000000000..206086ed33f --- /dev/null +++ b/src/oauth/workbuddy.ts @@ -0,0 +1,159 @@ +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { OAuthController, OAuthCredentials } from "./types"; + +export const WORKBUDDY_EXTERNAL_SESSION_REFRESH = "workbuddy-external-session"; + +const DEFAULT_DOMAIN = "www.codebuddy.cn"; +const REFRESH_SKEW_MS = 60_000; + +interface WorkBuddyAuthFile { + account?: { + uid?: unknown; + }; + auth?: { + accessToken?: unknown; + domain?: unknown; + expiresAt?: unknown; + }; +} + +export interface WorkBuddySession { + accessToken: string; + userId: string; + domain: string; + expiresAt: number; +} + +export interface WorkBuddyRequestIdentity { + conversationId: string; + requestId: string; +} + +export function workBuddyAuthPath(): string { + const override = process.env.WORKBUDDY_AUTH_FILE?.trim(); + if (override) return override; + return join( + homedir(), + "Library", + "Application Support", + "CodeBuddyExtension", + "Data", + "Public", + "auth", + "workbuddy-desktop.info", + ); +} + +function requiredHeaderValue(value: unknown, label: string, maxLength: number): string { + if (typeof value !== "string") throw new Error(`WorkBuddy ${label} is missing.`); + const normalized = value.trim(); + if (!normalized || normalized.length > maxLength || /[\r\n]/u.test(normalized)) { + throw new Error(`WorkBuddy ${label} is invalid.`); + } + return normalized; +} + +function epochMilliseconds(value: unknown): number { + const numeric = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) throw new Error("WorkBuddy token expiry is missing or invalid."); + return numeric < 10_000_000_000 ? numeric * 1000 : numeric; +} + +function safeDomain(value: unknown): string { + const candidate = typeof value === "string" && value.trim() ? value.trim() : DEFAULT_DOMAIN; + if ( + candidate.length > 253 + || candidate.startsWith(".") + || candidate.endsWith(".") + || candidate.includes("..") + || !/^[a-z0-9.-]+$/iu.test(candidate) + ) throw new Error("WorkBuddy domain is invalid."); + return candidate; +} + +export function parseWorkBuddySession(raw: string): WorkBuddySession { + let parsed: WorkBuddyAuthFile; + try { + parsed = JSON.parse(raw) as WorkBuddyAuthFile; + } catch { + throw new Error("The WorkBuddy desktop login file is not valid JSON."); + } + return { + accessToken: requiredHeaderValue(parsed.auth?.accessToken, "access token", 32_768), + userId: requiredHeaderValue(parsed.account?.uid, "user id", 1_024), + domain: safeDomain(parsed.auth?.domain), + expiresAt: epochMilliseconds(parsed.auth?.expiresAt), + }; +} + +export function readWorkBuddySession(authFile = workBuddyAuthPath()): WorkBuddySession { + let raw: string; + try { + raw = readFileSync(authFile, "utf8"); + } catch { + throw new Error("Could not read the WorkBuddy desktop login. Open WorkBuddy and sign in first."); + } + return parseWorkBuddySession(raw); +} + +export function credentialFromWorkBuddySession(session: WorkBuddySession): OAuthCredentials { + return { + access: session.accessToken, + // Refresh remains owned by WorkBuddy. OpenCodex re-imports the desktop session + // instead of copying or independently rotating WorkBuddy's refresh token. + refresh: WORKBUDDY_EXTERNAL_SESSION_REFRESH, + expires: session.expiresAt, + accountId: session.userId, + source: "local-cli", + }; +} + +export function readWorkBuddyCredential(): OAuthCredentials { + return credentialFromWorkBuddySession(readWorkBuddySession()); +} + +function assertCurrentSession(credential: OAuthCredentials): OAuthCredentials { + if (credential.expires <= Date.now() + REFRESH_SKEW_MS) { + throw new Error("The WorkBuddy desktop login is expired. Reopen WorkBuddy and sign in again."); + } + return credential; +} + +export async function loginWorkBuddy(ctrl: OAuthController): Promise { + ctrl.onProgress?.("Importing the current WorkBuddy desktop login..."); + return assertCurrentSession(readWorkBuddyCredential()); +} + +export async function refreshWorkBuddyToken( + _refreshToken: string, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) throw new DOMException("WorkBuddy login refresh was aborted.", "AbortError"); + return assertCurrentSession(readWorkBuddyCredential()); +} + +export function buildWorkBuddyRequestHeaders( + session = readWorkBuddySession(), + identity: WorkBuddyRequestIdentity = { + conversationId: randomUUID(), + requestId: randomUUID().replaceAll("-", ""), + }, +): Record { + return { + "X-User-Id": session.userId, + "X-Domain": session.domain, + "X-Product": "workbuddy-desktop", + "X-IDE-Type": "workbuddy", + "X-IDE-Name": "WorkBuddy", + "X-Conversation-ID": identity.conversationId, + "X-Conversation-Request-ID": identity.requestId, + "X-Conversation-Message-ID": identity.requestId, + "X-Request-ID": identity.requestId, + "X-Agent-Intent": "craft", + "X-Private-Data": "false", + "X-Requested-With": "XMLHttpRequest", + }; +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 7fea6644f08..47b84bf0fcb 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -13,6 +13,12 @@ import { cursorModelInputModalities, cursorModelReasoningEfforts, } from "../adapters/cursor/discovery"; +import { + WORKBUDDY_MODELS, + WORKBUDDY_MODEL_CONTEXT_WINDOWS, + WORKBUDDY_MODEL_INPUT_MODALITIES, + WORKBUDDY_MODEL_REASONING_EFFORTS, +} from "./workbuddy-models"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; @@ -718,6 +724,22 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // evidence from ai.google.dev does not establish Vertex publisher availability. { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.6-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, + { + id: "workbuddy", + label: "WorkBuddy", + adapter: "openai-chat", + baseUrl: "https://copilot.tencent.com/v2", + authKind: "oauth", + dashboardPreset: true, + note: "Imports the current WorkBuddy desktop login and follows token rotations from the local session file.", + models: [...WORKBUDDY_MODELS], + liveModels: false, + defaultModel: "auto", + modelContextWindows: WORKBUDDY_MODEL_CONTEXT_WINDOWS, + modelInputModalities: WORKBUDDY_MODEL_INPUT_MODALITIES, + modelReasoningEfforts: WORKBUDDY_MODEL_REASONING_EFFORTS, + parallelToolCalls: true, + }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, diff --git a/src/providers/workbuddy-models.ts b/src/providers/workbuddy-models.ts new file mode 100644 index 00000000000..9ef87db1f36 --- /dev/null +++ b/src/providers/workbuddy-models.ts @@ -0,0 +1,37 @@ +// Live catalog returned by WorkBuddy 5.2.6 through its public ACP session/new response. +// Model ids intentionally follow the wire values, not the UI display labels. +export const WORKBUDDY_MODELS = [ + "auto", + "hy3", + "glm-5.2", + "glm-5.1", + "glm-5v-turbo", + "minimax-m3", + "kimi-k3-1", + "kimi-k2.7", + "kimi-k2.6", + "deepseek-v4-flash", + "deepseek-v4-pro", +] as const; + +export const WORKBUDDY_MODEL_CONTEXT_WINDOWS: Record = { + auto: 168_000, + hy3: 192_000, + "glm-5.2": 1_000_000, + "glm-5.1": 200_000, + "glm-5v-turbo": 200_000, + "minimax-m3": 1_000_000, + "kimi-k3-1": 1_000_000, + "kimi-k2.7": 256_000, + "kimi-k2.6": 256_000, + "deepseek-v4-flash": 1_000_000, + "deepseek-v4-pro": 1_000_000, +}; + +export const WORKBUDDY_MODEL_INPUT_MODALITIES: Record = Object.fromEntries( + WORKBUDDY_MODELS.map(id => [id, ["text", "image"]]), +); + +export const WORKBUDDY_MODEL_REASONING_EFFORTS: Record = Object.fromEntries( + WORKBUDDY_MODELS.map(id => [id, ["low", "medium", "high", "xhigh", "max"]]), +); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 74ab53725c1..8463b2d1089 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1226,7 +1226,7 @@ export async function handleResponses( // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the // existing openai-chat / anthropic adapters authenticate with no change. - const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro") + const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro" || route.providerName === "workbuddy") && route.provider.authMode === "oauth"; let sentOAuthSnapshot: OAuthAccessSnapshot | undefined; let anthropicPoolAccountId: string | null = null; @@ -1266,6 +1266,12 @@ export async function handleResponses( const resolved = await getValidAccessTokenSnapshot(route.providerName); if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved; route.provider = { ...route.provider, apiKey: resolved.accessToken }; + if (route.providerName === "workbuddy") { + route.provider = { + ...route.provider, + headers: { ...route.provider.headers, ...resolved.requestHeaders }, + }; + } if (route.providerName === "kiro") { // `{}` is intentional: this is an account-scoped request with no stored routing metadata. // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback. @@ -2169,9 +2175,16 @@ export async function handleResponses( if (route.providerName === "kiro") { parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) }; } + const replayProvider = route.providerName === "workbuddy" + ? { + ...route.provider, + apiKey: refreshed.accessToken, + headers: { ...route.provider.headers, ...refreshed.requestHeaders }, + } + : { ...route.provider, apiKey: refreshed.accessToken }; const refreshedProvider = resolveProviderTransport( route.providerName, - { ...route.provider, apiKey: refreshed.accessToken }, + replayProvider, parsed.options.promptCacheKey, route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined, ); diff --git a/tests/oauth-tos-warning.test.ts b/tests/oauth-tos-warning.test.ts index abcd4b8f74f..83d24b2feef 100644 --- a/tests/oauth-tos-warning.test.ts +++ b/tests/oauth-tos-warning.test.ts @@ -16,6 +16,7 @@ describe("oauth ToS risk map", () => { test("flags elevated unofficial bridges", () => { expect(oauthTosRisk("github-copilot")).toBe("elevated"); expect(oauthTosRisk("cursor")).toBe("elevated"); + expect(oauthTosRisk("workbuddy")).toBe("elevated"); }); test("leaves lower-risk OAuth providers unmarked", () => { @@ -47,6 +48,7 @@ describe("oauth ToS warning UI seam", () => { expect(risk).toContain('"anthropic"'); expect(risk).toContain('"google-antigravity"'); expect(risk).toContain('"github-copilot"'); + expect(risk).toContain('"workbuddy"'); expect(providersSeam).toContain("OAuthTosWarningModal"); expect(providersSeam).toContain("requestLoginOAuth"); expect(providersSeam).toContain("oauthTosRisk(provider)"); diff --git a/tests/workbuddy-provider.test.ts b/tests/workbuddy-provider.test.ts new file mode 100644 index 00000000000..8bce4a14111 --- /dev/null +++ b/tests/workbuddy-provider.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { saveConfig } from "../src/config"; +import { getValidAccessTokenSnapshot, listOAuthProviders } from "../src/oauth"; +import { getAccountSet, getCredential } from "../src/oauth/store"; +import { + buildWorkBuddyRequestHeaders, + credentialFromWorkBuddySession, + loginWorkBuddy, + parseWorkBuddySession, + refreshWorkBuddyToken, + WORKBUDDY_EXTERNAL_SESSION_REFRESH, + workBuddyAuthPath, +} from "../src/oauth/workbuddy"; +import { deriveOAuthProviderConfig } from "../src/providers/derive"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { WORKBUDDY_MODELS } from "../src/providers/workbuddy-models"; +import { startServer } from "../src/server"; +import type { OcxConfig, OcxParsedRequest } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +const originalAuthFile = process.env.WORKBUDDY_AUTH_FILE; +const originalOpenCodexHome = process.env.OPENCODEX_HOME; +let testDir = ""; +let authFile = ""; +let isolatedCodexHome: IsolatedCodexHome | null = null; +const originalFetch = globalThis.fetch; + +function authPayload( + accessToken: string, + expiresAt = Date.now() + 3_600_000, + userId = "fixture-user-id", +): string { + return JSON.stringify({ + account: { uid: userId }, + auth: { + accessToken, + refreshToken: "desktop-owned-value", + domain: "www.codebuddy.cn", + expiresAt, + }, + }); +} + +function parsedRequest(): OcxParsedRequest { + return { + modelId: "glm-5.2", + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + stream: true, + options: {}, + }; +} + +beforeEach(() => { + testDir = join(tmpdir(), `workbuddy-provider-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + authFile = join(testDir, "workbuddy-desktop.info"); + process.env.WORKBUDDY_AUTH_FILE = authFile; + process.env.OPENCODEX_HOME = join(testDir, "opencodex"); + isolatedCodexHome = installIsolatedCodexHome("ocx-workbuddy-codex-"); + writeFileSync(authFile, authPayload("fixture-access-one")); +}); + +afterEach(() => { + if (originalAuthFile === undefined) delete process.env.WORKBUDDY_AUTH_FILE; + else process.env.WORKBUDDY_AUTH_FILE = originalAuthFile; + if (originalOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalOpenCodexHome; + globalThis.fetch = originalFetch; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + rmSync(testDir, { recursive: true, force: true }); +}); + +describe("WorkBuddy desktop credential import", () => { + test("parses the desktop session and normalizes second-based expiry", () => { + const expiresAtSeconds = Math.floor((Date.now() + 3_600_000) / 1000); + const session = parseWorkBuddySession(authPayload("fixture-access", expiresAtSeconds)); + + expect(session).toEqual({ + accessToken: "fixture-access", + userId: "fixture-user-id", + domain: "www.codebuddy.cn", + expiresAt: expiresAtSeconds * 1000, + }); + }); + + test("rejects header injection without echoing the rejected value", () => { + const canary = "line-break-canary"; + const raw = JSON.stringify({ + account: { uid: "fixture-user-id" }, + auth: { accessToken: `fixture-access\r\n${canary}`, expiresAt: Date.now() + 3_600_000 }, + }); + + try { + parseWorkBuddySession(raw); + throw new Error("expected parser to reject the access token"); + } catch (error) { + expect((error as Error).message).toBe("WorkBuddy access token is invalid."); + expect((error as Error).message).not.toContain(canary); + } + }); + + test("stores an external-session sentinel instead of the desktop refresh token", async () => { + const credential = await loginWorkBuddy({}); + + expect(credential.refresh).toBe(WORKBUDDY_EXTERNAL_SESSION_REFRESH); + expect(credential.accountId).toBe("fixture-user-id"); + expect(JSON.stringify(credential)).not.toContain("desktop-owned-value"); + }); + + test("re-imports a rotated desktop access token before each snapshot", async () => { + const first = await getValidAccessTokenSnapshot("workbuddy"); + writeFileSync(authFile, authPayload("fixture-access-two")); + const second = await getValidAccessTokenSnapshot("workbuddy"); + + expect(first.accessToken).toBe("fixture-access-one"); + expect(first.requestHeaders?.["X-User-Id"]).toBe("fixture-user-id"); + expect(first.requestHeaders?.Authorization).toBeUndefined(); + expect(second.accessToken).toBe("fixture-access-two"); + expect(second.generation).not.toBe(first.generation); + expect(getCredential("workbuddy")?.refresh).toBe(WORKBUDDY_EXTERNAL_SESSION_REFRESH); + expect(readFileSync(join(process.env.OPENCODEX_HOME!, "auth.json"), "utf8")).not.toContain("desktop-owned-value"); + }); + + test("replaces the single desktop account instead of retaining unusable sessions", async () => { + await getValidAccessTokenSnapshot("workbuddy"); + writeFileSync(authFile, authPayload("fixture-access-other", Date.now() + 3_600_000, "fixture-user-other")); + await getValidAccessTokenSnapshot("workbuddy"); + + const accountSet = getAccountSet("workbuddy"); + expect(accountSet?.accounts).toHaveLength(1); + expect(accountSet?.accounts[0]?.credential.accountId).toBe("fixture-user-other"); + }); + + test("aborted refresh does not read or mutate the desktop session", async () => { + const controller = new AbortController(); + controller.abort(); + rmSync(authFile); + + await expect(refreshWorkBuddyToken(WORKBUDDY_EXTERNAL_SESSION_REFRESH, controller.signal)).rejects.toMatchObject({ + name: "AbortError", + }); + }); + + test("honors the explicit auth-file override", () => { + expect(workBuddyAuthPath()).toBe(authFile); + }); +}); + +describe("WorkBuddy request profile", () => { + test("builds the required client headers with one request identity", () => { + const session = parseWorkBuddySession(authPayload("fixture-access")); + const headers = buildWorkBuddyRequestHeaders(session, { + conversationId: "conversation-fixture", + requestId: "request-fixture", + }); + + expect(headers).toMatchObject({ + "X-User-Id": "fixture-user-id", + "X-Domain": "www.codebuddy.cn", + "X-Product": "workbuddy-desktop", + "X-IDE-Type": "workbuddy", + "X-IDE-Name": "WorkBuddy", + "X-Conversation-ID": "conversation-fixture", + "X-Conversation-Request-ID": "request-fixture", + "X-Conversation-Message-ID": "request-fixture", + "X-Request-ID": "request-fixture", + }); + }); + + test("the generic adapter does not fabricate WorkBuddy headers without a routed provider", () => { + const provider = deriveOAuthProviderConfig("workbuddy")!; + provider.apiKey = "fixture-access-one"; + const request = createOpenAIChatAdapter(provider).buildRequest(parsedRequest()); + + expect(request.url).toBe("https://copilot.tencent.com/v2/chat/completions"); + expect(request.headers.Authorization).toBe("Bearer fixture-access-one"); + expect(request.headers["X-User-Id"]).toBeUndefined(); + }); +}); + +describe("WorkBuddy provider registry", () => { + test("exposes the OAuth preset and its static model catalog", () => { + const entry = PROVIDER_REGISTRY.find(provider => provider.id === "workbuddy"); + + expect(listOAuthProviders()).toContain("workbuddy"); + expect(entry).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://copilot.tencent.com/v2", + authKind: "oauth", + defaultModel: "auto", + liveModels: false, + parallelToolCalls: true, + models: [...WORKBUDDY_MODELS], + }); + expect(deriveOAuthProviderConfig("workbuddy")?.headers).toBeUndefined(); + }); + + test("credential conversion never imports fields outside the parsed session", () => { + const credential = credentialFromWorkBuddySession({ + accessToken: "fixture-access", + userId: "fixture-user-id", + domain: "www.codebuddy.cn", + expiresAt: Date.now() + 3_600_000, + }); + expect(Object.keys(credential).sort()).toEqual(["access", "accountId", "expires", "refresh", "source"]); + }); +}); + +describe("WorkBuddy OAuth upstream 401 replay", () => { + test("re-imports the rotated desktop token and retries once", async () => { + const provider = deriveOAuthProviderConfig("workbuddy")!; + const config: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "workbuddy", + providers: { workbuddy: provider }, + }; + saveConfig(config); + + const observed: Array<{ authorization: string | null; userId: string | null; marker: string | null }> = []; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://copilot.tencent.com/v2/chat/completions") { + const headers = new Headers(init?.headers); + observed.push({ + authorization: headers.get("authorization"), + userId: headers.get("x-user-id"), + marker: headers.get("x-opencodex-client-profile"), + }); + if (observed.length === 1) { + writeFileSync(authFile, authPayload("fixture-access-two")); + return new Response("rejected", { status: 401 }); + } + return new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", content: "ok after refresh" } }], + usage: { prompt_tokens: 4, completion_tokens: 3 }, + }), { headers: { "content-type": "application/json" } }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "workbuddy/glm-5.2", input: "hello", stream: false }), + }); + expect(response.status).toBe(200); + const body = await response.json() as { output?: Array<{ type?: string; content?: Array<{ text?: string }> }> }; + expect(body.output?.find(item => item.type === "message")?.content?.[0]?.text).toBe("ok after refresh"); + expect(observed).toEqual([ + { authorization: "Bearer fixture-access-one", userId: "fixture-user-id", marker: null }, + { authorization: "Bearer fixture-access-two", userId: "fixture-user-id", marker: null }, + ]); + } finally { + server.stop(true); + } + }); +});