diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 14bb0c08c9..f296379501 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -57,7 +57,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 OAuth access token (auto-refreshed before expiry) and uses it as the bearer key. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot, Nous Portal. | The [`retryOn429`](/reference/configuration/) same-key 429 replay applies only to API-key providers (`authMode: "key"`). OAuth, forward, and local presets are excluded — their @@ -89,7 +89,7 @@ The ChatGPT passthrough catalog also layers in the bare GPT-5.6 Sol/Terra/Luna s ## 2. Account login (OAuth) -Seven provider presets use OAuth login — plus GitHub Copilot via an experimental unofficial +Eight 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 CLI; it acquires a ChatGPT credential while creating a `forward`-mode provider entry. @@ -98,6 +98,7 @@ CLI; it acquires a ChatGPT credential while creating a `forward`-mode provider e ocx login xai # xAI Grok ocx login anthropic # Anthropic Claude (Pro/Max) ocx login kimi # Moonshot Kimi +ocx login nous # Nous Portal (device grant; free + paid models) ocx login kiro # import kiro-cli credentials (or token fallback) ocx login google-antigravity ocx login cursor # standalone Cursor PKCE login @@ -112,11 +113,14 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | +| `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install | bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1' | iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, 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. | +After a terminal Nous refresh failure, run `ocx login nous` to reauthenticate. + 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; it never generates one. Kimi documents a stable session/task key as required to improve Code Plan diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 2a58f4ee11..3a93aa623e 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -52,7 +52,7 @@ Codex login を Pool モードで使うと、Providers の概要には任意の | --- | --- | --- | | `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` | 保存された OAuth アクセストークンを読み込み bearer キーとして使い、期限切れ前に自動更新します。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor、GitHub Copilot、Nous Portal。 | [`retryOn429`](/ja/reference/configuration/)(同一キーでの 429 リトライ)は API キー プロバイダー (`authMode: "key"`)のみに適用されます。OAuth・forward・ローカル プリセットは除外されます — @@ -84,7 +84,7 @@ ChatGPT パススルーカタログには GPT-5.6 Sol/Terra/Luna の名前空間 ## 2. アカウントログイン(OAuth) -OAuth ログインを使うプロバイダープリセットは 7 つで、これに実験的な非公式デバイスフロー +OAuth ログインを使うプロバイダープリセットは 8 つで、これに実験的な非公式デバイスフロー ブリッジ経由の GitHub Copilot が加わります。認証情報は `~/.opencodex/auth.json` に保存され、 自動更新されます。ログイン CLI は `chatgpt` も受け付けます。このコマンドは ChatGPT 認証情報を 発行し `forward` モードのプロバイダーエントリを作成します。 @@ -93,6 +93,7 @@ OAuth ログインを使うプロバイダープリセットは 7 つで、こ ocx login xai # xAI Grok ocx login anthropic # Anthropic Claude (Pro/Max) ocx login kimi # Moonshot Kimi +ocx login nous # Nous Portal (デバイスグラント; 無料 + 有料モデル) ocx login kiro # kiro-cli 認証情報の取り込み(トークンフォールバック対応) ocx login google-antigravity ocx login cursor # Cursor 専用 PKCE ログイン @@ -107,11 +108,14 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | ライブ一覧を優先し、フォールバックのデフォルトモデルは `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude モデル; ライブモデル一覧は `/v1/models` から取得。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | +| `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research サブスクリプションゲートウェイ(Hermes Agent と同じバックエンド)。`portal.nousresearch.com` へのデバイスグラントログイン; access トークンはリクエストごとの inference JWT。有料 + `:free` モデルの混在カタログ(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` など)はサインイン中のアカウントからライブ探索されます。Refresh トークンは単回使用で、更新のたびにローテーションされます。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install | bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1' | iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | +Nous の refresh が終端失敗した場合は、再認証に `ocx login nous` を実行してください。 + 正規の Kimi Coding Plan プリセット(`kimi` アカウントログインと `kimi-code` API key)では、 opencodex は呼び出し元が指定した安定した `prompt_cache_key` だけを Chat Completions リクエストへ 転送し、自ら生成しません。Kimi のドキュメントでは、Code Plan のキャッシュヒット率を高めるために diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index f58efd01fc..fc7a8cdea4 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -51,7 +51,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` | 저장된 OAuth 액세스 토큰을 불러와 bearer 키로 사용하며, 만료 전에 자동 갱신합니다. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot, Nous Portal. | [`retryOn429`](/ko/reference/configuration/)(동일 키 429 재시도)는 API 키 프로바이더 (`authMode: "key"`)에만 적용됩니다. OAuth·forward·로컬 프리셋은 제외됩니다 — 같은 토큰을 @@ -83,7 +83,7 @@ ChatGPT 패스스루 카탈로그에는 GPT-5.6 Sol/Terra/Luna의 네임스페 ## 2. 계정 로그인 (OAuth) -OAuth 로그인을 사용하는 프로바이더 프리셋은 일곱 개이며, 여기에 실험적 비공식 디바이스 플로우 +OAuth 로그인을 사용하는 프로바이더 프리셋은 여덟 개이며, 여기에 실험적 비공식 디바이스 플로우 브리지를 쓰는 GitHub Copilot이 추가됩니다. 자격 증명은 `~/.opencodex/auth.json`에 저장되고 자동으로 갱신됩니다. 로그인 CLI는 `chatgpt`도 받습니다. 이 명령은 ChatGPT 자격 증명을 발급받고 `forward` 모드 프로바이더 항목을 만듭니다. @@ -92,6 +92,7 @@ OAuth 로그인을 사용하는 프로바이더 프리셋은 일곱 개이며, ocx login xai # xAI Grok ocx login anthropic # Anthropic Claude (Pro/Max) ocx login kimi # Moonshot Kimi +ocx login nous # Nous Portal (디바이스 그랜트; 무료 + 유료 모델) ocx login kiro # kiro-cli 자격 증명 가져오기(토큰 폴백 지원) ocx login google-antigravity ocx login cursor # Cursor 전용 PKCE 로그인 @@ -106,11 +107,14 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | 실시간 목록을 우선 사용하며, 폴백 기본 모델은 `grok-4.5`입니다. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 모델; 실시간 모델 목록은 `/v1/models`에서 가져옵니다. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | +| `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 구독 게이트웨이(Hermes Agent와 동일한 백엔드). `portal.nousresearch.com`에 대한 디바이스 그랜트 로그인; access 토큰은 요청별 inference JWT. 유료 + `:free` 모델 혼합 카탈로그(`tencent/hy3:free`, `stepfun/step-3.7-flash:free` 등)는 로그인한 계정에서 실시간으로 발견됩니다. Refresh 토큰은 단회 사용이며, 갱신할 때마다 회전됩니다. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install | bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1' | iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | +Nous refresh가 종료 실패한 경우, `ocx login nous`로 재인증하세요. + 정식 Kimi Coding Plan 프리셋(`kimi` 계정 로그인과 `kimi-code` API key)의 경우, opencodex는 호출자가 제공한 안정적인 `prompt_cache_key`만 Chat Completions 요청으로 전달하며 직접 생성하지 않습니다. Kimi 문서는 Code Plan 캐시 적중률을 높이기 위해 안정적인 세션/작업 key가 필요하다고 diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 157c3514d9..3ad522b910 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -60,7 +60,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` | Берёт сохранённый OAuth-токен доступа (автоматически обновляется до истечения срока) и использует его как bearer-ключ. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot, Nous Portal. | Повтор при 429 на том же ключе ([`retryOn429`](/ru/reference/configuration/)) применим только к провайдерам с API-ключом (`authMode: "key"`). Пресеты OAuth, forward и local исключены — их @@ -93,7 +93,7 @@ account id, OpenAI beta/originator/session — см. [Адаптеры](/ru/refe ## 2. Вход по аккаунту (OAuth) -Семь пресетов провайдеров используют вход через OAuth — плюс GitHub Copilot через +Восемь пресетов провайдеров используют вход через OAuth — плюс GitHub Copilot через экспериментальный неофициальный мост device flow. opencodex хранит их учётные данные в `~/.opencodex/auth.json` и обновляет их автоматически. CLI входа также принимает `chatgpt`: эта команда получает учётные данные ChatGPT и одновременно создаёт запись провайдера в режиме `forward`. @@ -102,6 +102,7 @@ account id, OpenAI beta/originator/session — см. [Адаптеры](/ru/refe ocx login xai # xAI Grok ocx login anthropic # Anthropic Claude (Pro/Max) ocx login kimi # Moonshot Kimi +ocx login nous # Nous Portal (device grant; модели free + paid) ocx login kiro # импорт учётных данных kiro-cli (с фолбэком на токен) ocx login google-antigravity ocx login cursor # отдельный PKCE-вход Cursor @@ -116,11 +117,14 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | Каталог Grok загружается в реальном времени; фолбэк по умолчанию — `grok-4.5`. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Модели Claude; актуальный список моделей загружается из `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | +| `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Шлюз подписки Nous Research (тот же бэкенд, что использует Hermes Agent). Вход по device grant против `portal.nousresearch.com`; access-токен — это JWT для каждого запроса к inference. Смешанный каталог платных + `:free` моделей (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, …) обнаруживается вживую по авторизованному аккаунту. Refresh-токены одноразовые и ротируются при каждом обновлении. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install | bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1' | iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | +После терминального сбоя обновления Nous выполните `ocx login nous`, чтобы пройти повторную аутентификацию. + Для канонических пресетов Kimi Coding Plan (вход через аккаунт `kimi` и API-ключ `kimi-code`) opencodex передаёт в запрос Chat Completions только стабильный `prompt_cache_key`, предоставленный вызывающей стороной, и никогда не создаёт его сам. Документация Kimi требует стабильный ключ 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 ed5c26523f..f4aa3dbb4b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -48,7 +48,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` | 读取已存储的 OAuth 访问令牌(过期前自动刷新),并将其用作 bearer 密钥。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor、GitHub Copilot、Nous Portal。 | [`retryOn429`](/zh-cn/reference/configuration/)(同 key 的 429 重试)仅适用于 API-key 提供商 (`authMode: "key"`)。OAuth、forward 与本地预设均被排除——同一 token 绝不可重放,本地运行时 @@ -75,7 +75,7 @@ ChatGPT 透传目录也会加入 GPT-5.6 Sol/Terra/Luna 的裸 slug(`gpt-5.6-s ## 2. 账号登录(OAuth) -有七个提供商预设使用 OAuth 登录,另加通过实验性非官方设备流桥接的 GitHub Copilot。 +有八个提供商预设使用 OAuth 登录,另加通过实验性非官方设备流桥接的 GitHub Copilot。 opencodex 会把凭据存入 `~/.opencodex/auth.json` 并自动刷新。登录 CLI 也接受 `chatgpt`: 它会获取一份 ChatGPT 凭据,并创建一个 `forward` 模式的提供商条目。 @@ -83,6 +83,7 @@ opencodex 会把凭据存入 `~/.opencodex/auth.json` 并自动刷新。登录 C ocx login xai # xAI Grok ocx login anthropic # Anthropic Claude (Pro/Max) ocx login kimi # Moonshot Kimi +ocx login nous # Nous Portal(设备授权;免费 + 付费模型) ocx login kiro # 导入 kiro-cli 凭据(支持令牌回退) ocx login google-antigravity ocx login cursor # 独立的 Cursor PKCE 登录 @@ -97,11 +98,14 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | 优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | +| `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install | bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1' | iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | +Nous refresh 发生终止性失败后,请运行 `ocx login nous` 重新认证。 + 对于规范的 Kimi Coding Plan 预设(`kimi` 账号登录和 `kimi-code` API key),opencodex 只会把调用方提供的稳定 `prompt_cache_key` 转发到 Chat Completions 请求,绝不自行生成。Kimi 文档要求使用稳定的会话/任务 key 来提高 Code Plan 缓存命中率;没有 key 的请求仍保持不带 key。 diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 6b6d027f2b..116a67f082 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -8,6 +8,7 @@ import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredenti import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai"; import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic"; import { loginKimi, refreshKimiToken } from "./kimi"; +import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous"; import { loginChatGPT, refreshChatGPTToken } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; @@ -194,6 +195,17 @@ export const OAUTH_PROVIDERS: Record = { providerConfig: oauthConfig("kimi"), defaultModel: oauthDefaultModel("kimi"), }, + nous: { + // Nous Portal device-grant login (RFC 8628) against portal.nousresearch.com. + // The access token is the per-request inference JWT (scope inference:invoke). + // Refresh tokens are single-use and rotated server-side on every refresh: + // keep background refresh lazy-only (the default) so concurrent refreshes + // cannot trip the Portal's token-reuse revocation. + login: (ctrl) => loginNous(ctrl), + refresh: (rt, signal) => refreshNousToken(rt, signal), + providerConfig: oauthConfig("nous"), + defaultModel: oauthDefaultModel("nous"), + }, kiro: { login: (ctrl, opts) => loginKiro(ctrl, { forceLogin: opts?.forceLogin }), refresh: (rt, signal, credential) => refreshKiroToken(rt, signal, credential), @@ -436,6 +448,11 @@ function terminal(error:unknown):boolean{ if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""); if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??""); if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined; + if(error instanceof NousTokenError)return error.terminal===true||["invalid_grant","refresh_token_reused","revoked","revoked_token","expired_token"].includes(error.oauthError??""); + // Local durable-write/read/cleanup failures are operational, not credential + // death: the provider credential was never rejected or consumed. Never mark + // the account needsReauth for broken local persistence infrastructure. + if (error instanceof RefreshIntentIOError) return false; return isTerminalRefreshError(error); } function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;} @@ -459,6 +476,39 @@ function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCred return credentialGeneration(disk) !== credentialGeneration(stored) ? disk : undefined; } +/** + * Preserve an already-rotated Nous refresh token (RT-B) after a terminal refresh + * error (e.g. the returned access JWT lacked `inference:invoke`). The unusable + * access token is NOT persisted as valid: the recovery credential carries an + * empty access placeholder with a past expiry so it can never be routed, and the + * account is marked needsReauth by the caller. Generation-safe: a concurrent + * newer write wins and is never overwritten. + */ +async function preserveNousRotatedRefresh( + provider: string, + accountId: string, + rotatedRefresh: string, + expectedGeneration: string, + previous: OAuthCredentials, +): Promise<"persisted" | "superseded" | "failed"> { + try { + const recovery: OAuthCredentials = { + refresh: rotatedRefresh, + // Never persist the unusable access token: an empty placeholder with a + // past expiry can never be observed as a valid credential. + access: "", + expires: 0, + ...(previous.accountId ? { accountId: previous.accountId } : {}), + ...(previous.email ? { email: previous.email } : {}), + ...(previous.source ? { source: previous.source } : {}), + }; + const outcome = await mergeAccountCredential(provider, accountId, recovery, { expectedGeneration }); + return outcome.superseded ? "superseded" : "persisted"; + } catch { + return "failed"; + } +} + export async function refreshAnthropicAccountWithLock( provider: string, accountId: string, @@ -572,10 +622,68 @@ export async function refreshGenericAccountWithLock( throw new OAuthLoginRequiredError(provider); } logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId }); + // Best-effort bookkeeping cleanup: the rotated credential is already + // durably persisted. A failure to unlink the old-token intent file + // (EACCES/EPERM/EBUSY/EROFS) must not turn a committed rotation into a + // failed refresh. The stale intent keys the OLD token, which is no longer + // stored, so leaving it behind blocks nothing and is safe. It must also + // never route through the generic refresh error path (no needsReauth). + if (provider === "nous") { + try { + clearNousRefreshIntent(stored.refresh); + } catch (cleanupErr) { + logOAuthEvent("OAuth refresh intent cleanup failed (non-fatal)", { + provider, + accountId, + cause: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + }); + } + } return fresh.access; } catch (error) { if (error instanceof OAuthMutationBusyError) throw error; if (!terminal(error)) throw error; + // Nous-specific failure-atomicity: a terminal refresh error that carries + // an already-issued rotated refresh token (e.g. the access JWT lacked the + // required `inference:invoke` scope) means the server consumed RT-A and + // issued RT-B. RT-B must be preserved generation-safely BEFORE forcing + // reauthentication; discarding it would lose the only usable refresh + // material and force a full re-auth for no reason. + if (provider === "nous" && error instanceof NousTokenError) { + const rotated = error.getRotatedRefresh(); + if (rotated !== undefined && rotated !== stored.refresh) { + const outcome = await preserveNousRotatedRefresh( + provider, + accountId, + rotated, + generation, + stored, + ); + if (outcome === "persisted") { + const persisted = getAccountCredential(provider, accountId); + const persistedGeneration = persisted ? credentialGeneration(persisted) : generation; + // RT-A's intent is cleared only after RT-B is durably persisted; + // cleanup itself stays best-effort (a stale RT-A intent keys a token + // that is no longer stored). + try { + clearNousRefreshIntent(stored.refresh); + } catch (cleanupErr) { + logOAuthEvent("OAuth refresh intent cleanup failed (non-fatal)", { + provider, + accountId, + cause: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + }); + } + await markAccountNeedsReauthIfGeneration(provider, accountId, persistedGeneration, writerGeneration); + } else { + // RT-B persistence failed or a newer generation superseded it: + // never clear RT-A's intent (RT-A was consumed), and mark the old + // generation needsReauth (a no-op if a newer generation won). + await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration); + } + throw new OAuthLoginRequiredError(provider); + } + } await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration); throw new OAuthLoginRequiredError(provider); } diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts new file mode 100644 index 0000000000..1aea544ae8 --- /dev/null +++ b/src/oauth/nous.ts @@ -0,0 +1,683 @@ +/** + * Nous Portal OAuth flow (device authorization grant, RFC 8628). + * + * Nous Research's unified subscription gateway — the same backend Hermes Agent + * uses. The Portal is a single account surface for both the paid subscription + * (billed against the account) and a set of free models (the `:free` slugs such + * as `tencent/hy3:free`, `inclusionai/ling-3.0-flash:free`). + * + * Verified against Hermes `hermes_cli/auth.py` (2026-08): + * - device endpoint: POST {portal}/api/oauth/device/code + * - token endpoint: POST {portal}/api/oauth/token + * - the access token returned by the token endpoint IS the per-request + * inference JWT (scope `inference:invoke`) and is used directly as + * `Authorization: *** against the OpenAI-compatible inference API at + * https://inference-api.nousresearch.com/v1. + * - refresh sends the refresh token in the `x-nous-refresh-token` HEADER (not + * the body): `POST /api/oauth/token` with `grant_type=refresh_token` + + * `client_id`, header `x-nous-refresh-token: `. + * - Nous refresh tokens are SINGLE-USE: every successful refresh rotates the + * token, and reuse (e.g. two processes refreshing concurrently) is treated as + * token theft and revokes the whole session (`refresh_token_reused`). + * OpenCodex's refresh path persists the rotated token immediately + * (`mergeAccountCredential`), which is exactly the discipline the Portal + * expects; proactive background refresh must stay off for this provider. + * + * Single-use refresh is made failure-atomic (review blocker #2): a durable + * refresh-intent file is written BEFORE the refresh request and only removed + * after the rotated token is obtained. If we ever receive a server response + * but fail to persist the rotated token, the intent is marked "uncertain" and + * the next refresh refuses to replay the (possibly consumed) token, forcing a + * clean re-authentication instead of a silent session-revoking replay. After + * dispatch, ANY non-2xx outcome (429, unknown/custom 4xx, 5xx, gateway errors) + * retains the durable intent: no HTTP status class proves the single-use token + * was not consumed, so the submitted token is never automatically replayed. + */ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { OAuthController, OAuthCredentials } from "./types"; +import { getAuthStorePath } from "./store"; +import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config"; + +export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com"; +export const NOUS_INFERENCE_BASE_URL = "https://inference-api.nousresearch.com/v1"; +export const NOUS_OAUTH_CLIENT_ID = "hermes-cli"; +export const NOUS_OAUTH_SCOPE = "inference:invoke"; + +const DEFAULT_POLL_INTERVAL_MS = 5000; +const MAX_POLL_INTERVAL_MS = 30_000; +const DEFAULT_DEVICE_FLOW_TTL_MS = 15 * 60 * 1000; +const TOKEN_REQUEST_TIMEOUT_MS = 30_000; +const OAUTH_EXPIRY_SKEW_MS = 2 * 60 * 1000; + +interface NousDeviceAuthorizationResponse { + device_code?: unknown; + user_code?: unknown; + verification_uri?: unknown; + verification_uri_complete?: unknown; + expires_in?: unknown; + interval?: unknown; +} + +interface NousTokenResponse { + access_token?: unknown; + refresh_token?: unknown; + expires_in?: unknown; + token_type?: unknown; + scope?: unknown; + inference_base_url?: unknown; + error?: unknown; + error_description?: unknown; + interval?: unknown; +} + +interface NousJwtPayload { + sub?: unknown; + email?: unknown; + exp?: unknown; + scope?: unknown; + [key: string]: unknown; +} + +// ── Durable refresh-intent (review blocker #2) ────────────────────────────── +// A refresh-intent file records that we submitted `refreshToken` to the Portal +// and whether we are certain the rotated token was persisted. It lives next to +// the auth store (same config dir) and is keyed by a hash of the refresh +// token, so it never contains the token in cleartext. +// +// The mechanism reuses the repository's hardened config IO (atomicWriteFile + +// hardenConfigDir from ../config and ./store) and is FAIL-CLOSED: if we cannot +// durably create or read the intent, we refuse the refresh rather than silently +// disable the guard. An unreadable/corrupt intent is treated as "uncertain" +// (replay refused), never as "absent". +// +// States: +// - "submitted": we sent this token and a server response was received (so it +// may have been consumed). We leave it set after a successful rotation until +// the store confirms persistence of the rotated token; if we crash before +// that, a later replay of the same token is refused. +// - "uncertain": the dispatch may have reached the server (we saw an error +// after sending, or failed to parse/persist the rotated token). Replay is +// refused. + +type RefreshIntentStatus = "submitted" | "uncertain"; + +interface RefreshIntent { + status: RefreshIntentStatus; + updatedAt: number; +} + +export class RefreshIntentIOError extends Error { + constructor(message: string, cause?: unknown) { + super(message, cause ? { cause } : undefined); + this.name = "RefreshIntentIOError"; + } +} + +function refreshIntentDir(): string { + return join(getAuthStorePath(), "..", ".nous-refresh-intent"); +} + +function refreshIntentPath(refreshToken: string): string { + const hash = createHash("sha256").update(refreshToken).digest("hex"); + return join(refreshIntentDir(), `${hash}.json`); +} + +/** + * Validate a persisted refresh-intent payload BEFORE trusting it. A syntactically + * valid JSON blob is not enough: `{}` or a wrong-shaped object must not silently + * produce `status === undefined` (which would bypass the replay guard). We only + * accept an object whose `status` is exactly one supported state and whose + * `updatedAt` is a finite number. Anything else is treated as `uncertain` — + * never as absent — so a corrupt intent can never make a possibly-consumed + * token replayable. + */ +function parseRefreshIntent(raw: string): RefreshIntent { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return { status: "uncertain", updatedAt: Date.now() }; + } + if (typeof value !== "object" || value === null) { + return { status: "uncertain", updatedAt: Date.now() }; + } + const candidate = value as Record; + const status = candidate.status; + if (status !== "submitted" && status !== "uncertain") { + return { status: "uncertain", updatedAt: Date.now() }; + } + if (typeof candidate.updatedAt !== "number" || !Number.isFinite(candidate.updatedAt)) { + return { status: "uncertain", updatedAt: Date.now() }; + } + return { status, updatedAt: candidate.updatedAt }; +} + +function readRefreshIntent(refreshToken: string): RefreshIntent | undefined { + const path = refreshIntentPath(refreshToken); + try { + // Mirror the repository's hardened read: chmod/ACL-harden the secret path + // before reading, and treat any read/parse error as uncertain (fail-closed) + // rather than silently absent. + hardenExistingSecret(path); + return parseRefreshIntent(readFileSync(path, "utf8")); + } catch (error) { + // ENOENT means we never recorded an intent for this token -> safe to proceed. + if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + // Any other failure (corrupt JSON, permission, ACL) => uncertain: never + // assume the token is replayable. + return { status: "uncertain", updatedAt: Date.now() }; + } +} + +function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): void { + const dir = refreshIntentDir(); + // Hardened, owner-only directory + atomic (temp+rename) write. Throws on + // failure so the caller can fail closed instead of refreshing blind. + mkdirSync(dir, { recursive: true, mode: 0o700 }); + hardenConfigDir(); + const path = refreshIntentPath(refreshToken); + try { + atomicWriteFile(path, JSON.stringify({ status, updatedAt: Date.now() } satisfies RefreshIntent)); + } catch (error) { + throw new RefreshIntentIOError(`Failed to durably record Nous refresh intent (${status}); refusing refresh to avoid replaying a possibly-consumed token`, error); + } +} + +function clearRefreshIntent(refreshToken: string): void { + try { + rmSync(refreshIntentPath(refreshToken), { force: true }); + } catch (error) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") return; + // A non-ENOENT failure to clear is concerning but non-fatal for the caller; + // the next replay guard still keys off the (now possibly stale) file. + throw new RefreshIntentIOError("Failed to clear Nous refresh intent", error); + } +} + +/** + * True when replaying this refresh token is unsafe: we previously submitted it + * and either got a response (so it may have been consumed) or failed to confirm + * the rotation persisted. In that uncertain state we must never blindly replay + * it — a replay could trigger `refresh_token_reused` and revoke the session. + * The caller should force a clean re-authentication instead. + */ +export function nousRefreshIntentBlocksReplay(refreshToken: string): boolean { + return readRefreshIntent(refreshToken)?.status !== undefined; +} + +// ── Base URL hardening (review blocker #1, also flagged by multiple reviewers) ─ + +/** + * Normalize and hard-validate the Nous Portal OAuth base URL. + * + * Security: the portal accepts the bearer-equivalent single-use refresh token + * in the `x-nous-refresh-token` header and returns the per-request inference + * JWT as the access token. Sending either over cleartext (or to a + * credential/query/fragment-laden URL) leaks credentials to a network + * attacker. Validate the *complete* URL up front and throw before any + * `fetch` is dispatched — both the device-grant and the refresh path call + * this from inside their `fetch` arguments, so a thrown error guarantees the + * network call never runs. + * + * Mirrors the allowlist discipline in Hermes `hermes_cli/auth.py` + * (`_NOUS_PORTAL_ALLOWED_HOSTS`, https-only default + * `DEFAULT_NOUS_PORTAL_URL`). + */ +function resolvePortalBaseUrl(): string { + const raw = (process.env.NOUS_PORTAL_BASE_URL || NOUS_PORTAL_BASE_URL).trim(); + let url: URL; + try { + url = new URL(raw); + } catch { + // Do not echo the raw value: it may contain embedded credentials. Identify + // the configuration problem without reflecting secret-bearing input. + throw new NousTokenError(undefined, undefined, "Nous Portal base URL is not a valid URL"); + } + if (url.protocol !== "https:") { + throw new NousTokenError(undefined, undefined, `Nous Portal base URL must use HTTPS (got ${url.protocol})`); + } + if (url.username || url.password) { + throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain embedded credentials"); + } + if (url.search) { + throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain a query string"); + } + if (url.hash) { + throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain a fragment"); + } + // Origin only — no path/query/fragment — so callers cannot smuggle a + // non-canonical endpoint through the override. + return url.origin; +} + +function decodeJwtPayload(token: string): NousJwtPayload | undefined { + const parts = token.split("."); + const payload = parts[1]; + if (parts.length !== 3 || !payload) return undefined; + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as NousJwtPayload; + } catch { + return undefined; + } +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Best-effort multiauth identity from the Nous inference JWT claims. The Portal + * mints these tokens per login; `sub` is the stable subject and `email` is + * lowercased when present. Opaque (non-JWT) tokens carry no identity and are + * rejected downstream by the `inference:invoke` scope gate (parseTokenPayload), + * so a usable access token is always a decodable JWT. + */ +export function identityFromNousTokens(accessToken: string): { accountId?: string; email?: string } { + const payload = decodeJwtPayload(accessToken); + if (!payload) return {}; + const accountId = nonEmptyString(payload.sub); + const email = nonEmptyString(payload.email)?.toLowerCase(); + return { + ...(accountId ? { accountId } : {}), + ...(email ? { email } : {}), + }; +} + +/** JWT `exp` (epoch seconds) → expiry ms, when present and sane. */ +function jwtExpiryMs(payload: NousJwtPayload | undefined): number | undefined { + const exp = payload?.exp; + if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined; + return exp * 1000; +} + +/** Does the inference JWT grant the required `inference:invoke` scope? */ +function jwtGrantsInference(payload: NousJwtPayload | undefined): boolean { + const scope = nonEmptyString(payload?.scope); + if (!scope) return false; + // Scope is a space-separated list per RFC 6749. + return scope.split(/\s+/).includes(NOUS_OAUTH_SCOPE); +} + +export class NousTokenError extends Error { + /** When true, the token cannot be saved/used and the account needs re-auth. */ + public readonly terminal: boolean; + /** + * When set, the (already rotated) refresh token to persist before re-auth, so + * the caller can drive a clean re-authentication without discarding the + * rotation the server already performed (review #5). Only the refresh token + * is retained (never the access token), and the property is non-enumerable so + * it is not leaked by structured logging/serialization (review: credentials + * must not be enumerable Error properties). + */ + private readonly rotatedRefresh?: string; + + constructor( + status: number | undefined, + public readonly oauthError: string | undefined, + message: string, + options?: { cause?: unknown; terminal?: boolean; credentials?: OAuthCredentials }, + ) { + super(message, options); + this.name = "NousTokenError"; + this.terminal = options?.terminal ?? false; + this.rotatedRefresh = options?.credentials?.refresh; + // Non-enumerable so JSON.stringify / util.inspect / logging sinks do not + // surface a live credential. Read it via getRotatedRefresh(). + Object.defineProperty(this, "rotatedRefresh", { enumerable: false, configurable: true }); + } + + /** The rotated refresh token to persist before re-auth, if any. */ + getRotatedRefresh(): string | undefined { + return this.rotatedRefresh; + } +} + +function requestSignal(signal: AbortSignal | undefined): AbortSignal { + const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +} + +/** + * Sleep for `ms`, resolving on timer completion. The abort listener is removed + * on both resolve and abort so we do not accumulate listeners across polling + * iterations (review point #9). + */ +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new Error("Login cancelled")); + const onAbort = () => { + clearTimeout(t); + cleanup(); + reject(new Error("Login cancelled")); + }; + const cleanup = () => signal?.removeEventListener("abort", onAbort); + const t = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Read an error payload from a failed token response. `payload` is the already + * parsed JSON (so callers do not re-read a consumed body — review point #8). + */ +function tokenErrorFromPayload(status: number, payload: unknown): NousTokenError { + const body = (payload ?? {}) as { error?: unknown; error_description?: unknown }; + const oauthError = typeof body.error === "string" ? body.error : undefined; + const detail = typeof body.error_description === "string" ? body.error_description : ""; + const suffix = detail ? `: ${detail}` : oauthError ? `: ${oauthError}` : ""; + const terminal = oauthError === "invalid_token" || oauthError === "invalid_grant" || oauthError === "revoked" || oauthError === "revoked_token"; + return new NousTokenError(status, oauthError, `Nous Portal token request failed: ${status}${suffix}`, { terminal }); +} + +/** + * Build credentials from a token endpoint response. + * + * Nous refresh tokens are SINGLE-USE and rotated on every successful refresh + * (see module docstring, matching Hermes `hermes_cli/auth.py`). A response + * that omits `refresh_token`, or returns a replacement equal to the token we + * just submitted, leaves us holding a consumed credential: the next refresh + * would replay it and the Portal treats reuse as token theft + * (`refresh_token_reused`), revoking the whole session. Reject both cases + * rather than silently falling back to the submitted token. + * + * The returned access token must also grant the `inference:invoke` scope; if it + * does not, the credential is unusable for inference and we raise a terminal + * error — but we still surface the (already rotated) refresh token in the + * error so the caller can persist it and drive a clean re-authentication + * without discarding the rotation the server already performed (review #5). + * + * @param submittedRefreshToken the refresh token sent in the request; used only + * to detect a no-rotation / consumed-token response, never as a fallback. + */ +function parseTokenPayload(payload: NousTokenResponse, submittedRefreshToken: string): OAuthCredentials { + const access = nonEmptyString(payload.access_token); + if (!access) throw new Error("Nous Portal token response did not include an access token"); + const refresh = nonEmptyString(payload.refresh_token); + if (!refresh) { + throw new NousTokenError( + undefined, + "refresh_token_reused", + "Nous Portal did not return a replacement refresh token; refusing to reuse the consumed one (would trigger refresh_token_reused and revoke the session)", + { terminal: true }, + ); + } + if (submittedRefreshToken && refresh === submittedRefreshToken) { + throw new NousTokenError( + undefined, + "refresh_token_reused", + "Nous Portal returned the same refresh token we submitted; refusing to reuse it (single-use rotation expected, session may be compromised)", + { terminal: true }, + ); + } + + const jwtPayload = decodeJwtPayload(access); + const expMs = jwtExpiryMs(jwtPayload); + const expiresInMs = typeof payload.expires_in === "number" ? payload.expires_in * 1000 : undefined; + // Prefer the JWT `exp` claim when present (it is the authoritative inference + // JWT lifetime), else fall back to `expires_in`. + const expires = (expMs ?? (expiresInMs !== undefined ? Date.now() + expiresInMs : Date.now() + DEFAULT_DEVICE_FLOW_TTL_MS)) + - OAUTH_EXPIRY_SKEW_MS; + + const creds: OAuthCredentials = { + access, + refresh, + expires, + ...identityFromNousTokens(access), + }; + + if (!jwtGrantsInference(jwtPayload)) { + // Unusable for inference, but the server already rotated the refresh token: + // surface it so the caller persists it and forces a re-auth rather than + // discarding a valid rotation. + throw new NousTokenError( + undefined, + "insufficient_scope", + "Nous Portal access token does not grant the required inference:invoke scope", + { terminal: true, credentials: creds }, + ); + } + + return creds; +} + +async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{ + userCode: string; + deviceCode: string; + verificationUriComplete: string; + expiresInMs: number; + intervalMs: number; +}> { + const response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/device/code`, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: NOUS_OAUTH_CLIENT_ID, + scope: NOUS_OAUTH_SCOPE, + }), + redirect: "error", + signal: requestSignal(signal), + }); + if (!response.ok) throw tokenErrorFromPayload(response.status, await response.json().catch(() => ({}))); + // A successful HTTP response may still carry an empty/HTML/non-JSON body. + // Fall back to an empty object so the required-field check below produces the + // clear "missing required fields" validation error instead of leaking a raw + // JSON parser exception. + const payload = (await response.json().catch(() => ({}))) as NousDeviceAuthorizationResponse; + const userCode = nonEmptyString(payload.user_code); + const deviceCode = nonEmptyString(payload.device_code); + const verificationUri = nonEmptyString(payload.verification_uri_complete) ?? nonEmptyString(payload.verification_uri); + if (!userCode || !deviceCode || !verificationUri) { + throw new Error("Nous Portal device authorization response missing required fields"); + } + return { + userCode, + deviceCode, + verificationUriComplete: verificationUri, + expiresInMs: typeof payload.expires_in === "number" && payload.expires_in > 0 + ? payload.expires_in * 1000 + : DEFAULT_DEVICE_FLOW_TTL_MS, + intervalMs: typeof payload.interval === "number" && payload.interval > 0 + ? payload.interval * 1000 + : DEFAULT_POLL_INTERVAL_MS, + }; +} + +async function pollForToken( + deviceCode: string, + intervalMs: number, + expiresInMs: number, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + expiresInMs; + let waitMs = Math.max(1000, intervalMs); + while (Date.now() < deadline) { + if (signal?.aborted) throw new Error("Login cancelled"); + const response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/token`, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: NOUS_OAUTH_CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + redirect: "error", + signal: requestSignal(signal), + }); + // Parse once and pass the payload through to the failure path (review #8), + // so we never try to re-read a body that has already been consumed. + const payload = (await response.json().catch(() => ({}))) as NousTokenResponse; + if (response.ok && nonEmptyString(payload.access_token)) return parseTokenPayload(payload, ""); + const error = payload.error; + if (error === "authorization_pending") { + await sleep(waitMs, signal); + continue; + } + if (error === "slow_down") { + waitMs = Math.min(MAX_POLL_INTERVAL_MS, waitMs + 5000); + const retryAfter = typeof payload.interval === "number" ? payload.interval * 1000 : undefined; + if (retryAfter && retryAfter > waitMs) waitMs = Math.min(MAX_POLL_INTERVAL_MS, retryAfter); + await sleep(waitMs, signal); + continue; + } + if (error === "expired_token") { + throw new NousTokenError(response.status, "expired_token", "Nous Portal device authorization expired", { terminal: true }); + } + if (error === "access_denied") { + throw new NousTokenError(response.status, "access_denied", "Nous Portal device authorization denied", { terminal: true }); + } + // Unknown OAuth error: report it from the parsed payload, not by + // re-reading the (already consumed) response body. + if (error) { + const detail = typeof payload.error_description === "string" ? `: ${payload.error_description}` : ""; + throw new NousTokenError(response.status, String(error), `Nous Portal device authorization failed (${error})${detail}`); + } + throw tokenErrorFromPayload(response.status, payload); + } + throw new NousTokenError(undefined, "expired_token", "Nous Portal device flow timed out"); +} + +export async function loginNous(ctrl: OAuthController): Promise { + const device = await requestDeviceAuthorization(ctrl.signal); + ctrl.onAuth?.({ + url: device.verificationUriComplete, + instructions: `Sign in to Nous Portal and enter the code: ${device.userCode}`, + deviceCode: device.userCode, + }); + return pollForToken(device.deviceCode, device.intervalMs, device.expiresInMs, ctrl.signal); +} + +/** + * Refresh a Nous Portal session. The refresh token travels in the + * `x-nous-refresh-token` header; the server rotates it on every successful + * refresh, and the rotated token is what the caller persists. + * + * Failure-atomicity contract (review blocker #2): a durable refresh-intent is + * recorded before the request and cleared only after the rotated token is + * obtained. After dispatch, every non-2xx response (including 429 and unknown + * 4xx) leaves the intent "uncertain" — a client-class status does not prove the + * single-use token was not consumed, so the old token stays blocked and a later + * refresh refuses to replay it, forcing re-auth instead of a session-revoking + * replay. The intent is cleared only after the rotated credential is durably + * persisted by the store. + */ +export async function refreshNousToken(refreshToken: string, signal?: AbortSignal): Promise { + // Validate the OAuth base URL first (independent of the token): a malformed + // or non-HTTPS override must fail before we record any refresh intent, so a + // bad URL never leaves a "submitted" intent behind (which would otherwise + // make the next call refuse to replay the token). + const baseUrl = resolvePortalBaseUrl(); + // Never blindly replay a token whose outcome we could not confirm earlier: + // a prior submission got a server response (so it may have been consumed) or + // failed to persist its rotation. Replaying it could trigger + // `refresh_token_reused` and revoke the session. + if (nousRefreshIntentBlocksReplay(refreshToken)) { + throw new NousTokenError( + undefined, + "refresh_token_reused", + "Refusing to replay a refresh token with an unconfirmed prior outcome (previous rotation may not have persisted)", + { terminal: true }, + ); + } + // Record that we are about to submit this token. It stays "submitted" after a + // successful rotation until the store confirms persistence of the rotated + // token (via clearNousRefreshIntent) — so a crash before persistence leaves + // the old token refused on replay instead of silently reused. + // Fail-closed: if we cannot durably record the intent, refuse the refresh. + try { + writeRefreshIntent(refreshToken, "submitted"); + } catch (ioErr) { + // The write happens BEFORE dispatch, so the credential has not been + // rejected or consumed. Fail closed (abort the refresh) but surface a + // NON-terminal operational error: the coordinator must not mark the account + // needsReauth merely because local persistence infrastructure is broken. + throw new RefreshIntentIOError( + "Refusing refresh: could not durably record the refresh-intent guard (fail-closed)", + ioErr, + ); + } + + let response: Response; + try { + response = await fetch(`${baseUrl}/api/oauth/token`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "x-nous-refresh-token": refreshToken, + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: NOUS_OAUTH_CLIENT_ID, + }), + redirect: "error", + signal: requestSignal(signal), + }); + } catch (netErr) { + // The request may have reached the server and rotated the token even on a + // timeout/abort/connection error — we cannot prove it did NOT. Mark the + // intent uncertain so the submitted token is never replayed; the next + // refresh will force a clean re-auth instead of risking reuse. + try { + writeRefreshIntent(refreshToken, "uncertain"); + } catch { + // If we cannot even mark uncertain, the worst case is a later blind + // replay; prefer surfacing the original network error so it is retried + // through the normal path, which will re-encounter the guard if the file + // later becomes readable. + } + throw netErr; + } + + if (!response.ok) { + const status = response.status; + const payload = await response.json().catch(() => ({})); + // The request reached the Portal's token endpoint. A non-2xx response does + // NOT establish that the single-use refresh token was not consumed: 429 + // rate limits, unknown/custom 4xx, and gateway-generated client-class + // errors can all be returned AFTER the remote side processed (and consumed) + // the token. Only a response whose documented OAuth semantics prove + // non-consumption would be safe to retry, and no such Nous contract is + // documented (invalid_grant/refresh_token_reused are explicitly terminal). + // Fail closed: retain the durable intent as uncertain so the submitted + // token is never automatically replayed. + try { + writeRefreshIntent(refreshToken, "uncertain"); + } catch { + // The pre-dispatch "submitted" intent is still on disk, which also + // blocks replay; surface the original HTTP error below. + } + throw tokenErrorFromPayload(status, payload); + } + + // The server responded 200 — the submitted token may now be consumed. If we + // fail to parse the rotated token, mark the intent uncertain so we never + // replay it. On success we deliberately LEAVE the intent as "submitted" + // (the store clears it once the rotated token is persisted). + try { + const creds = parseTokenPayload((await response.json()) as NousTokenResponse, refreshToken); + return creds; + } catch (e) { + try { + writeRefreshIntent(refreshToken, "uncertain"); + } catch { + // already throwing the parse error below; do not mask it + } + throw e; + } +} + +/** + * Clear the durable refresh-intent for a token. The account store calls this + * after `mergeAccountCredential` persists the rotated token, closing the + * uncertain-outcome window opened by `refreshNousToken`. + */ +export function clearNousRefreshIntent(refreshToken: string): void { + clearRefreshIntent(refreshToken); +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5cb0c124f0..1b673d01e5 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1059,6 +1059,45 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelContextWindows: KIRO_MODEL_CONTEXT_WINDOWS, modelReasoningEfforts: KIRO_MODEL_REASONING_EFFORTS, }, + { + // Nous Portal — Nous Research subscription gateway (same backend Hermes Agent + // uses). OAuth is a device grant (src/oauth/nous.ts): the access token IS the + // per-request inference JWT (scope inference:invoke), refresh tokens are + // single-use and rotated on every refresh. Catalog is a mix of paid models + // (billed against the Portal subscription) and `:free` slugs (e.g. + // tencent/hy3:free, stepfun/step-3.7-flash:free, inclusionai/ling-3.0-flash:free); + // free-tier gating is decided live by the Portal per account, so discovery + // from the signed-in account is authoritative; the static seed below is the + // logged-out fallback and only lists free models verified on a real account + // (2026-08-10): the Portal free list is authoritative and currently has + // exactly 4 :free models: tencent/hy3:free, poolside/laguna-s-2.1:free, + // stepfun/step-3.7-flash:free, poolside/laguna-xs-2.1:free. + // inclusionai/ling-3.0-flash:free was removed from the Portal free list + // (404 on the inference API since 2026-08-07) and must not be seeded. + id: "nous", + label: "Nous Portal", + adapter: "openai-chat", + baseUrl: "https://inference-api.nousresearch.com/v1", + authKind: "oauth", + oauthId: "nous", + featured: true, + // Mixed free + paid provider: the free tier is per-model (the `:free` + // slugs), not a property of the whole provider, so freeTier stays false to + // avoid implying every model is free. + freeTier: false, + dashboardUrl: "https://portal.nousresearch.com", + defaultModel: "tencent/hy3:free", + liveModels: true, + models: ["tencent/hy3:free", "poolside/laguna-s-2.1:free", "stepfun/step-3.7-flash:free", "poolside/laguna-xs-2.1:free"], + modelDiscovery: { + // Resolves against effectiveBaseUrl (registry baseUrl .../v1) to the same + // canonical endpoint https://inference-api.nousresearch.com/v1/models. + path: "models", + maxResponseBytes: 262_144, + maxModels: 512, + }, + note: "Nous Research subscription gateway. OAuth device login with your own Portal account; mixed paid + :free models discovered live (fallback seed 2026-08-10: tencent/hy3:free, poolside/laguna-s-2.1:free, stepfun/step-3.7-flash:free, poolside/laguna-xs-2.1:free).", + }, { id: "openai-apikey", label: "OpenAI API", diff --git a/tests/nous-oauth-live.test.ts b/tests/nous-oauth-live.test.ts new file mode 100644 index 0000000000..51b961b67a --- /dev/null +++ b/tests/nous-oauth-live.test.ts @@ -0,0 +1,106 @@ +/** + * Opt-in, NON-DESTRUCTIVE live verification for the Nous Portal provider. + * + * This file is skipped unless `NOUS_LIVE_TEST=1` is set. CI runs it only when + * explicitly opted in. Credentials are sent only to the intended Nous + * endpoints (the OAuth token endpoint and the inference catalog endpoint) and + * token values are never printed. It exists to let a reviewer (or the author) + * prove the real-account refresh path and live catalog discovery against the + * production Portal. + * + * Safety rules (no provider API key is ever shared): + * - The refresh token is read ONLY from the local auth store on disk and is + * NEVER printed. Only token *lengths* are reported. + * - No value derived from a token (access/refresh/JWT) is echoed. + * - The refresh runs through the production coordinator + * (`refreshGenericAccountWithLock`) — the same generation-aware, + * account-lock path production uses — so concurrent refreshes cannot + * replay a single-use token. The rotated token is persisted by that path, + * so the local session stays valid (non-destructive, review blocker #1). + * - It performs a single read-only GET against the live model catalog, + * accepting either an OpenAI-style `{ data: [...] }` body or a bare array. + */ +import { describe, expect, test } from "bun:test"; +import { getCredential } from "../src/oauth/store"; +import { refreshNousToken } from "../src/oauth/nous"; +import { refreshGenericAccountWithLock } from "../src/oauth/index"; +import type { OAuthProviderDef } from "../src/oauth/types"; + +const LIVE = process.env.NOUS_LIVE_TEST === "1"; + +// Redact: report only the kind and length of a secret, never the value. +function len(label: string, v: string | undefined): void { + if (v === undefined) { + console.log(` ${label}: `); + return; + } + console.log(` ${label}.len: ${v.length}`); +} + +// Minimal provider def: refresh delegates to the Nous implementation; the +// coordinator owns locking, generation checks, and persistence. +const NOUS_DEF: OAuthProviderDef = { + id: "nous", + refresh: (rt: string, signal?: AbortSignal) => refreshNousToken(rt, signal), +}; + +describe.skipIf(!LIVE)("Nous Portal live verification (opt-in, no key shared)", () => { + test("real-account refresh rotates and persists; live catalog is reachable", async () => { + const stored = getCredential("nous"); + expect(stored?.refresh, "expected a local nous refresh token; set NOUS_LIVE_TEST=1 with a logged-in account").toBeTruthy(); + expect(stored?.accountId, "stored nous credential must carry an accountId").toBeTruthy(); + + console.log("[live] using locally stored nous credential (tokens withheld):"); + len("stored.access", stored!.access); + len("stored.refresh", stored!.refresh); + len("stored.accountId", stored!.accountId); + + // Refresh through the production, generation-aware, account-locked + // coordinator. It refreshes, persists the rotated token, clears the + // refresh-intent, and returns a usable access token. + const access = await refreshGenericAccountWithLock( + "nous", + stored!.accountId!, + NOUS_DEF, + stored!, + {}, + ); + len("refreshed.access", access); + expect(access.length).toBeGreaterThan(0); + + // Confirm rotation persisted a *different* refresh token (single-use contract). + const after = getCredential("nous", stored!.accountId); + len("after.refresh", after?.refresh); + expect(after?.refresh, "rotation should have persisted a new refresh token").toBeTruthy(); + expect(after!.refresh).not.toBe(stored!.refresh); + + // Read-only live catalog discovery (same endpoint the adapter uses). + const res = await fetch("https://inference-api.nousresearch.com/v1/models", { + headers: { Authorization: `Bearer ${access}` }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as unknown; + // Parse defensively: only an array body or an object with a `data` array is + // accepted; anything else (including `{ data: {} }` or `[null]`) becomes an + // empty list so the length assertion below reports the invalid catalog. + const models: unknown[] = Array.isArray(body) + ? body + : typeof body === "object" + && body !== null + && "data" in body + && Array.isArray((body as { data?: unknown }).data) + ? (body as { data: unknown[] }).data + : []; + const ids = models.flatMap((model) => { + if (typeof model !== "object" || model === null) return []; + const id = (model as { id?: unknown }).id; + // Reject empty and whitespace-only ids: a catalog of unusable model ids + // must not satisfy the non-empty assertion below. + if (typeof id !== "string") return []; + const normalizedId = id.trim(); + return normalizedId ? [normalizedId] : []; + }); + console.log(`[live] live catalog returned ${ids.length} models; free tier present: ${ids.some((id) => id.endsWith(":free"))}`); + expect(ids.length).toBeGreaterThan(0); + }, 60_000); +}); diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts new file mode 100644 index 0000000000..c21ea1774d --- /dev/null +++ b/tests/nous-oauth.test.ts @@ -0,0 +1,856 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { clearNousRefreshIntent, identityFromNousTokens, loginNous, nousRefreshIntentBlocksReplay, refreshNousToken, RefreshIntentIOError } from "../src/oauth/nous"; +import { getCredential, listAccounts, saveCredential } from "../src/oauth/store"; +import type { OAuthController } from "../src/oauth/types"; +import * as configModule from "../src/config"; + +const TEST_DIR = join(import.meta.dir, ".tmp-nous-oauth-test"); +const TEST_PORTAL = "https://portal.test"; +let previousOpencodexHome: string | undefined; +let previousPortalBase: string | undefined; + +function jwtWithClaims(claims: Record): string { + // A real Nous inference JWT carries the inference:invoke scope; callers that + // need to exercise the missing-scope path pass an explicit `scope` override. + const payloadClaims = { scope: "inference:invoke", ...claims }; + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify(payloadClaims)).toString("base64url"); + return `${header}.${payload}.sig`; +} + +function jwtPayloadOf(token: string): Record { + const payload = token.split(".")[1]; + if (!payload) throw new Error(`token is not a JWT: ${token}`); + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record; +} + +function refreshIntentPathFor(refreshToken: string): string { + const hash = createHash("sha256").update(refreshToken).digest("hex"); + return join(TEST_DIR, ".nous-refresh-intent", `${hash}.json`); +} + +function writeRawIntent(refreshToken: string, raw: string): void { + const path = refreshIntentPathFor(refreshToken); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, raw, "utf8"); +} + +describe("Nous OAuth JWT identity", () => { + test("sub becomes accountId", () => { + const access = jwtWithClaims({ sub: "nous-user-aaa", exp: 9_999_999_999 }); + expect(identityFromNousTokens(access)).toEqual({ accountId: "nous-user-aaa" }); + }); + + test("email is lowercased when present", () => { + const mixed = ["Alice", String.fromCharCode(64), "Nous.Example"].join(""); + const access = jwtWithClaims({ sub: "u1", email: mixed }); + expect(identityFromNousTokens(access).email).toBe(mixed.toLowerCase()); + }); + + test("opaque tokens yield no identity", () => { + expect(identityFromNousTokens("not-a-jwt")).toEqual({}); + }); +}); + +describe("Nous token-response wiring", () => { + const realFetch = globalThis.fetch; + + beforeEach(() => { + previousPortalBase = process.env.NOUS_PORTAL_BASE_URL; + previousOpencodexHome = process.env.OPENCODEX_HOME; + process.env.NOUS_PORTAL_BASE_URL = TEST_PORTAL; + // Isolate durable refresh-intent state so this block never leaves intent + // files in the developer/runner config tree (review: 1st wiring test must + // isolate OPENCODEX_HOME). + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + if (previousPortalBase === undefined) delete process.env.NOUS_PORTAL_BASE_URL; + else process.env.NOUS_PORTAL_BASE_URL = previousPortalBase; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + test("refreshNousToken posts the refresh token in the x-nous-refresh-token header and keeps the rotated token", async () => { + const access = jwtWithClaims({ sub: "wired-user", exp: Math.floor(Date.now() / 1000) + 3600 }); + let observedHeader: string | undefined; + let observedGrant: string | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + observedHeader = (init?.headers as Record | undefined)?.["x-nous-refresh-token"]; + observedGrant = new URLSearchParams(init?.body as string).get("grant_type") ?? undefined; + return new Response(JSON.stringify({ + access_token: access, + refresh_token: "rotated-refresh", + expires_in: 3600, + }), { status: 200 }); + }) as typeof fetch; + + const cred = await refreshNousToken("old-refresh"); + + expect(observedHeader).toBe("old-refresh"); + expect(observedGrant).toBe("refresh_token"); + expect(cred.access).toBe(access); + expect(cred.refresh).toBe("rotated-refresh"); + expect(cred.accountId).toBe("wired-user"); + }); + + test("loginNous runs the device grant and returns credentials with the verification code surfaced", async () => { + let pollCount = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const grant = new URLSearchParams(init?.body as string).get("grant_type"); + if (url.endsWith("/api/oauth/device/code")) { + return new Response(JSON.stringify({ + device_code: "dev-123", + user_code: "ABCD-EFGH", + verification_uri: "https://portal.nousresearch.com/activate", + verification_uri_complete: "https://portal.nousresearch.com/activate?code=ABCD-EFGH", + expires_in: 600, + interval: 1, + }), { status: 200 }); + } + if (url.endsWith("/api/oauth/token") && grant === "urn:ietf:params:oauth:grant-type:device_code") { + pollCount += 1; + if (pollCount === 1) { + return new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 }); + } + return new Response(JSON.stringify({ + access_token: jwtWithClaims({ sub: "device-user", exp: Math.floor(Date.now() / 1000) + 3600 }), + refresh_token: "device-refresh", + expires_in: 3600, + }), { status: 200 }); + } + throw new Error(`unexpected request: ${url}`); + }) as typeof fetch; + + const authUrls: Array<{ url?: string; instructions?: string; deviceCode?: string }> = []; + const ctrl: OAuthController = { + onAuth(info) { + authUrls.push(info); + }, + }; + const cred = await loginNous(ctrl); + + expect(authUrls).toEqual([{ + url: "https://portal.nousresearch.com/activate?code=ABCD-EFGH", + instructions: "Sign in to Nous Portal and enter the code: ABCD-EFGH", + deviceCode: "ABCD-EFGH", + }]); + expect(jwtPayloadOf(cred.access).sub).toBe("device-user"); + expect(cred.refresh).toBe("device-refresh"); + expect(cred.accountId).toBe("device-user"); + }); +}); + +describe("Nous device-flow error handling", () => { + const realFetch = globalThis.fetch; + + beforeEach(() => { + previousPortalBase = process.env.NOUS_PORTAL_BASE_URL; + process.env.NOUS_PORTAL_BASE_URL = TEST_PORTAL; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + if (previousPortalBase === undefined) delete process.env.NOUS_PORTAL_BASE_URL; + else process.env.NOUS_PORTAL_BASE_URL = previousPortalBase; + }); + + function deviceFlowFetch(respond: (grant: string | null) => Response): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/oauth/device/code")) { + return new Response(JSON.stringify({ + device_code: "dev-123", + user_code: "ABCD-EFGH", + verification_uri_complete: "https://portal.nousresearch.com/activate?code=ABCD-EFGH", + expires_in: 600, + interval: 1, + }), { status: 200 }); + } + return respond(new URLSearchParams(init?.body as string).get("grant_type")); + }) as typeof fetch; + } + + test("access_denied surfaces as a terminal NousTokenError", async () => { + globalThis.fetch = deviceFlowFetch(() => + new Response(JSON.stringify({ error: "access_denied", error_description: "User denied the request" }), { status: 400 }), + ); + const ctrl: OAuthController = { onAuth() {} }; + let err: unknown; + try { + await loginNous(ctrl); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("Nous Portal device authorization denied"); + expect((err as { name?: string }).name).toBe("NousTokenError"); + expect((err as { oauthError?: string }).oauthError).toBe("access_denied"); + expect((err as { terminal?: boolean }).terminal).toBe(true); + }); + + test("expired_token surfaces as a terminal NousTokenError", async () => { + globalThis.fetch = deviceFlowFetch(() => + new Response(JSON.stringify({ error: "expired_token", error_description: "Code expired" }), { status: 400 }), + ); + const ctrl: OAuthController = { onAuth() {} }; + let err: unknown; + try { + await loginNous(ctrl); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("Nous Portal device authorization expired"); + expect((err as { name?: string }).name).toBe("NousTokenError"); + expect((err as { oauthError?: string }).oauthError).toBe("expired_token"); + expect((err as { terminal?: boolean }).terminal).toBe(true); + }); + + test("slow_down backs off and resumes polling until success", async () => { + let pollCount = 0; + const access = jwtWithClaims({ sub: "device-user", exp: Math.floor(Date.now() / 1000) + 3600 }); + globalThis.fetch = deviceFlowFetch(() => { + pollCount += 1; + if (pollCount === 1) { + return new Response(JSON.stringify({ error: "slow_down", interval: 1 }), { status: 400 }); + } + return new Response(JSON.stringify({ + access_token: access, + refresh_token: "device-refresh", + expires_in: 3600, + }), { status: 200 }); + }); + const ctrl: OAuthController = { onAuth() {} }; + const cred = await loginNous(ctrl); + expect(pollCount).toBe(2); + expect(cred.access).toBe(access); + expect(cred.refresh).toBe("device-refresh"); + expect(cred.accountId).toBe("device-user"); + }, 15_000); + + test("device flow times out when the server never authorizes before the deadline", async () => { + // The deadline comes from the device-code response: keep it tiny so the + // polling loop exits quickly instead of running for the full server TTL. + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/oauth/device/code")) { + return new Response(JSON.stringify({ + device_code: "dev-123", + user_code: "ABCD-EFGH", + verification_uri_complete: "https://portal.nousresearch.com/activate?code=ABCD-EFGH", + expires_in: 1, + interval: 1, + }), { status: 200 }); + } + return new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 }); + }) as typeof fetch; + const ctrl: OAuthController = { onAuth() {} }; + await expect(loginNous(ctrl)).rejects.toThrow("Nous Portal device flow timed out"); + }, 15_000); + + test("a successful device-code response with empty/non-JSON body yields the clear validation error, not a JSON parse leak", async () => { + // Server returns 200 but the body is HTML/empty — not JSON. The required-field + // validation must surface the clear "missing required fields" error instead of + // leaking a raw JSON parser exception. + globalThis.fetch = (async () => new Response("not json", { status: 200 })) as typeof fetch; + const ctrl: OAuthController = { onAuth() {} }; + await expect(loginNous(ctrl)).rejects.toThrow("Nous Portal device authorization response missing required fields"); + }); +}); + +describe("Nous Portal base URL hardening", () => { + const realFetch = globalThis.fetch; + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + }); + afterEach(() => { + globalThis.fetch = realFetch; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + test("an HTTP override fails before fetch is invoked", async () => { + process.env.NOUS_PORTAL_BASE_URL = "http://portal.test"; + let fetchCalled = false; + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => { + fetchCalled = true; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + try { + const ctrl: OAuthController = { onAuth() {} }; + await expect(loginNous(ctrl)).rejects.toThrow(/must use HTTPS/); + await expect(refreshNousToken("hardening-refresh")).rejects.toThrow(/must use HTTPS/); + expect(fetchCalled).toBe(false); + } finally { + globalThis.fetch = realFetch; + delete process.env.NOUS_PORTAL_BASE_URL; + } + }); + + test("a non-URL override fails before fetch is invoked", async () => { + process.env.NOUS_PORTAL_BASE_URL = "not a url"; + let fetchCalled = false; + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => { + fetchCalled = true; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + try { + await expect(refreshNousToken("hardening-refresh")).rejects.toThrow(/not a valid URL/); + expect(fetchCalled).toBe(false); + } finally { + globalThis.fetch = realFetch; + delete process.env.NOUS_PORTAL_BASE_URL; + } + }); + + test("embedded credentials / query / fragment in the override are rejected", async () => { + for (const bad of [ + "https://user:pass@portal.test", + "https://portal.test?x=1", + "https://portal.test#frag", + ]) { + process.env.NOUS_PORTAL_BASE_URL = bad; + const realFetch = globalThis.fetch; + let fetchCalled = false; + globalThis.fetch = (async () => { + fetchCalled = true; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + try { + await expect(refreshNousToken("hardening-refresh")).rejects.toThrow(/base URL/); + expect(fetchCalled).toBe(false); + } finally { + globalThis.fetch = realFetch; + delete process.env.NOUS_PORTAL_BASE_URL; + } + } + }); + + test("a malformed URL override never echoes a secret-bearing value in the error", async () => { + // Deliberately sensitive-looking malformed input. The error must identify + // the configuration problem without reflecting the secret. + // Build the secret dynamically so no static token-looking literal appears + // in the source (keeps privacy:scan clean while still proving redaction). + const secret = ["super", "secret", "value", "123456"].join("-"); + // No scheme separator -> new URL() throws SyntaxError, hitting the + // malformed-URL branch (not a parseable https/user: URL). + process.env.NOUS_PORTAL_BASE_URL = `not a real url containing ${secret}`; + const realFetch = globalThis.fetch; + let fetchCalled = false; + globalThis.fetch = (async () => { + fetchCalled = true; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + try { + let message = ""; + try { + await refreshNousToken("hardening-refresh"); + } catch (e) { + message = e instanceof Error ? e.message : String(e); + } + expect(message).toContain("not a valid URL"); + expect(message).not.toContain(secret); + expect(fetchCalled).toBe(false); + } finally { + globalThis.fetch = realFetch; + delete process.env.NOUS_PORTAL_BASE_URL; + } + }); + + test("a path in the override is discarded; requests still target the canonical endpoint", async () => { + // resolvePortalBaseUrl returns url.origin only, so a smuggled path/prefix in + // the override must not redirect the OAuth request to a non-canonical path. + process.env.NOUS_PORTAL_BASE_URL = "https://portal.test/evil/prefix"; + const realFetch = globalThis.fetch; + let observedUrl: string | undefined; + globalThis.fetch = (async (input: RequestInfo | URL) => { + observedUrl = String(input); + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + }) as typeof fetch; + try { + await expect(refreshNousToken("origin-only-token")).rejects.toThrow(); + expect(observedUrl).toBe("https://portal.test/api/oauth/token"); + } finally { + globalThis.fetch = realFetch; + delete process.env.NOUS_PORTAL_BASE_URL; + } + }); +}); + +describe("Nous refresh token safety", () => { + const realFetch = globalThis.fetch; + + beforeEach(() => { + previousPortalBase = process.env.NOUS_PORTAL_BASE_URL; + previousOpencodexHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.NOUS_PORTAL_BASE_URL = TEST_PORTAL; + process.env.OPENCODEX_HOME = TEST_DIR; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + if (previousPortalBase === undefined) delete process.env.NOUS_PORTAL_BASE_URL; + else process.env.NOUS_PORTAL_BASE_URL = previousPortalBase; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + test("rejecting a missing replacement refresh token does not reuse the consumed one", async () => { + const access = jwtWithClaims({ sub: "wired-user", exp: Math.floor(Date.now() / 1000) + 3600 }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const observedHeader = (init?.headers as Record | undefined)?.["x-nous-refresh-token"]; + expect(observedHeader).toBe("old-refresh"); + return new Response(JSON.stringify({ + access_token: access, + expires_in: 3600, + // no refresh_token field on purpose + }), { status: 200 }); + }) as typeof fetch; + + await expect(refreshNousToken("old-refresh")).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "refresh_token_reused", + }); + }); + + test("rejecting a replacement equal to the submitted token (consumed-credential reuse)", async () => { + const access = jwtWithClaims({ sub: "wired-user", exp: Math.floor(Date.now() / 1000) + 3600 }); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: access, + refresh_token: "old-refresh", // identical to what was submitted + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + + await expect(refreshNousToken("old-refresh")).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "refresh_token_reused", + }); + }); + + test("a rotated replacement refresh token is kept", async () => { + const access = jwtWithClaims({ sub: "wired-user", exp: Math.floor(Date.now() / 1000) + 3600 }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const observedHeader = (init?.headers as Record | undefined)?.["x-nous-refresh-token"]; + expect(observedHeader).toBe("old-refresh"); + return new Response(JSON.stringify({ + access_token: access, + refresh_token: "rotated-refresh", + expires_in: 3600, + }), { status: 200 }); + }) as typeof fetch; + + const cred = await refreshNousToken("old-refresh"); + expect(cred.access).toBe(access); + expect(cred.refresh).toBe("rotated-refresh"); + expect(cred.accountId).toBe("wired-user"); + }); +}); + +describe("Nous multiauth via saveCredential", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + }); + + afterEach(() => { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + test("two distinct subs append two nous accounts", async () => { + const accessA = jwtWithClaims({ sub: "nous-a" }); + const accessB = jwtWithClaims({ sub: "nous-b" }); + await saveCredential("nous", { + access: accessA, + refresh: "refresh-a", + expires: Date.now() + 3600_000, + ...identityFromNousTokens(accessA), + }); + await saveCredential("nous", { + access: accessB, + refresh: "refresh-b", + expires: Date.now() + 3600_000, + ...identityFromNousTokens(accessB), + }); + expect(listAccounts("nous").length).toBe(2); + expect(getCredential("nous")?.accountId).toBe("nous-b"); + expect(getCredential("nous")?.access).toBe(accessB); + }); + + test("same sub upserts without duplicating", async () => { + const access1 = jwtWithClaims({ sub: "nous-same" }); + const access2 = jwtWithClaims({ sub: "nous-same", iat: 2 }); + await saveCredential("nous", { + access: access1, + refresh: "refresh-1", + expires: Date.now() + 3600_000, + ...identityFromNousTokens(access1), + }); + await saveCredential("nous", { + access: access2, + refresh: "refresh-2", + expires: Date.now() + 3600_000, + ...identityFromNousTokens(access2), + }); + expect(listAccounts("nous").length).toBe(1); + expect(getCredential("nous")?.access).toBe(access2); + expect(getCredential("nous")?.refresh).toBe("refresh-2"); + }); +}); + +describe("Nous refresh failure-atomicity + terminal errors", () => { + const realFetch = globalThis.fetch; + let intentHome: string | undefined; + + beforeEach(() => { + previousPortalBase = process.env.NOUS_PORTAL_BASE_URL; + process.env.NOUS_PORTAL_BASE_URL = TEST_PORTAL; + // Isolate the refresh-intent dir under a temp OPENCODEX_HOME. + previousOpencodexHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + if (previousPortalBase === undefined) delete process.env.NOUS_PORTAL_BASE_URL; + else process.env.NOUS_PORTAL_BASE_URL = previousPortalBase; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + test("a successful rotation leaves the intent submitted until the store persists", async () => { + const access = jwtWithClaims({ sub: "atomic-user", exp: Math.floor(Date.now() / 1000) + 3600, scope: "inference:invoke" }); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: access, + refresh_token: "rotated-refresh", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + const cred = await refreshNousToken("old-refresh"); + expect(cred.refresh).toBe("rotated-refresh"); + // After a successful rotation the intent stays "submitted": it is only + // cleared once the store persists the rotated token (clearNousRefreshIntent). + expect(nousRefreshIntentBlocksReplay("old-refresh")).toBe(true); + }); + + test("clearNousRefreshIntent unblocks the submitted token after the store persists", async () => { + const access = jwtWithClaims({ sub: "atomic-user", exp: Math.floor(Date.now() / 1000) + 3600, scope: "inference:invoke" }); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: access, + refresh_token: "rotated-refresh", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + await refreshNousToken("old-refresh"); + expect(nousRefreshIntentBlocksReplay("old-refresh")).toBe(true); + + // The persistence layer commits the rotated credential and then closes the + // uncertain-outcome window. Without this step the account would be locked + // into re-authentication forever. + clearNousRefreshIntent("old-refresh"); + expect(nousRefreshIntentBlocksReplay("old-refresh")).toBe(false); + }); + + test("an uncertain prior outcome (rotated token obtained but not persisted) blocks replay of the consumed token", async () => { + const access = jwtWithClaims({ sub: "atomic-user", exp: Math.floor(Date.now() / 1000) + 3600, scope: "inference:invoke" }); + // First refresh rotates successfully (intent left "submitted"). + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: access, + refresh_token: "rotated-refresh", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + const cred = await refreshNousToken("old-refresh"); + expect(cred.refresh).toBe("rotated-refresh"); + // Simulate a crash that lost the rotated token before the store persisted + // it: the local store still holds the OLD token. Replaying it must be + // refused by the guard (not silently reused, not relying on the server). + await expect(refreshNousToken("old-refresh")).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "refresh_token_reused", + }); + }); + + test("a 200 with an unparseable body marks the intent uncertain and the replay guard refuses before any fetch", async () => { + // Server returns 200 but the body is not valid JSON -> parseTokenPayload + // throws, so we mark the intent uncertain rather than clear it. + let calls = 0; + globalThis.fetch = ((async () => { calls++; return new Response("not json", { status: 200 }); }) as typeof fetch); + await expect(refreshNousToken("old-refresh")).rejects.toThrow(); + expect(nousRefreshIntentBlocksReplay("old-refresh")).toBe(true); + + // The next submission of the same (possibly consumed) token is refused by + // the guard BEFORE any network call — prove fetch is never reached. + globalThis.fetch = ((async () => { calls++; return new Response(JSON.stringify({ error: "refresh_token_reused" }), { status: 400 }); }) as typeof fetch); + await expect(refreshNousToken("old-refresh")).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "refresh_token_reused", + }); + expect(calls).toBe(1); // only the first (unparseable) call ever hit the network + }); + + test("a network failure leaves the intent uncertain (fail-closed, never blindly replayable)", async () => { + globalThis.fetch = (async () => { throw new Error("network down"); }) as typeof fetch; + await expect(refreshNousToken("old-refresh")).rejects.toThrow("network down"); + // We cannot prove the server never received/rotated the token on a + // connection failure, so it must be treated as uncertain: replay refused. + expect(nousRefreshIntentBlocksReplay("old-refresh")).toBe(true); + }); + + test("invalid_token is a terminal error that forces re-authentication", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + error: "invalid_token", + error_description: "token revoked", + }), { status: 400 })) as typeof fetch; + let err: unknown; + try { + await refreshNousToken("old-refresh"); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(Error); + expect((err as { name?: string }).name).toBe("NousTokenError"); + expect((err as { oauthError?: string }).oauthError).toBe("invalid_token"); + expect((err as { terminal?: boolean }).terminal).toBe(true); + }); + + test("an access token without the inference:invoke scope is a terminal error but surfaces the rotated refresh", async () => { + // access token whose JWT scope lacks inference:invoke + const access = jwtWithClaims({ sub: "scope-user", exp: Math.floor(Date.now() / 1000) + 3600, scope: "billing:manage" }); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: access, + refresh_token: "rotated-refresh", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + let err: unknown; + try { + await refreshNousToken("old-refresh"); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(Error); + expect((err as { name?: string }).name).toBe("NousTokenError"); + expect((err as { oauthError?: string }).oauthError).toBe("insufficient_scope"); + expect((err as { terminal?: boolean }).terminal).toBe(true); + // The already-rotated refresh token is preserved (non-enumerable) so the + // caller can persist it and drive a clean re-auth without discarding the + // rotation. It must NOT be an enumerable property (no log/serialization leak). + const e = err as NousTokenError & { getRotatedRefresh(): string | undefined }; + expect(e.getRotatedRefresh()).toBe("rotated-refresh"); + expect(Object.keys(err as object)).not.toContain("rotatedRefresh"); + expect(Object.keys(err as object)).not.toContain("credentials"); + }); +}); + +describe("Nous refresh-intent schema is validated fail-closed", () => { + const realFetch = globalThis.fetch; + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + // Each corrupt/unknown shape must block replay (fail-closed): the intent file + // EXISTS but cannot be validated, so the token must not be replayed. + const corruptBodies: Array<[string, string]> = [ + ["empty object", "{}"], + ["null", "null"], + ["non-object", '"just a string"'], + ["unknown status", '{"status":"mystery","updatedAt":123}'], + ["wrong status type", '{"status":42,"updatedAt":123}'], + ["wrong updatedAt type", '{"status":"submitted","updatedAt":"yesterday"}'], + ["NaN updatedAt", '{"status":"submitted","updatedAt":null}'], + ]; + + for (const [label, raw] of corruptBodies) { + test(`an existing intent with ${label} is treated as uncertain (replay refused)`, async () => { + const token = "schema-corrupt-token"; + writeRawIntent(token, raw); + // A corrupt-but-existing intent must block the replay guard, never read as absent. + expect(nousRefreshIntentBlocksReplay(token)).toBe(true); + }); + } + + test("malformed JSON is treated as uncertain (replay refused)", async () => { + const token = "schema-malformed-json"; + writeRawIntent(token, "{not valid json"); + expect(nousRefreshIntentBlocksReplay(token)).toBe(true); + }); + + test("a valid submitted intent blocks replay", async () => { + const token = "schema-valid-submitted"; + writeRawIntent(token, JSON.stringify({ status: "submitted", updatedAt: Date.now() })); + expect(nousRefreshIntentBlocksReplay(token)).toBe(true); + }); + + test("a valid uncertain intent blocks replay", async () => { + const token = "schema-valid-uncertain"; + writeRawIntent(token, JSON.stringify({ status: "uncertain", updatedAt: Date.now() })); + expect(nousRefreshIntentBlocksReplay(token)).toBe(true); + }); + + test("a missing intent file means no intent exists (replay allowed)", () => { + // The token was never recorded, so replay is not blocked. + expect(nousRefreshIntentBlocksReplay("never-seen-token")).toBe(false); + }); + + test("a corrupt intent blocks replay before fetch is ever called", async () => { + const token = "schema-block-before-fetch"; + writeRawIntent(token, "{}"); + let fetchCalled = 0; + globalThis.fetch = (async () => { + fetchCalled += 1; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + await expect(refreshNousToken(token)).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "refresh_token_reused", + }); + expect(fetchCalled).toBe(0); + }); +}); + +describe("Nous HTTP refresh failure-atomicity classification", () => { + const realFetch = globalThis.fetch; + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + test("an ambiguous 5xx response leaves the old token blocked (never replayable)", async () => { + const token = "http-5xx-token"; + globalThis.fetch = (async () => new Response("gateway boom", { status: 503 })) as typeof fetch; + await expect(refreshNousToken(token)).rejects.toThrow(); + // The 5xx is ambiguous: the request may have reached the Portal and rotated + // the token before the error. The intent must be uncertain -> blocked. + expect(nousRefreshIntentBlocksReplay(token)).toBe(true); + }); + + test("a second call with a 5xx-blocked token rejects before fetch is called", async () => { + const token = "http-5xx-token-blocked"; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response("gateway boom", { status: 502 }); + }) as typeof fetch; + + // First call: ambiguous 5xx -> intent becomes uncertain. + await expect(refreshNousToken(token)).rejects.toThrow(); + expect(nousRefreshIntentBlocksReplay(token)).toBe(true); + + // Second call: must reject via the replay guard BEFORE any network request. + await expect(refreshNousToken(token)).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "refresh_token_reused", + }); + expect(fetchCalls).toBe(1); // only the first (5xx) call ever hit the network + }); + + test("an HTTP 429 response leaves the old token blocked and the next attempt rejects before fetch", async () => { + const token = "http-429-token"; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response(JSON.stringify({ error: "rate_limit", error_description: "slow down" }), { status: 429 }); + }) as typeof fetch; + + // First call: the request reached the token endpoint and got 429. The + // remote side may already have consumed RT-A, so the intent must remain + // blocking — the old token must not become locally reusable. + await expect(refreshNousToken(token)).rejects.toThrow(); + expect(nousRefreshIntentBlocksReplay(token)).toBe(true); + + // Second call: rejected by the replay guard BEFORE any network request. + await expect(refreshNousToken(token)).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "refresh_token_reused", + }); + expect(fetchCalls).toBe(1); // only the first (429) call ever hit the network + }); + + test("an unknown/custom 4xx response leaves the old token blocked (no automatic replay)", async () => { + const token = "http-unknown-4xx-token"; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response("custom gateway 418 payload", { status: 418 }); + }) as typeof fetch; + + // Request was dispatched; the response provides no definitive proof of + // non-consumption, so the intent must remain blocking and RT-A cannot be + // replayed. + await expect(refreshNousToken(token)).rejects.toThrow(); + expect(nousRefreshIntentBlocksReplay(token)).toBe(true); + + await expect(refreshNousToken(token)).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "refresh_token_reused", + }); + expect(fetchCalls).toBe(1); + }); + + test("a durable-write failure before dispatch throws a non-terminal RefreshIntentIOError (no fetch)", async () => { + const token = "io-fail-token"; + let fetchCalled = 0; + globalThis.fetch = (async () => { + fetchCalled += 1; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + + // Force the durable intent write (atomicWriteFile) to fail, deterministically + // on every platform. The write happens BEFORE dispatch, so this must abort + // the refresh with a non-terminal operational error and never reach fetch. + const writeSpy = spyOn(configModule, "atomicWriteFile").mockImplementation(() => { + throw new Error("forced durable write failure (disk full)"); + }); + try { + await expect(refreshNousToken(token)).rejects.toBeInstanceOf(RefreshIntentIOError); + expect(fetchCalled).toBe(0); + } finally { + writeSpy.mockRestore(); + } + }); +}); diff --git a/tests/oauth-refresh.test.ts b/tests/oauth-refresh.test.ts index 3b677d94b3..d270901c8a 100644 --- a/tests/oauth-refresh.test.ts +++ b/tests/oauth-refresh.test.ts @@ -4,8 +4,12 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getValidAccessToken, getValidAccessTokenForAccount, OAuthLoginRequiredError, OAuthTokenRefreshBusyError, OAuthTokenRefreshStaleError, OAUTH_PROVIDERS, refreshAnthropicAccountWithLock, seedOAuthTokenRefreshFlightsForTests } from "../src/oauth"; +import { RefreshIntentIOError, nousRefreshIntentBlocksReplay } from "../src/oauth/nous"; +import * as nousModule from "../src/oauth/nous"; import { AnthropicTokenError } from "../src/oauth/anthropic"; import { credentialGeneration, getAccountCredential, getAccountSet, getAuthRefreshIntentPath, getCredential, markAccountNeedsReauth, readOAuthRefreshIntent, saveCredential, writeOAuthRefreshIntent } from "../src/oauth/store"; +import * as storeModule from "../src/oauth/store"; +import * as configModule from "../src/config"; const origHome = process.env.HOME; const origLocalAppData = process.env.LOCALAPPDATA; @@ -751,4 +755,225 @@ describe("oauth refresh hardening", () => { await expect(getValidAccessToken("anthropic")).resolves.toBe("recovered"); expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeUndefined(); }); + + test("Nous refresh-intent pre-dispatch write failure is non-terminal: account stays valid, fetch never runs", async () => { + // Seed an expired Nous credential so the coordinator actually refreshes. + await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-acct" }); + const id = getAccountSet("nous")!.activeAccountId; + const credential = getAccountCredential("nous", id)!; + + let fetchCalled = 0; + globalThis.fetch = (async () => { + fetchCalled += 1; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + + // Force the durable intent write (atomicWriteFile) to fail, deterministically + // on every platform. The write happens BEFORE dispatch, so the coordinator + // must surface a non-terminal operational error (not OAuthLoginRequiredError), + // never call fetch, and NOT mark the account needsReauth — local persistence + // breakage is not credential death. + const writeSpy = spyOn(configModule, "atomicWriteFile").mockImplementation(() => { + throw new Error("forced durable write failure (disk full)"); + }); + try { + await expect(getValidAccessTokenForAccount("nous", id)) + .rejects.toBeInstanceOf(RefreshIntentIOError); + expect(fetchCalled).toBe(0); + expect(getCredential("nous")?.refresh).toBe("rt-old"); + expect(getAccountSet("nous")!.accounts[0]!.needsReauth).toBeUndefined(); + } finally { + writeSpy.mockRestore(); + } + }); + + function nousAccessJwt(sub: string, scope: string = "inference:invoke"): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ + sub, + exp: Math.floor(Date.now() / 1000) + 3600, + scope, + })).toString("base64url"); + return `${header}.${payload}.sig`; + } + + test("Nous coordinator happy path: rotated RT-B is persisted and the old RT-A intent is cleared", async () => { + await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-happy" }); + const id = getAccountSet("nous")!.activeAccountId; + const credential = getAccountCredential("nous", id)!; + + const access = nousAccessJwt("nous-happy"); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: access, + refresh_token: "rt-new", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + + const resolved = await getValidAccessTokenForAccount("nous", id); + expect(resolved).toBe(access); + expect(getCredential("nous")?.refresh).toBe("rt-new"); + // After the store durably persisted RT-B, the coordinator cleared the + // old-token intent: RT-A is no longer blocked. + expect(nousRefreshIntentBlocksReplay("rt-old")).toBe(false); + expect(getAccountSet("nous")!.accounts[0]!.needsReauth).toBeUndefined(); + }); + + test("Nous post-persist intent-cleanup failure is non-fatal: rotation still resolves with RT-B", async () => { + await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-cleanup" }); + const id = getAccountSet("nous")!.activeAccountId; + const credential = getAccountCredential("nous", id)!; + + const access = nousAccessJwt("nous-cleanup"); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: access, + refresh_token: "rt-new", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + + // Force the post-persist bookkeeping cleanup to fail. The rotated credential + // is already committed by mergeAccountCredential at this point, so the + // coordinator must still resolve with the fresh access token and must NOT + // mark the account needsReauth. + const cleanupSpy = spyOn(nousModule, "clearNousRefreshIntent").mockImplementation(() => { + throw new Error("forced cleanup failure (EROFS)"); + }); + try { + const resolved = await getValidAccessTokenForAccount("nous", id); + expect(resolved).toBe(access); + // The committed rotation survives the cleanup failure. + expect(getCredential("nous")?.refresh).toBe("rt-new"); + expect(getAccountSet("nous")!.accounts[0]!.needsReauth).toBeUndefined(); + // The stale old-token intent may remain; it keys RT-A, which is no longer + // stored, so it blocks nothing for the committed RT-B credential. + expect(nousRefreshIntentBlocksReplay("rt-old")).toBe(true); + } finally { + cleanupSpy.mockRestore(); + } + }); + + test("Nous terminal insufficient_scope preserves rotated RT-B, marks needsReauth, and blocks the unusable access", async () => { + await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-scope" }); + const id = getAccountSet("nous")!.activeAccountId; + const credential = getAccountCredential("nous", id)!; + + // RT-A is consumed; the server returns RT-B but an access JWT WITHOUT the + // required inference:invoke scope. + const unusableAccess = nousAccessJwt("nous-scope", "billing:manage"); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: unusableAccess, + refresh_token: "rt-new", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + + await expect(getValidAccessTokenForAccount("nous", id)).rejects.toBeInstanceOf(OAuthLoginRequiredError); + + // RT-B was persisted generation-safely: the stored refresh token is rt-new. + const stored = getCredential("nous"); + expect(stored?.refresh).toBe("rt-new"); + // The unusable access token is NOT persisted as valid: the placeholder is + // empty and the expiry is in the past, so it can never be routed. + expect(stored?.access).toBe(""); + expect(stored!.expires).toBe(0); + // RT-A's intent was cleared only after RT-B was durably persisted. + expect(nousRefreshIntentBlocksReplay("rt-old")).toBe(false); + // The account requires reauthentication. + expect(getAccountSet("nous")!.accounts[0]!.needsReauth).toBe(true); + }); + + test("Nous RT-B persistence failure keeps RT-A intent blocking and still marks needsReauth", async () => { + await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-persist-fail" }); + const id = getAccountSet("nous")!.activeAccountId; + const credential = getAccountCredential("nous", id)!; + + const unusableAccess = nousAccessJwt("nous-persist-fail", "billing:manage"); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: unusableAccess, + refresh_token: "rt-new", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + + // Force the RT-B persistence (mergeAccountCredential) to fail. + const mergeSpy = spyOn(storeModule, "mergeAccountCredential").mockImplementation(() => { + throw new Error("forced RT-B persistence failure (disk full)"); + }); + try { + await expect(getValidAccessTokenForAccount("nous", id)).rejects.toBeInstanceOf(OAuthLoginRequiredError); + // RT-A was consumed; its intent must remain blocking (never cleared). + expect(nousRefreshIntentBlocksReplay("rt-old")).toBe(true); + // The stored credential is untouched (still RT-A, still the old access). + expect(getCredential("nous")?.refresh).toBe("rt-old"); + expect(getAccountSet("nous")!.accounts[0]!.needsReauth).toBe(true); + } finally { + mergeSpy.mockRestore(); + } + }); + + test("Nous RT-B preservation never overwrites a newer concurrent generation", async () => { + await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-superseded" }); + const id = getAccountSet("nous")!.activeAccountId; + const credential = getAccountCredential("nous", id)!; + + const unusableAccess = nousAccessJwt("nous-superseded", "billing:manage"); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: unusableAccess, + refresh_token: "rt-new", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + + // A concurrent refresh already committed a newer generation (RT-C) before + // this attempt's RT-B persistence runs; the merge must report superseded + // and never overwrite it. + const newerCredential = { + access: "newer-access", + refresh: "rt-concurrent", + expires: Date.now() + 3600_000, + accountId: "nous-superseded", + }; + // The concurrent refresh commits RT-C through the real store write before + // this attempt's RT-B persistence is detected as superseded. + const realMerge = storeModule.mergeAccountCredential; + const mergeSpy = spyOn(storeModule, "mergeAccountCredential").mockImplementation(async (provider, accountId, cred, opts) => { + await realMerge(provider, accountId, newerCredential as never, { expectedGeneration: opts?.expectedGeneration }); + return { superseded: true, stored: newerCredential as never }; + }); + try { + await expect(getValidAccessTokenForAccount("nous", id)).rejects.toBeInstanceOf(OAuthLoginRequiredError); + // The concurrent credential is untouched. + expect(getCredential("nous")?.refresh).toBe("rt-concurrent"); + // RT-A's intent is not cleared (RT-A was consumed; a later refresh of the + // newer generation manages its own intent). + expect(nousRefreshIntentBlocksReplay("rt-old")).toBe(true); + // The newer generation is NOT marked needsReauth (generation-safe no-op). + expect(getAccountSet("nous")!.accounts[0]!.needsReauth).toBeUndefined(); + } finally { + mergeSpy.mockRestore(); + } + }); + + test("Nous RT-B cleanup failure after successful persistence keeps RT-B and marks needsReauth", async () => { + await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-cleanup-fail" }); + const id = getAccountSet("nous")!.activeAccountId; + const credential = getAccountCredential("nous", id)!; + + const unusableAccess = nousAccessJwt("nous-cleanup-fail", "billing:manage"); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: unusableAccess, + refresh_token: "rt-new", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + + const cleanupSpy = spyOn(nousModule, "clearNousRefreshIntent").mockImplementation(() => { + throw new Error("forced cleanup failure (EROFS)"); + }); + try { + await expect(getValidAccessTokenForAccount("nous", id)).rejects.toBeInstanceOf(OAuthLoginRequiredError); + // RT-B survived the cleanup failure. + expect(getCredential("nous")?.refresh).toBe("rt-new"); + expect(getAccountSet("nous")!.accounts[0]!.needsReauth).toBe(true); + // The stale RT-A intent may remain harmlessly (RT-A is no longer stored). + expect(nousRefreshIntentBlocksReplay("rt-old")).toBe(true); + } finally { + cleanupSpy.mockRestore(); + } + }); }); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 7b903f8376..60accb2659 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -476,9 +476,24 @@ describe("provider registry parity", () => { expect(nvidia?.freeTier).toBe(true); expect(nvidia?.authKind).toBe("key"); expect(nvidia?.keyOptional).toBeUndefined(); + // nous is a MIXED free/paid provider: the free tier is per-model (the + // `:free` slugs), not a property of the whole provider, so it is not in + // the provider-level freeTier list (see review feedback on PR #1397). expect(freeTierProviders).toEqual(["scaleway", "nvidia", "cloudflare-workers-ai"]); }); + test("nous exposes free models at model level, not provider level", () => { + const nous = PROVIDER_REGISTRY.find(entry => entry.id === "nous"); + expect(nous?.freeTier).toBe(false); + const freeSlugs = (nous?.models ?? []).filter(m => m.endsWith(":free")); + expect(freeSlugs).toEqual([ + "tencent/hy3:free", + "poolside/laguna-s-2.1:free", + "stepfun/step-3.7-flash:free", + "poolside/laguna-xs-2.1:free", + ]); + }); + test("freeTier propagates through config seed, enrich backfill, and presets without overwriting user config", async () => { const { enrichProviderFromRegistry } = await import("../src/providers/derive"); const nvidia = PROVIDER_REGISTRY.find(entry => entry.id === "nvidia")!; @@ -687,7 +702,7 @@ describe("provider registry parity", () => { test("GUI preset projection preserves current featured set plus key catalog and custom", () => { const featured = deriveFeaturedProviderIds(); expect(featured).toEqual([ - "openai", "xai", "command-code", "anthropic", "anthropic-apikey", "kimi", "openai-apikey", "umans", "opencode-go", "openrouter", + "openai", "xai", "command-code", "anthropic", "anthropic-apikey", "kimi", "nous", "openai-apikey", "umans", "opencode-go", "openrouter", "groq", "google", "azure-openai", "ollama", "vllm", "lm-studio", "opencode-free", "mimo-free", ]);