diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f296379501..24313e50ad 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, Nous Portal. | +| `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, Command Code, 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 @@ -114,7 +114,7 @@ ocx logout | `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. | +| `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. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 3a93aa623e..7a44f3a70e 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、GitHub Copilot、Nous Portal。 | +| `oauth` | 保存された OAuth アクセストークンを読み込み bearer キーとして使い、期限切れ前に自動更新します。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor、Command Code、GitHub Copilot、Nous Portal。 | [`retryOn429`](/ja/reference/configuration/)(同一キーでの 429 リトライ)は API キー プロバイダー (`authMode: "key"`)のみに適用されます。OAuth・forward・ローカル プリセットは除外されます — @@ -109,7 +109,7 @@ ocx logout | `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` セッションが復元されます。 | +| `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 ではありません。 | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index fc7a8cdea4..0911820dae 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, GitHub Copilot, Nous Portal. | +| `oauth` | 저장된 OAuth 액세스 토큰을 불러와 bearer 키로 사용하며, 만료 전에 자동 갱신합니다. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, Command Code, GitHub Copilot, Nous Portal. | [`retryOn429`](/ko/reference/configuration/)(동일 키 429 재시도)는 API 키 프로바이더 (`authMode: "key"`)에만 적용됩니다. OAuth·forward·로컬 프리셋은 제외됩니다 — 같은 토큰을 @@ -108,7 +108,7 @@ ocx logout | `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` 세션을 복원합니다. | +| `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가 아닙니다. | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 3ad522b910..5b2e67a451 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, Nous Portal. | +| `oauth` | Берёт сохранённый OAuth-токен доступа (автоматически обновляется до истечения срока) и использует его как bearer-ключ. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, Command Code, GitHub Copilot, Nous Portal. | Повтор при 429 на том же ключе ([`retryOn429`](/ru/reference/configuration/)) применим только к провайдерам с API-ключом (`authMode: "key"`). Пресеты OAuth, forward и local исключены — их @@ -118,7 +118,7 @@ ocx logout | `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`. | +| `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. | 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 f4aa3dbb4b..1740e66e10 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、GitHub Copilot、Nous Portal。 | +| `oauth` | 读取已存储的 OAuth 访问令牌(过期前自动刷新),并将其用作 bearer 密钥。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor、Command Code、GitHub Copilot、Nous Portal。 | [`retryOn429`](/zh-cn/reference/configuration/)(同 key 的 429 重试)仅适用于 API-key 提供商 (`authMode: "key"`)。OAuth、forward 与本地预设均被排除——同一 token 绝不可重放,本地运行时 @@ -99,7 +99,7 @@ ocx logout | `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` 会话。 | +| `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。 | diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 116a67f082..b2b86b4be6 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -205,6 +205,11 @@ export const OAUTH_PROVIDERS: Record = { refresh: (rt, signal) => refreshNousToken(rt, signal), providerConfig: oauthConfig("nous"), defaultModel: oauthDefaultModel("nous"), + // Single-use rotating refresh tokens must never be background-refreshed + // proactively: concurrent refreshes would trip the Portal's reuse + // revocation. Anchor the lazy-only default explicitly so it cannot be + // silently overridden to proactive. + defaultRefreshPolicy: "lazy-only", }, kiro: { login: (ctrl, opts) => loginKiro(ctrl, { forceLogin: opts?.forceLogin }), @@ -490,7 +495,7 @@ async function preserveNousRotatedRefresh( rotatedRefresh: string, expectedGeneration: string, previous: OAuthCredentials, -): Promise<"persisted" | "superseded" | "failed"> { +): Promise<{ kind: "persisted"; generation: string } | { kind: "superseded" } | { kind: "failed" }> { try { const recovery: OAuthCredentials = { refresh: rotatedRefresh, @@ -503,9 +508,17 @@ async function preserveNousRotatedRefresh( ...(previous.source ? { source: previous.source } : {}), }; const outcome = await mergeAccountCredential(provider, accountId, recovery, { expectedGeneration }); - return outcome.superseded ? "superseded" : "persisted"; - } catch { - return "failed"; + if (outcome.superseded) return { kind: "superseded" }; + // Return the exact generation this write produced, so the caller never + // re-reads the store (a concurrent writer could otherwise supply a different + // credential generation and be marked needsReauth by mistake). + return { kind: "persisted", generation: credentialGeneration(recovery) }; + } catch (error) { + // A store-mutation busy outcome is transient and retryable; surface it + // unchanged so the caller can retry rather than treating it as a permanent + // RT-B persistence failure. + if (error instanceof OAuthMutationBusyError) throw error; + return { kind: "failed" }; } } @@ -659,9 +672,7 @@ export async function refreshGenericAccountWithLock( generation, stored, ); - if (outcome === "persisted") { - const persisted = getAccountCredential(provider, accountId); - const persistedGeneration = persisted ? credentialGeneration(persisted) : generation; + if (outcome.kind === "persisted") { // 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). @@ -674,7 +685,10 @@ export async function refreshGenericAccountWithLock( cause: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), }); } - await markAccountNeedsReauthIfGeneration(provider, accountId, persistedGeneration, writerGeneration); + // Mark exactly the generation this write produced — never a + // credential written by a concurrent login between the merge and + // this step. + await markAccountNeedsReauthIfGeneration(provider, accountId, outcome.generation, 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 diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 1aea544ae8..56d74b5e48 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -34,7 +34,7 @@ * 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 { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { OAuthController, OAuthCredentials } from "./types"; import { getAuthStorePath } from "./store"; @@ -48,6 +48,13 @@ 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; +// Fallback lifetime for an inference access token when neither the JWT `exp` +// claim nor `expires_in` is present. Kept distinct from the device-flow window: +// the two are unrelated durations. +const DEFAULT_ACCESS_TOKEN_TTL_MS = 12 * 60 * 60 * 1000; +// Upper bound on a plausible inference-JWT lifetime; guards against a bad `exp` +// unit (ms instead of s) or a badly skewed clock. +const MAX_PLAUSIBLE_TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; const TOKEN_REQUEST_TIMEOUT_MS = 30_000; const OAUTH_EXPIRY_SKEW_MS = 2 * 60 * 1000; @@ -178,6 +185,11 @@ function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): // 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 }); + // mkdirSync only applies the mode at creation; re-apply owner-only on an + // existing directory so a permissive pre-existing dir is corrected. Fail + // closed: if the directory cannot be hardened to owner-only, do not write + // refresh-intent data into a directory a local attacker may observe. + chmodSync(dir, 0o700); hardenConfigDir(); const path = refreshIntentPath(refreshToken); try { @@ -223,9 +235,9 @@ export function nousRefreshIntentBlocksReplay(refreshToken: string): boolean { * 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`). + * Mirrors the https-only default in Hermes `hermes_cli/auth.py` + * (`DEFAULT_NOUS_PORTAL_URL`). Note: unlike Hermes, this function does not pin + * the host to an allowlist; any HTTPS origin passes. */ function resolvePortalBaseUrl(): string { const raw = (process.env.NOUS_PORTAL_BASE_URL || NOUS_PORTAL_BASE_URL).trim(); @@ -291,7 +303,14 @@ export function identityFromNousTokens(accessToken: string): { accountId?: strin function jwtExpiryMs(payload: NousJwtPayload | undefined): number | undefined { const exp = payload?.exp; if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined; - return exp * 1000; + const expMs = exp * 1000; + // Ignore an implausible claim (past, or absurdly far in the future) and let + // the caller fall back to `expires_in`. A too-large `exp` (e.g. ms instead of + // seconds, or clock skew) would otherwise pin the credential as never + // expiring, and a too-small one would force an immediate refresh that burns a + // single-use token. + if (expMs <= Date.now() || expMs > Date.now() + MAX_PLAUSIBLE_TOKEN_LIFETIME_MS) return undefined; + return expMs; } /** Does the inference JWT grant the required `inference:invoke` scope? */ @@ -398,13 +417,34 @@ function tokenErrorFromPayload(status: number, payload: unknown): NousTokenError */ 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"); + if (!access) { + // A response without a usable access token cannot be used for inference; + // classify it as terminal so the coordinator forces re-authentication. + throw new NousTokenError( + undefined, + "invalid_token", + "Nous Portal token response did not include an access token", + { terminal: true }, + ); + } const refresh = nonEmptyString(payload.refresh_token); if (!refresh) { + if (submittedRefreshToken) { + // Rotation path: the server consumed RT-A but returned no replacement, so + // reusing RT-A would trigger refresh_token_reused and revoke the session. + 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 }, + ); + } + // Device-login path: no refresh token was submitted; a missing refresh_token + // in the initial token response is an unusable response, not a reuse. 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)", + "invalid_token", + "Nous Portal token response did not include a refresh token", { terminal: true }, ); } @@ -422,8 +462,9 @@ function parseTokenPayload(payload: NousTokenResponse, submittedRefreshToken: st 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 rawExpires = expMs + ?? (expiresInMs !== undefined ? Date.now() + expiresInMs : Date.now() + DEFAULT_ACCESS_TOKEN_TTL_MS); + const expires = Math.max(0, rawExpires - OAUTH_EXPIRY_SKEW_MS); const creds: OAuthCredentials = { access, @@ -496,34 +537,59 @@ async function pollForToken( signal?: AbortSignal, ): Promise { const deadline = Date.now() + expiresInMs; + // Deadline-aware retry wait: never sleep past the device-flow deadline, so a + // late retry cannot delay the expiration report or accept a stale response. + const sleepUntilDeadline = async (ms: number) => { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) return false; + await sleep(Math.min(ms, remainingMs), signal); + return true; + }; 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), - }); + let response: Response; + try { + 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), + }); + } catch (netErr) { + // Genuine cancellation must abort immediately. Any other transport-level + // failure (timeout, DNS, dropped connection, proxy reset) must not destroy + // a device session that is still within its deadline: retry with the + // current wait interval. The device-code grant is idempotent for the + // pending case, so a retried poll is safe (unlike the refresh path). + if (signal?.aborted) throw new Error("Login cancelled"); + if (await sleepUntilDeadline(waitMs)) continue; + break; + } // 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, ""); + // Normalize a successful-but-non-object body (for example valid JSON + // `null`) to an empty object so the required-field validation below + // produces a terminal NousTokenError instead of a raw TypeError. + const parsed = (await response.json().catch(() => ({}))) as unknown; + const payload = (parsed && typeof parsed === "object" ? parsed : {}) as NousTokenResponse; + if (Date.now() >= deadline) break; + if (response.ok) return parseTokenPayload(payload, ""); const error = payload.error; if (error === "authorization_pending") { - await sleep(waitMs, signal); + if (!(await sleepUntilDeadline(waitMs))) break; 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); + if (!(await sleepUntilDeadline(waitMs))) break; continue; } if (error === "expired_token") { diff --git a/tests/nous-oauth-live.test.ts b/tests/nous-oauth-live.test.ts index 51b961b67a..83204a84af 100644 --- a/tests/nous-oauth-live.test.ts +++ b/tests/nous-oauth-live.test.ts @@ -21,10 +21,10 @@ * 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 { getAccountCredential, getAccountSet, getCredential } from "../src/oauth/store"; +import { NOUS_INFERENCE_BASE_URL, refreshNousToken } from "../src/oauth/nous"; import { refreshGenericAccountWithLock } from "../src/oauth/index"; -import type { OAuthProviderDef } from "../src/oauth/types"; +import type { OAuthCredentials } from "../src/oauth/types"; const LIVE = process.env.NOUS_LIVE_TEST === "1"; @@ -37,9 +37,11 @@ function len(label: string, v: string | undefined): void { 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 = { +// Minimal refresh-only provider def passed to the coordinator; the coordinator +// owns locking, generation checks, and persistence. (The full OAuthProviderDef +// is private to src/oauth/index.ts and not exported, so use the structural +// subset the coordinator actually consumes.) +const NOUS_DEF: { id: string; refresh: (rt: string, signal?: AbortSignal) => Promise } = { id: "nous", refresh: (rt: string, signal?: AbortSignal) => refreshNousToken(rt, signal), }; @@ -49,6 +51,10 @@ describe.skipIf(!LIVE)("Nous Portal live verification (opt-in, no key shared)", 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(); + // The store keys accounts by a hashed row id, not the JWT `sub`; use the row + // id for account-scoped refresh and read-back. + const rowId = getAccountSet("nous")?.activeAccountId; + expect(rowId, "stored nous account set must have an active row id").toBeTruthy(); console.log("[live] using locally stored nous credential (tokens withheld):"); len("stored.access", stored!.access); @@ -60,7 +66,7 @@ describe.skipIf(!LIVE)("Nous Portal live verification (opt-in, no key shared)", // refresh-intent, and returns a usable access token. const access = await refreshGenericAccountWithLock( "nous", - stored!.accountId!, + rowId!, NOUS_DEF, stored!, {}, @@ -69,13 +75,13 @@ describe.skipIf(!LIVE)("Nous Portal live verification (opt-in, no key shared)", expect(access.length).toBeGreaterThan(0); // Confirm rotation persisted a *different* refresh token (single-use contract). - const after = getCredential("nous", stored!.accountId); + const after = getAccountCredential("nous", rowId!); 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", { + const res = await fetch(`${NOUS_INFERENCE_BASE_URL}/models`, { headers: { Authorization: `Bearer ${access}` }, }); expect(res.status).toBe(200); diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts index c21ea1774d..8accc3b117 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -148,6 +148,24 @@ describe("Nous token-response wiring", () => { expect(cred.refresh).toBe("device-refresh"); expect(cred.accountId).toBe("device-user"); }); + + test("an implausible JWT exp falls back to expires_in instead of pinning a never-expiring credential", async () => { + // A too-large `exp` (e.g. milliseconds instead of seconds, or clock skew) + // must not produce an expiry so far in the future that the credential is + // never refreshed. The caller falls back to `expires_in`. + const access = jwtWithClaims({ sub: "wired-user", exp: 9_999_999_999 }); + 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"); + const expected = Date.now() + 3600_000 - 2 * 60 * 1000; + // The expiry is derived from expires_in (≈ now + 3600s - skew), not from the + // absurd exp claim. + expect(Math.abs(cred.expires - expected)).toBeLessThan(5000); + }); }); describe("Nous device-flow error handling", () => { @@ -266,6 +284,80 @@ describe("Nous device-flow error handling", () => { const ctrl: OAuthController = { onAuth() {} }; await expect(loginNous(ctrl)).rejects.toThrow("Nous Portal device authorization response missing required fields"); }); + + test("a device-login response missing refresh_token is an unusable-response error, not refresh_token_reused", async () => { + // In the device flow no refresh token was submitted, so a missing + // refresh_token must not be mislabeled as single-use reuse. + globalThis.fetch = (async (input: RequestInfo | URL) => { + 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 }); + } + // Token endpoint: unusable device-login response (no refresh_token). + return new Response(JSON.stringify({ + access_token: jwtWithClaims({ sub: "device-user", exp: Math.floor(Date.now() / 1000) + 3600 }), + }), { status: 200 }); + }) as typeof fetch; + const ctrl: OAuthController = { onAuth() {} }; + let err: unknown; + try { + await loginNous(ctrl); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(Error); + expect((err as { oauthError?: string }).oauthError).toBe("invalid_token"); + expect((err as { oauthError?: string }).oauthError).not.toBe("refresh_token_reused"); + }); + + test("a successful device-flow response missing access_token is a terminal invalid_token error", async () => { + // A 200 token response that omits access_token must be routed through + // parseTokenPayload so it is classified as terminal invalid_token, not + // silently treated as a transient/unknown error that retries the login. + globalThis.fetch = deviceFlowFetch(() => + new Response(JSON.stringify({ refresh_token: "device-refresh" }), { status: 200 }), + ); + 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("did not include an access token"); + 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("a successful device-flow response with a null body is a terminal invalid_token error, not a TypeError", async () => { + // A 200 response whose body is valid JSON `null` must be normalized to an + // empty object so parseTokenPayload raises the intended terminal + // invalid_token error instead of dereferencing null into a raw TypeError. + globalThis.fetch = deviceFlowFetch(() => + new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }), + ); + const ctrl: OAuthController = { onAuth() {} }; + let err: unknown; + try { + await loginNous(ctrl); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(TypeError); + expect((err as Error).message).toContain("did not include an access token"); + 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); + }); }); describe("Nous Portal base URL hardening", () => { diff --git a/tests/oauth-refresh.test.ts b/tests/oauth-refresh.test.ts index d270901c8a..0975e8285c 100644 --- a/tests/oauth-refresh.test.ts +++ b/tests/oauth-refresh.test.ts @@ -950,6 +950,42 @@ describe("oauth refresh hardening", () => { } }); + test("Nous RT-B preservation does not mark a credential committed by a concurrent writer", async () => { + await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-toctou" }); + const id = getAccountSet("nous")!.activeAccountId; + + const unusableAccess = nousAccessJwt("nous-toctou", "billing:manage"); + globalThis.fetch = (async () => new Response(JSON.stringify({ + access_token: unusableAccess, + refresh_token: "rt-new", + expires_in: 3600, + }), { status: 200 })) as typeof fetch; + + // The recovery write (RT-B) succeeds, then a concurrent login commits a + // fresh, fully usable credential before the coordinator marks needsReauth. + // The persisted-branch generation must be the one THIS write produced, so + // the newer credential is never marked needsReauth. + const realMerge = storeModule.mergeAccountCredential; + const mergeSpy = spyOn(storeModule, "mergeAccountCredential").mockImplementation(async (provider, accountId, cred, opts) => { + const outcome = await realMerge(provider, accountId, cred, opts); + await saveCredential("nous", { + access: nousAccessJwt("nous-toctou"), + refresh: "rt-fresh-login", + expires: Date.now() + 3600_000, + accountId: "nous-toctou", + }); + return outcome; + }); + try { + await expect(getValidAccessTokenForAccount("nous", id)).rejects.toBeInstanceOf(OAuthLoginRequiredError); + // The concurrently committed, usable credential must remain usable. + expect(getCredential("nous")?.refresh).toBe("rt-fresh-login"); + 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;