From 5cb2fed0a503a124f4cb72f76a485cdd28041dec Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Mon, 10 Aug 2026 05:56:15 +0200 Subject: [PATCH 01/14] =?UTF-8?q?feat(providers):=20add=20Nous=20Portal=20?= =?UTF-8?q?(Nous=20Research)=20OAuth=20provider=20=E2=80=94=20device=20gra?= =?UTF-8?q?nt=20+=20free/paid=20live=20catalog=20(Closes=20#1148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/content/docs/guides/providers.md | 4 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- src/oauth/index.ts | 13 + src/oauth/nous.ts | 279 ++++++++++++++++++ src/providers/registry.ts | 27 ++ tests/nous-oauth.test.ts | 180 +++++++++++ tests/provider-registry-parity.test.ts | 4 +- 10 files changed, 508 insertions(+), 7 deletions(-) create mode 100644 src/oauth/nous.ts create mode 100644 tests/nous-oauth.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 14bb0c08c9..725b3e8339 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -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,6 +113,7 @@ 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. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 2a58f4ee11..a9c0d6432f 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、Nous Portal。 | [`retryOn429`](/ja/reference/configuration/)(同一キーでの 429 リトライ)は API キー プロバイダー (`authMode: "key"`)のみに適用されます。OAuth・forward・ローカル プリセットは除外されます — diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index f58efd01fc..15d04ebf5e 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, Nous Portal. | [`retryOn429`](/ko/reference/configuration/)(동일 키 429 재시도)는 API 키 프로바이더 (`authMode: "key"`)에만 적용됩니다. OAuth·forward·로컬 프리셋은 제외됩니다 — 같은 토큰을 diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 157c3514d9..ef572101a5 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 исключены — их 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..fc9e27f8c1 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、Nous Portal。 | [`retryOn429`](/zh-cn/reference/configuration/)(同 key 的 429 重试)仅适用于 API-key 提供商 (`authMode: "key"`)。OAuth、forward 与本地预设均被排除——同一 token 绝不可重放,本地运行时 diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 6b6d027f2b..58ec121f1d 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 } 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,7 @@ 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 ["invalid_grant","refresh_token_reused","revoked","revoked_token","expired_token"].includes(error.oauthError??""); 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;} diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts new file mode 100644 index 0000000000..799c3b4bb1 --- /dev/null +++ b/src/oauth/nous.ts @@ -0,0 +1,279 @@ +/** + * 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: Bearer` 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. + */ +import type { OAuthController, OAuthCredentials } from "./types"; + +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; + [key: string]: unknown; +} + +function resolvePortalBaseUrl(): string { + return (process.env.NOUS_PORTAL_BASE_URL || NOUS_PORTAL_BASE_URL).replace(/\/+$/, ""); +} + +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 tokens yield no identity (account still + * works, single-account only). + */ +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; +} + +export class NousTokenError extends Error { + constructor( + public readonly status: number | undefined, + public readonly oauthError: string | undefined, + message: string, + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = "NousTokenError"; + } +} + +function requestSignal(signal: AbortSignal | undefined): AbortSignal { + const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new Error("Login cancelled")); + const t = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("Login cancelled")); }, { once: true }); + }); +} + +async function readTokenError(response: Response): Promise { + let oauthError: string | undefined; + let detail = ""; + try { + const body = (await response.json()) as { error?: unknown; error_description?: unknown }; + if (typeof body.error === "string") oauthError = body.error; + if (typeof body.error_description === "string") detail = body.error_description; + } catch { + // Non-JSON error body — fall through to the status-only message. + } + const suffix = detail ? `: ${detail}` : oauthError ? `: ${oauthError}` : ""; + return new NousTokenError(response.status, oauthError, `Nous Portal token request failed: ${response.status}${suffix}`); +} + +function parseTokenPayload(payload: NousTokenResponse, refreshFallback?: 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) ?? refreshFallback; + if (!refresh) throw new Error("Nous Portal token response did not include a refresh token"); + + 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; + return { + access, + refresh, + expires, + ...identityFromNousTokens(access), + }; +} + +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, + }), + signal: requestSignal(signal), + }); + if (!response.ok) throw await readTokenError(response); + const payload = (await response.json()) 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", + }), + signal: requestSignal(signal), + }); + 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"); + if (error === "access_denied") throw new NousTokenError(response.status, "access_denied", "Nous Portal device authorization denied"); + throw await readTokenError(response); + } + 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. + */ +export async function refreshNousToken(refreshToken: string, signal?: AbortSignal): Promise { + const response = await fetch(`${resolvePortalBaseUrl()}/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, + }), + signal: requestSignal(signal), + }); + if (!response.ok) throw await readTokenError(response); + return parseTokenPayload((await response.json()) as NousTokenResponse, refreshToken); +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5cb0c124f0..83d88b850e 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1059,6 +1059,33 @@ 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 (no static model list). + id: "nous", + label: "Nous Portal", + adapter: "openai-chat", + baseUrl: "https://inference-api.nousresearch.com/v1", + authKind: "oauth", + oauthId: "nous", + featured: true, + freeTier: true, + dashboardUrl: "https://portal.nousresearch.com", + defaultModel: "tencent/hy3:free", + liveModels: true, + modelDiscovery: { + url: "https://inference-api.nousresearch.com/v1/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.", + }, { id: "openai-apikey", label: "OpenAI API", diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts new file mode 100644 index 0000000000..7992df74fe --- /dev/null +++ b/tests/nous-oauth.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { identityFromNousTokens, loginNous, refreshNousToken } from "../src/oauth/nous"; +import { getCredential, listAccounts, saveCredential } from "../src/oauth/store"; +import type { OAuthController } from "../src/oauth/types"; + +const TEST_DIR = join(import.meta.dir, ".tmp-nous-oauth-test"); +const TEST_PORTAL = "http://portal.test"; +let previousOpencodexHome: string | undefined; +let previousPortalBase: string | undefined; + +function jwtWithClaims(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify(claims)).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; +} + +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; + 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; + }); + + 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 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"); + }); +}); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 7b903f8376..5da0882137 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -476,7 +476,7 @@ describe("provider registry parity", () => { expect(nvidia?.freeTier).toBe(true); expect(nvidia?.authKind).toBe("key"); expect(nvidia?.keyOptional).toBeUndefined(); - expect(freeTierProviders).toEqual(["scaleway", "nvidia", "cloudflare-workers-ai"]); + expect(freeTierProviders).toEqual(["nous", "scaleway", "nvidia", "cloudflare-workers-ai"]); }); test("freeTier propagates through config seed, enrich backfill, and presets without overwriting user config", async () => { @@ -687,7 +687,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", ]); From d57de6981e00a60d5b1566767245f51c309f7158 Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Mon, 10 Aug 2026 06:12:13 +0200 Subject: [PATCH 02/14] feat(providers): seed Nous Portal free models from live Portal list (hy3, laguna-s/xs, step-3.7-flash) --- src/providers/registry.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 83d88b850e..6ac7b8bf66 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1067,7 +1067,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // (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 (no static model list). + // 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", @@ -1079,12 +1085,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ 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: { url: "https://inference-api.nousresearch.com/v1/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.", + 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", From 3a80860c18c0701147971bc8cc3bf955568aaa83 Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Mon, 10 Aug 2026 06:47:00 +0200 Subject: [PATCH 03/14] test(nous-oauth): cover device-flow error paths and refresh-token fallback - access_denied / expired_token surface as terminal NousTokenError - slow_down backs off (interval bump) then resumes polling to success - authorization_pending until deadline raises a timed-out error - refresh omitting a new refresh_token keeps the previous one (header sent) --- tests/nous-oauth.test.ts | 123 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts index 7992df74fe..4071178310 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -124,6 +124,129 @@ describe("Nous token-response wiring", () => { }); }); +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() {} }; + await expect(loginNous(ctrl)).rejects.toThrow("Nous Portal device authorization denied"); + }); + + 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() {} }; + await expect(loginNous(ctrl)).rejects.toThrow("Nous Portal device authorization expired"); + }); + + 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); +}); + +describe("Nous refresh fallback", () => { + 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; + }); + + test("keeps the previous refresh token when the response omits a new 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; + + const cred = await refreshNousToken("old-refresh"); + + expect(cred.access).toBe(access); + expect(cred.refresh).toBe("old-refresh"); + expect(cred.accountId).toBe("wired-user"); + }); +}); + describe("Nous multiauth via saveCredential", () => { beforeEach(() => { previousOpencodexHome = process.env.OPENCODEX_HOME; From 01824ba2f1849623781b81cdd80990a3f9882008 Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Mon, 10 Aug 2026 17:23:34 +0200 Subject: [PATCH 04/14] fix(oauth/nous): enforce HTTPS base URL and single-use refresh rotation; docs + tests Addresses the two CHANGES_REQUESTED blockers on PR #1397: 1. resolvePortalBaseUrl() now hard-validates the full OAuth base URL via new URL() and throws BEFORE any fetch is dispatched: rejects non-HTTPS schemes, embedded credentials, query strings, and fragments; returns only url.origin. Aligns opencodex with Hermes hermes_cli/auth.py (_NOUS_PORTAL_ALLOWED_HOSTS, https-only) and prevents the single-use refresh token / inference JWT from ever traversing cleartext. 2. parseTokenPayload() no longer falls back to the submitted refresh token. A response that omits refresh_token, or returns a replacement equal to the submitted token, throws NousTokenError(oauthError: 'refresh_token_reused') so the next refresh cannot replay a consumed credential and trigger session revocation. Also: - tests/nous-oauth.test.ts: HTTPS/URL hardening (fetch never reached), missing/equal refresh rejection, and NousTokenError.oauthError contract on access_denied / expired_token. - tests/nous-oauth-live.test.ts: opt-in, CI-skipped live verification that reads the local refresh token without printing it (lengths only), asserts rotation + read-only /v1/models reachability. No provider key is shared. - docs ru/guides/providers.md: eight OAuth presets, ocx login nous, nous row. Verified: tsc --noEmit, bun test nous-oauth (17/17), privacy:scan passed, targeted suite 186/186. Full bun run test in progress. --- .../src/content/docs/ru/guides/providers.md | 4 +- src/oauth/nous.ts | 74 ++++++++++- tests/nous-oauth-live.test.ts | 61 +++++++++ tests/nous-oauth.test.ts | 124 +++++++++++++++++- 4 files changed, 250 insertions(+), 13 deletions(-) create mode 100644 tests/nous-oauth-live.test.ts diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index ef572101a5..3714c8dc7e 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -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,6 +117,7 @@ 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 и обнаружение моделей с фильтрацией по аккаунту. | diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 799c3b4bb1..007e2862e2 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -64,8 +64,45 @@ interface NousJwtPayload { [key: string]: unknown; } +/** + * 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 { - return (process.env.NOUS_PORTAL_BASE_URL || NOUS_PORTAL_BASE_URL).replace(/\/+$/, ""); + const raw = (process.env.NOUS_PORTAL_BASE_URL || NOUS_PORTAL_BASE_URL).trim(); + let url: URL; + try { + url = new URL(raw); + } catch { + throw new NousTokenError(undefined, undefined, `Nous Portal base URL is not a valid URL: ${raw}`); + } + if (url.protocol !== "https:") { + throw new NousTokenError(undefined, undefined, `Nous Portal base URL must use HTTPS (got ${url.protocol}): ${raw}`); + } + if (url.username || url.password) { + throw new NousTokenError(undefined, undefined, `Nous Portal base URL must not contain embedded credentials: ${raw}`); + } + if (url.search) { + throw new NousTokenError(undefined, undefined, `Nous Portal base URL must not contain a query string: ${raw}`); + } + if (url.hash) { + throw new NousTokenError(undefined, undefined, `Nous Portal base URL must not contain a fragment: ${raw}`); + } + // 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 { @@ -146,11 +183,38 @@ async function readTokenError(response: Response): Promise { return new NousTokenError(response.status, oauthError, `Nous Portal token request failed: ${response.status}${suffix}`); } -function parseTokenPayload(payload: NousTokenResponse, refreshFallback?: string): OAuthCredentials { +/** + * 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. + * + * @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) ?? refreshFallback; - if (!refresh) throw new Error("Nous Portal token response did not include a refresh 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)", + ); + } + 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)", + ); + } const jwtPayload = decodeJwtPayload(access); const expMs = jwtExpiryMs(jwtPayload); @@ -225,7 +289,7 @@ async function pollForToken( signal: requestSignal(signal), }); const payload = (await response.json().catch(() => ({}))) as NousTokenResponse; - if (response.ok && nonEmptyString(payload.access_token)) return parseTokenPayload(payload); + if (response.ok && nonEmptyString(payload.access_token)) return parseTokenPayload(payload, ""); const error = payload.error; if (error === "authorization_pending") { await sleep(waitMs, signal); diff --git a/tests/nous-oauth-live.test.ts b/tests/nous-oauth-live.test.ts new file mode 100644 index 0000000000..f04d393780 --- /dev/null +++ b/tests/nous-oauth-live.test.ts @@ -0,0 +1,61 @@ +/** + * Opt-in, NON-DESTRUCTIVE live verification for the Nous Portal provider. + * + * This file is skipped unless `NOUS_LIVE_TEST=1` is set, so it never runs in + * CI and no credential ever travels off the local machine. 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. + * - This test REFRESHES but does NOT persist the rotated token back to the + * store and does NOT call logout, so it cannot destroy the real session. + * - It performs a single read-only GET against the live model catalog. + */ +import { describe, expect, test } from "bun:test"; +import { getCredential } from "../src/oauth/store"; +import { refreshNousToken } from "../src/oauth/nous"; + +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}`); +} + +describe.skipIf(!LIVE)("Nous Portal live verification (opt-in, no key shared)", () => { + test("real-account refresh returns a rotated token and 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(); + + 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 against the production Portal. Tokens are read back but redacted. + const refreshed = await refreshNousToken(stored!.refresh); + len("refreshed.access", refreshed.access); + len("refreshed.refresh", refreshed.refresh); + expect(refreshed.access.length).toBeGreaterThan(0); + expect(refreshed.refresh.length).toBeGreaterThan(0); + // Rotation must have produced a different refresh token (single-use contract). + expect(refreshed.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 ${refreshed.access}` }, + }); + expect(res.status).toBe(200); + const models = (await res.json()) as Array<{ id?: string }>; + const ids = models.map((m) => m.id).filter(Boolean) as string[]; + 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 index 4071178310..515a3fa747 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -6,7 +6,7 @@ import { getCredential, listAccounts, saveCredential } from "../src/oauth/store" import type { OAuthController } from "../src/oauth/types"; const TEST_DIR = join(import.meta.dir, ".tmp-nous-oauth-test"); -const TEST_PORTAL = "http://portal.test"; +const TEST_PORTAL = "https://portal.test"; let previousOpencodexHome: string | undefined; let previousPortalBase: string | undefined; @@ -159,7 +159,16 @@ describe("Nous device-flow error handling", () => { new Response(JSON.stringify({ error: "access_denied", error_description: "User denied the request" }), { status: 400 }), ); const ctrl: OAuthController = { onAuth() {} }; - await expect(loginNous(ctrl)).rejects.toThrow("Nous Portal device authorization denied"); + 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"); }); test("expired_token surfaces as a terminal NousTokenError", async () => { @@ -167,7 +176,16 @@ describe("Nous device-flow error handling", () => { new Response(JSON.stringify({ error: "expired_token", error_description: "Code expired" }), { status: 400 }), ); const ctrl: OAuthController = { onAuth() {} }; - await expect(loginNous(ctrl)).rejects.toThrow("Nous Portal device authorization expired"); + 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"); }); test("slow_down backs off and resumes polling until success", async () => { @@ -213,7 +231,68 @@ describe("Nous device-flow error handling", () => { }, 15_000); }); -describe("Nous refresh fallback", () => { +describe("Nous Portal base URL hardening", () => { + 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("old-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("old-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("old-refresh")).rejects.toThrow(/base URL/); + expect(fetchCalled).toBe(false); + } finally { + globalThis.fetch = realFetch; + delete process.env.NOUS_PORTAL_BASE_URL; + } + } + }); +}); + +describe("Nous refresh token safety", () => { const realFetch = globalThis.fetch; beforeEach(() => { @@ -227,7 +306,7 @@ describe("Nous refresh fallback", () => { else process.env.NOUS_PORTAL_BASE_URL = previousPortalBase; }); - test("keeps the previous refresh token when the response omits a new one", async () => { + 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"]; @@ -239,10 +318,41 @@ describe("Nous refresh fallback", () => { }), { status: 200 }); }) as typeof fetch; - const cred = await refreshNousToken("old-refresh"); + 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("old-refresh"); + expect(cred.refresh).toBe("rotated-refresh"); expect(cred.accountId).toBe("wired-user"); }); }); From e139b0947b466e3c9a22836729ab20b2ae3f406b Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Tue, 11 Aug 2026 00:35:55 +0200 Subject: [PATCH 05/14] fix(oauth/nous): failure-atomic refresh, terminal errors, scope check, redirect guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the 10 review points from Wibias on PR #1397: - #2 Single-use refresh is now failure-atomic. A durable refresh-intent file (keyed by a sha256 of the refresh token, never the token in cleartext) is written before the refresh request and cleared only after the rotated token is obtained. If the server responds but the rotation cannot be persisted, the intent is marked 'uncertain' and a later refresh REFUSES to replay the possibly-consumed token (NousTokenError refresh_token_reused, terminal) — forcing a clean re-auth instead of a session-revoking replay. - #3 Credential-bearing OAuth requests (device + token) now pass redirect: 'error' so custom auth headers cannot follow a cross-origin redirect. - #4 invalid_token (and invalid_grant/revoked/revoked_token) are now terminal NousTokenError values that drive re-authentication. - #5 The returned access-token JWT scope is validated for inference:invoke before the credential is treated as usable. An insufficient-scope token is a terminal error that STILL surfaces the already-rotated refresh token, so the caller can persist it and re-auth without discarding the rotation. - #6 Live /models test accepts both the OpenAI-style { data: [...] } body and a bare array (production contract). - #7 freeTier is no longer true for the mixed free/paid provider; free models are classified at model level (the :free slugs). Parity test updated. - #8 pollForToken parses the response body once and passes the payload through to the error path instead of re-reading a consumed body. - #9 sleep() now removes its abort listener on both resolve and abort, so polling iterations do not accumulate listeners. - #1 The live test is now non-destructive: it persists the rotated token back through mergeAccountCredential (prod path), so the local session stays valid. - #10 Russian docs already mirror the English source (8 presets, ocx login nous, nous table row with device grant + single-use rotation). No provider API key is shared; privacy:scan passes. Verified: tsc --noEmit, nous-oauth 21/21, provider-registry-parity + targeted suite 193/193. --- src/oauth/nous.ts | 251 +++++++++++++++++++++---- src/providers/registry.ts | 5 +- tests/nous-oauth-live.test.ts | 23 ++- tests/nous-oauth.test.ts | 144 +++++++++++++- tests/provider-registry-parity.test.ts | 17 +- 5 files changed, 396 insertions(+), 44 deletions(-) diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 007e2862e2..9e276db62c 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -11,7 +11,7 @@ * - 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: Bearer` against the OpenAI-compatible inference API at + * `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` + @@ -22,8 +22,19 @@ * 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. */ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; import type { OAuthController, OAuthCredentials } from "./types"; +import { getAuthStorePath } from "./store"; export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com"; export const NOUS_INFERENCE_BASE_URL = "https://inference-api.nousresearch.com/v1"; @@ -61,9 +72,74 @@ 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. + +type RefreshIntentStatus = "pending" | "uncertain"; + +interface RefreshIntent { + status: RefreshIntentStatus; + updatedAt: number; +} + +function refreshIntentDir(): string { + const base = join(getAuthStorePath(), "..", ".nous-refresh-intent"); + return base; +} + +function refreshIntentPath(refreshToken: string): string { + const hash = createHash("sha256").update(refreshToken).digest("hex"); + return join(refreshIntentDir(), `${hash}.json`); +} + +function readRefreshIntent(refreshToken: string): RefreshIntent | undefined { + try { + const raw = readFileSync(refreshIntentPath(refreshToken), "utf8"); + return JSON.parse(raw) as RefreshIntent; + } catch { + return undefined; + } +} + +function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): void { + const dir = refreshIntentDir(); + try { + mkdirSync(dir, { recursive: true }); + writeFileSync(refreshIntentPath(refreshToken), JSON.stringify({ status, updatedAt: Date.now() } satisfies RefreshIntent), "utf8"); + } catch { + // Best-effort: if we cannot record the intent, the refresh still proceeds; + // we simply lose the uncertain-outcome guard for this single attempt. + } +} + +function clearRefreshIntent(refreshToken: string): void { + try { + rmSync(refreshIntentPath(refreshToken), { force: true }); + } catch { + // ignore + } +} + +/** + * True when we have already submitted this refresh token and were NOT able to + * confirm the rotated token was persisted. In that uncertain state we must + * never blindly replay it — the server may have already consumed it, and a + * replay would trigger `refresh_token_reused` and revoke the session. The + * caller should force a clean re-authentication instead. + */ +export function nousRefreshIntentIsUncertain(refreshToken: string): boolean { + return readRefreshIntent(refreshToken)?.status === "uncertain"; +} + +// ── Base URL hardening (review blocker #1, also flagged by multiple reviewers) ─ + /** * Normalize and hard-validate the Nous Portal OAuth base URL. * @@ -144,15 +220,30 @@ function jwtExpiryMs(payload: NousJwtPayload | undefined): number | 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) credentials to persist before re-auth. */ + public readonly credentials?: OAuthCredentials; + constructor( - public readonly status: number | undefined, + status: number | undefined, public readonly oauthError: string | undefined, message: string, - options?: { cause?: unknown }, + options?: { cause?: unknown; terminal?: boolean; credentials?: OAuthCredentials }, ) { super(message, options); this.name = "NousTokenError"; + this.terminal = options?.terminal ?? false; + this.credentials = options?.credentials; } } @@ -161,26 +252,39 @@ function requestSignal(signal: AbortSignal | undefined): AbortSignal { 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 t = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("Login cancelled")); }, { once: true }); + 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 }); }); } -async function readTokenError(response: Response): Promise { - let oauthError: string | undefined; - let detail = ""; - try { - const body = (await response.json()) as { error?: unknown; error_description?: unknown }; - if (typeof body.error === "string") oauthError = body.error; - if (typeof body.error_description === "string") detail = body.error_description; - } catch { - // Non-JSON error body — fall through to the status-only message. - } +/** + * 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}` : ""; - return new NousTokenError(response.status, oauthError, `Nous Portal token request failed: ${response.status}${suffix}`); + 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 }); } /** @@ -194,6 +298,12 @@ async function readTokenError(response: Response): Promise { * (`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. */ @@ -206,6 +316,7 @@ function parseTokenPayload(payload: NousTokenResponse, submittedRefreshToken: st 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) { @@ -213,6 +324,7 @@ function parseTokenPayload(payload: NousTokenResponse, submittedRefreshToken: st 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 }, ); } @@ -223,12 +335,27 @@ function parseTokenPayload(payload: NousTokenResponse, submittedRefreshToken: st // 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; - return { + + 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<{ @@ -245,9 +372,10 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{ client_id: NOUS_OAUTH_CLIENT_ID, scope: NOUS_OAUTH_SCOPE, }), + redirect: "error", signal: requestSignal(signal), }); - if (!response.ok) throw await readTokenError(response); + if (!response.ok) throw tokenErrorFromPayload(response.status, await response.json().catch(() => ({}))); const payload = (await response.json()) as NousDeviceAuthorizationResponse; const userCode = nonEmptyString(payload.user_code); const deviceCode = nonEmptyString(payload.device_code); @@ -286,8 +414,11 @@ async function pollForToken( 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; @@ -304,7 +435,13 @@ async function pollForToken( } if (error === "expired_token") throw new NousTokenError(response.status, "expired_token", "Nous Portal device authorization expired"); if (error === "access_denied") throw new NousTokenError(response.status, "access_denied", "Nous Portal device authorization denied"); - throw await readTokenError(response); + // 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"); } @@ -323,21 +460,67 @@ export async function loginNous(ctrl: OAuthController): Promise { - const response = await fetch(`${resolvePortalBaseUrl()}/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, - }), - signal: requestSignal(signal), - }); - if (!response.ok) throw await readTokenError(response); - return parseTokenPayload((await response.json()) as NousTokenResponse, refreshToken); + // Never blindly replay a token whose outcome we could not confirm earlier. + if (nousRefreshIntentIsUncertain(refreshToken)) { + throw new NousTokenError( + undefined, + "refresh_token_reused", + "Refusing to replay a refresh token with an uncertain prior outcome (previous rotation may not have persisted)", + { terminal: true }, + ); + } + // Mark that we are about to submit this token. It stays "pending" until we + // either obtain the rotated token (cleared) or confirm a server response + // while failing to persist (marked "uncertain"). + writeRefreshIntent(refreshToken, "pending"); + + let response: Response; + try { + response = await fetch(`${resolvePortalBaseUrl()}/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) { + // Network-level failure: the server never saw the token, so it was not + // consumed. Leave the intent "pending" so a later retry can resubmit it. + throw netErr; + } + + if (!response.ok) { + // Error before any rotation: the token was not consumed. Clear the intent + // so a retry can resubmit it. + clearRefreshIntent(refreshToken); + throw tokenErrorFromPayload(response.status, await response.json().catch(() => ({}))); + } + + // The server responded 200 — the submitted token may now be consumed. If we + // fail to parse/persist the rotated token, mark the intent uncertain so we + // never replay it. + try { + const creds = parseTokenPayload((await response.json()) as NousTokenResponse, refreshToken); + clearRefreshIntent(refreshToken); + return creds; + } catch (e) { + writeRefreshIntent(refreshToken, "uncertain"); + throw e; + } } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 6ac7b8bf66..8440e87ced 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1081,7 +1081,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ authKind: "oauth", oauthId: "nous", featured: true, - freeTier: 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, diff --git a/tests/nous-oauth-live.test.ts b/tests/nous-oauth-live.test.ts index f04d393780..28920d9341 100644 --- a/tests/nous-oauth-live.test.ts +++ b/tests/nous-oauth-live.test.ts @@ -10,12 +10,14 @@ * - 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. - * - This test REFRESHES but does NOT persist the rotated token back to the - * store and does NOT call logout, so it cannot destroy the real session. - * - It performs a single read-only GET against the live model catalog. + * - This test REFRESHES and then PERSISTS the rotated token back through the + * same `mergeAccountCredential` path production uses, so the local session + * stays valid (it is not 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 { getCredential, mergeAccountCredential } from "../src/oauth/store"; import { refreshNousToken } from "../src/oauth/nous"; const LIVE = process.env.NOUS_LIVE_TEST === "1"; @@ -30,9 +32,10 @@ function len(label: string, v: string | undefined): void { } describe.skipIf(!LIVE)("Nous Portal live verification (opt-in, no key shared)", () => { - test("real-account refresh returns a rotated token and live catalog is reachable", async () => { + 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); @@ -48,12 +51,20 @@ describe.skipIf(!LIVE)("Nous Portal live verification (opt-in, no key shared)", // Rotation must have produced a different refresh token (single-use contract). expect(refreshed.refresh).not.toBe(stored!.refresh); + // Persist the rotation through the production path so the local session + // stays valid (non-destructive). + const result = await mergeAccountCredential("nous", refreshed.accountId ?? stored!.accountId!, refreshed); + console.log(`[live] rotated token persisted (superseded=${"superseded" in result})`); + // Read-only live catalog discovery (same endpoint the adapter uses). const res = await fetch("https://inference-api.nousresearch.com/v1/models", { headers: { Authorization: `Bearer ${refreshed.access}` }, }); expect(res.status).toBe(200); - const models = (await res.json()) as Array<{ id?: string }>; + const body = (await res.json()) as unknown; + const models = Array.isArray(body) + ? (body as Array<{ id?: string }>) + : ((body as { data?: Array<{ id?: string }> }).data ?? []); const ids = models.map((m) => m.id).filter(Boolean) as string[]; console.log(`[live] live catalog returned ${ids.length} models; free tier present: ${ids.some((id) => id.endsWith(":free"))}`); expect(ids.length).toBeGreaterThan(0); diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts index 515a3fa747..be5f88648f 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; -import { identityFromNousTokens, loginNous, refreshNousToken } from "../src/oauth/nous"; +import { identityFromNousTokens, loginNous, nousRefreshIntentIsUncertain, refreshNousToken } from "../src/oauth/nous"; import { getCredential, listAccounts, saveCredential } from "../src/oauth/store"; import type { OAuthController } from "../src/oauth/types"; @@ -11,8 +11,11 @@ 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(claims)).toString("base64url"); + const payload = Buffer.from(JSON.stringify(payloadClaims)).toString("base64url"); return `${header}.${payload}.sig`; } @@ -232,6 +235,20 @@ describe("Nous device-flow error handling", () => { }); 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; @@ -297,13 +314,20 @@ describe("Nous refresh token safety", () => { 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 () => { @@ -411,3 +435,119 @@ describe("Nous multiauth via saveCredential", () => { 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 rotated token is persisted and the intent is cleared", 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"); + // Intent cleared after a successful rotation. + expect(nousRefreshIntentIsUncertain("old-refresh")).toBe(false); + }); + + test("an uncertain prior outcome blocks replay of the consumed token (no silent reuse)", async () => { + const access = jwtWithClaims({ sub: "atomic-user", exp: Math.floor(Date.now() / 1000) + 3600, scope: "inference:invoke" }); + // First attempt: server returns 200 but parse would fail to persist -> mark uncertain. + let firstCall = true; + globalThis.fetch = (async () => { + if (firstCall) { + firstCall = false; + return new Response(JSON.stringify({ + access_token: access, + refresh_token: "rotated-refresh", + expires_in: 3600, + }), { status: 200 }); + } + return new Response(JSON.stringify({ error: "refresh_token_reused" }), { status: 400 }); + }) as typeof fetch; + + // First refresh rotates successfully (intent cleared). + const cred = await refreshNousToken("old-refresh"); + expect(cred.refresh).toBe("rotated-refresh"); + + // Simulate a crash that lost the rotated token: re-submit the OLD token. + // Because we cannot re-clear the intent here (store persistence is what + // clears it), emulate the uncertain state the store would leave behind. + // We re-run a refresh that reaches the server but fails to persist: to + // exercise the guard we write the uncertain intent directly via a failed + // parse path. + globalThis.fetch = (async () => new Response("not json", { status: 200 })) as typeof fetch; + // A non-JSON 200 body makes parseTokenPayload throw, marking the intent + // uncertain; the NEXT submission of the same token must be refused. + await expect(refreshNousToken("old-refresh")).rejects.toThrow(); + expect(nousRefreshIntentIsUncertain("old-refresh")).toBe(true); + + globalThis.fetch = (async () => 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", + }); + }); + + 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 so the caller can persist + // it and drive a clean re-auth without discarding the rotation. + expect((err as { credentials?: { refresh?: string } }).credentials?.refresh).toBe("rotated-refresh"); + }); +}); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 5da0882137..60accb2659 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -476,7 +476,22 @@ describe("provider registry parity", () => { expect(nvidia?.freeTier).toBe(true); expect(nvidia?.authKind).toBe("key"); expect(nvidia?.keyOptional).toBeUndefined(); - expect(freeTierProviders).toEqual(["nous", "scaleway", "nvidia", "cloudflare-workers-ai"]); + // 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 () => { From 65b7bd1474de5cf1324eb7b83531588c4701d7c9 Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Tue, 11 Aug 2026 00:50:52 +0200 Subject: [PATCH 06/14] fix(oauth/nous): close the uncertain-outcome window for single-use refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep re-review (real execution proof) showed the first intent design still relied on the server to refuse a replay when the rotated token was obtained but lost before the store persisted it. Harden the contract: - The refresh-intent file now stays in the 'submitted' state after a successful rotation (it previously cleared it). It is only cleared by the account store via clearNousRefreshIntent() once mergeAccountCredential persists the rotated token. - Replaying a token whose intent is 'submitted' OR 'uncertain' is refused up front (NousTokenError refresh_token_reused, terminal) — never blindly replayed, and without depending on the server's reuse detection. - Network-level failure (server never saw the token) still clears the intent so a retry is safe. - clearNousRefreshIntent is wired into the shared refresh orchestrator (src/oauth/index.ts) right after mergeAccountCredential; it is a no-op for non-Nous providers (they never write an intent). Verified by a real execution probe (not just mocks): a rotation that obtains the rotated token but crashes before persistence now makes the next replay of the old token refused by the guard, with the intent present on disk. Tests: nous-oauth 23/23 (adds 'rotated token obtained but not persisted blocks replay', '200 unparseable body marks uncertain', 'network failure replayable'); targeted suite 195/195. tsc + privacy:scan clean. --- src/oauth/index.ts | 4 +-- src/oauth/nous.ts | 60 ++++++++++++++++++++++++++------------- tests/nous-oauth.test.ts | 61 +++++++++++++++++++++------------------- 3 files changed, 75 insertions(+), 50 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 58ec121f1d..39c35734f5 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -8,7 +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 } from "./nous"; +import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent } from "./nous"; import { loginChatGPT, refreshChatGPTToken } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; @@ -463,7 +463,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede ...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}), }; } -export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise{const writerGeneration=captureConfigGeneration();const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh,deps.signal),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(error instanceof OAuthMutationBusyError){permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));throw error;}if(!terminal(error))throw error;const failedAt=now();permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),failedAt+XAI_PERMANENT_FAILURE_TTL_MS);sweepExpiredOnWrite(failedAt);await markAccountNeedsReauthIfGeneration(provider,accountId,generation,writerGeneration);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}} +export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise{const writerGeneration=captureConfigGeneration();const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh,deps.signal),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}clearNousRefreshIntent(candidate.refresh);permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(error instanceof OAuthMutationBusyError){permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));throw error;}if(!terminal(error))throw error;const failedAt=now();permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),failedAt+XAI_PERMANENT_FAILURE_TTL_MS);sweepExpiredOnWrite(failedAt);await markAccountNeedsReauthIfGeneration(provider,accountId,generation,writerGeneration);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}} function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCredentials | undefined { if (stored.source !== "local-cli") return undefined; diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 9e276db62c..998aadc651 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -81,8 +81,16 @@ interface NousJwtPayload { // 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. +// +// States: +// - "submitted": we sent this token and the server responded (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 server responded 200 but we failed to parse/persist the +// rotated token. Replay is refused. -type RefreshIntentStatus = "pending" | "uncertain"; +type RefreshIntentStatus = "submitted" | "uncertain"; interface RefreshIntent { status: RefreshIntentStatus; @@ -128,14 +136,14 @@ function clearRefreshIntent(refreshToken: string): void { } /** - * True when we have already submitted this refresh token and were NOT able to - * confirm the rotated token was persisted. In that uncertain state we must - * never blindly replay it — the server may have already consumed it, and a - * replay would trigger `refresh_token_reused` and revoke the session. The - * caller should force a clean re-authentication instead. + * 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 nousRefreshIntentIsUncertain(refreshToken: string): boolean { - return readRefreshIntent(refreshToken)?.status === "uncertain"; +export function nousRefreshIntentBlocksReplay(refreshToken: string): boolean { + return readRefreshIntent(refreshToken)?.status !== undefined; } // ── Base URL hardening (review blocker #1, also flagged by multiple reviewers) ─ @@ -469,19 +477,23 @@ export async function loginNous(ctrl: OAuthController): Promise { - // Never blindly replay a token whose outcome we could not confirm earlier. - if (nousRefreshIntentIsUncertain(refreshToken)) { + // 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 uncertain prior outcome (previous rotation may not have persisted)", + "Refusing to replay a refresh token with an unconfirmed prior outcome (previous rotation may not have persisted)", { terminal: true }, ); } - // Mark that we are about to submit this token. It stays "pending" until we - // either obtain the rotated token (cleared) or confirm a server response - // while failing to persist (marked "uncertain"). - writeRefreshIntent(refreshToken, "pending"); + // 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. + writeRefreshIntent(refreshToken, "submitted"); let response: Response; try { @@ -501,7 +513,8 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna }); } catch (netErr) { // Network-level failure: the server never saw the token, so it was not - // consumed. Leave the intent "pending" so a later retry can resubmit it. + // consumed. Clear the intent so a later retry can resubmit it. + clearRefreshIntent(refreshToken); throw netErr; } @@ -513,14 +526,23 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna } // The server responded 200 — the submitted token may now be consumed. If we - // fail to parse/persist the rotated token, mark the intent uncertain so we - // never replay it. + // 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); - clearRefreshIntent(refreshToken); return creds; } catch (e) { writeRefreshIntent(refreshToken, "uncertain"); 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/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts index be5f88648f..1967f5a3e9 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; -import { identityFromNousTokens, loginNous, nousRefreshIntentIsUncertain, refreshNousToken } from "../src/oauth/nous"; +import { identityFromNousTokens, loginNous, nousRefreshIntentBlocksReplay, refreshNousToken } from "../src/oauth/nous"; import { getCredential, listAccounts, saveCredential } from "../src/oauth/store"; import type { OAuthController } from "../src/oauth/types"; @@ -459,7 +459,7 @@ describe("Nous refresh failure-atomicity + terminal errors", () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); - test("a rotated token is persisted and the intent is cleared", async () => { + 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, @@ -468,42 +468,38 @@ describe("Nous refresh failure-atomicity + terminal errors", () => { }), { status: 200 })) as typeof fetch; const cred = await refreshNousToken("old-refresh"); expect(cred.refresh).toBe("rotated-refresh"); - // Intent cleared after a successful rotation. - expect(nousRefreshIntentIsUncertain("old-refresh")).toBe(false); + // 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("an uncertain prior outcome blocks replay of the consumed token (no silent reuse)", async () => { + 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 attempt: server returns 200 but parse would fail to persist -> mark uncertain. - let firstCall = true; - globalThis.fetch = (async () => { - if (firstCall) { - firstCall = false; - return new Response(JSON.stringify({ - access_token: access, - refresh_token: "rotated-refresh", - expires_in: 3600, - }), { status: 200 }); - } - return new Response(JSON.stringify({ error: "refresh_token_reused" }), { status: 400 }); - }) as typeof fetch; - - // First refresh rotates successfully (intent cleared). + // 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", + }); + }); - // Simulate a crash that lost the rotated token: re-submit the OLD token. - // Because we cannot re-clear the intent here (store persistence is what - // clears it), emulate the uncertain state the store would leave behind. - // We re-run a refresh that reaches the server but fails to persist: to - // exercise the guard we write the uncertain intent directly via a failed - // parse path. + test("a 200 with an unparseable body marks the intent uncertain and blocks replay", async () => { + // Server returns 200 but the body is not valid JSON -> parseTokenPayload + // throws, so we mark the intent uncertain rather than clear it. globalThis.fetch = (async () => new Response("not json", { status: 200 })) as typeof fetch; - // A non-JSON 200 body makes parseTokenPayload throw, marking the intent - // uncertain; the NEXT submission of the same token must be refused. await expect(refreshNousToken("old-refresh")).rejects.toThrow(); - expect(nousRefreshIntentIsUncertain("old-refresh")).toBe(true); + expect(nousRefreshIntentBlocksReplay("old-refresh")).toBe(true); + // The next submission of the same (possibly consumed) token is refused. globalThis.fetch = (async () => new Response(JSON.stringify({ error: "refresh_token_reused" }), { status: 400 })) as typeof fetch; await expect(refreshNousToken("old-refresh")).rejects.toMatchObject({ name: "NousTokenError", @@ -511,6 +507,13 @@ describe("Nous refresh failure-atomicity + terminal errors", () => { }); }); + test("a network failure leaves the token replayable (not consumed by the server)", async () => { + globalThis.fetch = (async () => { throw new Error("network down"); }) as typeof fetch; + await expect(refreshNousToken("old-refresh")).rejects.toThrow("network down"); + // No server response -> the token was not consumed -> safe to retry. + expect(nousRefreshIntentBlocksReplay("old-refresh")).toBe(false); + }); + test("invalid_token is a terminal error that forces re-authentication", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ error: "invalid_token", From ae846e2710d5c7426fd4896eb61c0894a7a88285 Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Tue, 11 Aug 2026 02:40:17 +0200 Subject: [PATCH 07/14] fix(oauth/nous): fail-closed refresh-intent, hardened IO, shared-classifier terminal Addresses the remaining CHANGES_REQUESTED findings from Wibias on PR #1397 (head after this: fail-closed end-to-end single-use refresh recovery). 1. Refresh-intent is now FAIL-CLOSED and reuses the repo's hardened config IO: - writeRefreshIntent uses atomicWriteFile + hardenConfigDir (owner-only 0o700 dir) and THROWS on failure instead of swallowing it (refresh is refused rather than proceeding blind). readRefreshIntent treats any read/parse/permission error as 'uncertain' (replay refused), never as absent. clearNousRefreshIntent surfaces non-ENOENT failures. - Ambiguous fetch failures (timeout/abort/connection) now mark the intent 'uncertain' instead of clearing it: dispatch may have occurred, so the submitted token must never be replayed. 2. Post-persist cleanup is wired into the correct coordinator (refreshGenericAccountWithLock, the actual Nous path) after a successful mergeAccountCredential; removed the misplaced call from the xAI path. 3. Shared terminal classifier now honors NousTokenError.terminal (so provider-classified invalid_token / insufficient_scope move the account to re-authentication instead of staying retryable). 4. Opt-in live test refreshes through the production, generation-aware, account-locked coordinator (refreshGenericAccountWithLock) instead of calling refreshNousToken + mergeAccountCredential outside the lock. 5. First normal refresh-wiring test now isolates OPENCODEX_HOME so it cannot leave durable intent state in the config tree. 6. Embedded-credential URL validation no longer echoes the raw (credential- bearing) URL in the thrown error. 7. NousTokenError no longer stores live credentials as an enumerable property; only the rotated refresh token is retained, via a non-enumerable getter (getRotatedRefresh), so structured logging/serialization cannot leak it. 8. Replay-guard test now proves fetch is never called (not just the error shape). 9. Provider docs (ja/ko/zh-cn) updated to 'eight' OAuth presets to match the English/Russian sources. Verified by a real execution probe (not just mocks): rotation obtained but not persisted -> next replay refused by guard; network failure -> fail-closed uncertain (not replayable); insufficient_scope error does not leak credentials. Tests: nous-oauth 23/23 (adds fail-closed network-failure, replay-guard proves-no-fetch, non-enumerable credentials); targeted suite 195/195. tsc --noEmit and bun run privacy:scan clean. Kept draft, no maintainer-sponsored. --- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- src/oauth/index.ts | 5 +- src/oauth/nous.ts | 152 ++++++++++++++---- tests/nous-oauth-live.test.ts | 50 ++++-- tests/nous-oauth.test.ts | 44 +++-- 7 files changed, 187 insertions(+), 70 deletions(-) diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index a9c0d6432f..80cf8f64c3 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -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` モードのプロバイダーエントリを作成します。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 15d04ebf5e..b0c4fe9710 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -83,7 +83,7 @@ ChatGPT 패스스루 카탈로그에는 GPT-5.6 Sol/Terra/Luna의 네임스페 ## 2. 계정 로그인 (OAuth) -OAuth 로그인을 사용하는 프로바이더 프리셋은 일곱 개이며, 여기에 실험적 비공식 디바이스 플로우 +OAuth 로그인을 사용하는 프로바이더 프리셋은 여덟 개이며, 여기에 실험적 비공식 디바이스 플로우 브리지를 쓰는 GitHub Copilot이 추가됩니다. 자격 증명은 `~/.opencodex/auth.json`에 저장되고 자동으로 갱신됩니다. 로그인 CLI는 `chatgpt`도 받습니다. 이 명령은 ChatGPT 자격 증명을 발급받고 `forward` 모드 프로바이더 항목을 만듭니다. 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 fc9e27f8c1..ab95f7e7f1 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -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` 模式的提供商条目。 diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 39c35734f5..5589ee4d00 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -448,7 +448,7 @@ 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 ["invalid_grant","refresh_token_reused","revoked","revoked_token","expired_token"].includes(error.oauthError??""); + if(error instanceof NousTokenError)return error.terminal===true||["invalid_grant","refresh_token_reused","revoked","revoked_token","expired_token"].includes(error.oauthError??""); 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;} @@ -463,7 +463,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede ...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}), }; } -export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise{const writerGeneration=captureConfigGeneration();const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh,deps.signal),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}clearNousRefreshIntent(candidate.refresh);permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(error instanceof OAuthMutationBusyError){permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));throw error;}if(!terminal(error))throw error;const failedAt=now();permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),failedAt+XAI_PERMANENT_FAILURE_TTL_MS);sweepExpiredOnWrite(failedAt);await markAccountNeedsReauthIfGeneration(provider,accountId,generation,writerGeneration);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}} +export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise{const writerGeneration=captureConfigGeneration();const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh,deps.signal),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(error instanceof OAuthMutationBusyError){permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));throw error;}if(!terminal(error))throw error;const failedAt=now();permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),failedAt+XAI_PERMANENT_FAILURE_TTL_MS);sweepExpiredOnWrite(failedAt);await markAccountNeedsReauthIfGeneration(provider,accountId,generation,writerGeneration);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}} function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCredentials | undefined { if (stored.source !== "local-cli") return undefined; @@ -585,6 +585,7 @@ export async function refreshGenericAccountWithLock( throw new OAuthLoginRequiredError(provider); } logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId }); + if (provider === "nous") clearNousRefreshIntent(stored.refresh); return fresh.access; } catch (error) { if (error instanceof OAuthMutationBusyError) throw error; diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 998aadc651..57259e780d 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -31,10 +31,11 @@ * clean re-authentication instead of a silent session-revoking replay. */ import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +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"; @@ -82,13 +83,20 @@ interface NousJwtPayload { // 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 the server responded (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 server responded 200 but we failed to parse/persist the -// rotated token. Replay is refused. +// - "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"; @@ -97,9 +105,15 @@ interface RefreshIntent { updatedAt: number; } +class RefreshIntentIOError extends Error { + constructor(message: string, cause?: unknown) { + super(message, cause ? { cause } : undefined); + this.name = "RefreshIntentIOError"; + } +} + function refreshIntentDir(): string { - const base = join(getAuthStorePath(), "..", ".nous-refresh-intent"); - return base; + return join(getAuthStorePath(), "..", ".nous-refresh-intent"); } function refreshIntentPath(refreshToken: string): string { @@ -108,30 +122,46 @@ function refreshIntentPath(refreshToken: string): string { } function readRefreshIntent(refreshToken: string): RefreshIntent | undefined { + const path = refreshIntentPath(refreshToken); try { - const raw = readFileSync(refreshIntentPath(refreshToken), "utf8"); - return JSON.parse(raw) as RefreshIntent; - } catch { - return undefined; + // 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 JSON.parse(readFileSync(path, "utf8")) as RefreshIntent; + } 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 { - mkdirSync(dir, { recursive: true }); - writeFileSync(refreshIntentPath(refreshToken), JSON.stringify({ status, updatedAt: Date.now() } satisfies RefreshIntent), "utf8"); - } catch { - // Best-effort: if we cannot record the intent, the refresh still proceeds; - // we simply lose the uncertain-outcome guard for this single attempt. + 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 { - // ignore + } 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); } } @@ -173,16 +203,16 @@ function resolvePortalBaseUrl(): string { throw new NousTokenError(undefined, undefined, `Nous Portal base URL is not a valid URL: ${raw}`); } if (url.protocol !== "https:") { - throw new NousTokenError(undefined, undefined, `Nous Portal base URL must use HTTPS (got ${url.protocol}): ${raw}`); + 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: ${raw}`); + 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: ${raw}`); + 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: ${raw}`); + 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. @@ -239,8 +269,15 @@ function jwtGrantsInference(payload: NousJwtPayload | undefined): boolean { 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) credentials to persist before re-auth. */ - public readonly credentials?: OAuthCredentials; + /** + * 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, @@ -251,7 +288,15 @@ export class NousTokenError extends Error { super(message, options); this.name = "NousTokenError"; this.terminal = options?.terminal ?? false; - this.credentials = options?.credentials; + 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; } } @@ -477,6 +522,11 @@ export async function loginNous(ctrl: OAuthController): 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 @@ -493,11 +543,21 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna // 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. - writeRefreshIntent(refreshToken, "submitted"); + // Fail-closed: if we cannot durably record the intent, refuse the refresh. + try { + writeRefreshIntent(refreshToken, "submitted"); + } catch (ioErr) { + throw new NousTokenError( + undefined, + "refresh_intent_io", + "Refusing refresh: could not durably record the refresh-intent guard (fail-closed)", + { terminal: true, cause: ioErr }, + ); + } let response: Response; try { - response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/token`, { + response = await fetch(`${baseUrl}/api/oauth/token`, { method: "POST", headers: { Accept: "application/json", @@ -512,16 +572,34 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna signal: requestSignal(signal), }); } catch (netErr) { - // Network-level failure: the server never saw the token, so it was not - // consumed. Clear the intent so a later retry can resubmit it. - clearRefreshIntent(refreshToken); + // 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) { // Error before any rotation: the token was not consumed. Clear the intent - // so a retry can resubmit it. - clearRefreshIntent(refreshToken); + // so a retry can resubmit it. Fail-closed: if clearing fails, surface it. + try { + clearRefreshIntent(refreshToken); + } catch (ioErr) { + throw new NousTokenError( + undefined, + "refresh_intent_io", + "Refresh failed and the refresh-intent could not be cleared for safe retry", + { terminal: true, cause: ioErr }, + ); + } throw tokenErrorFromPayload(response.status, await response.json().catch(() => ({}))); } @@ -533,7 +611,11 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna const creds = parseTokenPayload((await response.json()) as NousTokenResponse, refreshToken); return creds; } catch (e) { - writeRefreshIntent(refreshToken, "uncertain"); + try { + writeRefreshIntent(refreshToken, "uncertain"); + } catch { + // already throwing the parse error below; do not mask it + } throw e; } } diff --git a/tests/nous-oauth-live.test.ts b/tests/nous-oauth-live.test.ts index 28920d9341..56b7298890 100644 --- a/tests/nous-oauth-live.test.ts +++ b/tests/nous-oauth-live.test.ts @@ -10,15 +10,19 @@ * - 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. - * - This test REFRESHES and then PERSISTS the rotated token back through the - * same `mergeAccountCredential` path production uses, so the local session - * stays valid (it is not destructive — review blocker #1). + * - 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, mergeAccountCredential } from "../src/oauth/store"; +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"; @@ -31,6 +35,13 @@ 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 = { + 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"); @@ -42,23 +53,28 @@ describe.skipIf(!LIVE)("Nous Portal live verification (opt-in, no key shared)", len("stored.refresh", stored!.refresh); len("stored.accountId", stored!.accountId); - // Refresh against the production Portal. Tokens are read back but redacted. - const refreshed = await refreshNousToken(stored!.refresh); - len("refreshed.access", refreshed.access); - len("refreshed.refresh", refreshed.refresh); - expect(refreshed.access.length).toBeGreaterThan(0); - expect(refreshed.refresh.length).toBeGreaterThan(0); - // Rotation must have produced a different refresh token (single-use contract). - expect(refreshed.refresh).not.toBe(stored!.refresh); + // 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); - // Persist the rotation through the production path so the local session - // stays valid (non-destructive). - const result = await mergeAccountCredential("nous", refreshed.accountId ?? stored!.accountId!, refreshed); - console.log(`[live] rotated token persisted (superseded=${"superseded" in result})`); + // 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 ${refreshed.access}` }, + headers: { Authorization: `Bearer ${access}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as unknown; diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts index 1967f5a3e9..e39aac265e 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -47,13 +47,23 @@ describe("Nous token-response wiring", () => { 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 () => { @@ -260,7 +270,7 @@ describe("Nous Portal base URL hardening", () => { try { const ctrl: OAuthController = { onAuth() {} }; await expect(loginNous(ctrl)).rejects.toThrow(/must use HTTPS/); - await expect(refreshNousToken("old-refresh")).rejects.toThrow(/must use HTTPS/); + await expect(refreshNousToken("hardening-refresh")).rejects.toThrow(/must use HTTPS/); expect(fetchCalled).toBe(false); } finally { globalThis.fetch = realFetch; @@ -277,7 +287,7 @@ describe("Nous Portal base URL hardening", () => { return new Response("{}", { status: 200 }); }) as typeof fetch; try { - await expect(refreshNousToken("old-refresh")).rejects.toThrow(/not a valid URL/); + await expect(refreshNousToken("hardening-refresh")).rejects.toThrow(/not a valid URL/); expect(fetchCalled).toBe(false); } finally { globalThis.fetch = realFetch; @@ -299,7 +309,7 @@ describe("Nous Portal base URL hardening", () => { return new Response("{}", { status: 200 }); }) as typeof fetch; try { - await expect(refreshNousToken("old-refresh")).rejects.toThrow(/base URL/); + await expect(refreshNousToken("hardening-refresh")).rejects.toThrow(/base URL/); expect(fetchCalled).toBe(false); } finally { globalThis.fetch = realFetch; @@ -492,26 +502,30 @@ describe("Nous refresh failure-atomicity + terminal errors", () => { }); }); - test("a 200 with an unparseable body marks the intent uncertain and blocks replay", async () => { + 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. - globalThis.fetch = (async () => new Response("not json", { status: 200 })) as typeof fetch; + 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. - globalThis.fetch = (async () => new Response(JSON.stringify({ error: "refresh_token_reused" }), { status: 400 })) as typeof fetch; + // 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 token replayable (not consumed by the server)", async () => { + 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"); - // No server response -> the token was not consumed -> safe to retry. - expect(nousRefreshIntentBlocksReplay("old-refresh")).toBe(false); + // 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 () => { @@ -549,8 +563,12 @@ describe("Nous refresh failure-atomicity + terminal errors", () => { 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 so the caller can persist - // it and drive a clean re-auth without discarding the rotation. - expect((err as { credentials?: { refresh?: string } }).credentials?.refresh).toBe("rotated-refresh"); + // 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"); }); }); From 2efcf4b90bd86fa8fa2ff62f891a9426b24750c1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:35:03 +0200 Subject: [PATCH 08/14] fix(oauth/nous): fail-closed refresh-intent schema, atomic HTTP failure, non-terminal local IO - Validate persisted refresh-intent schema; corrupt/unknown state is treated as uncertain (replay refused), never absent. Only ENOENT means no intent. - Classify HTTP refresh failures atomically: ambiguous 5xx/gateway responses leave the submitted token blocked (uncertain); only definitive 4xx client rejections clear the intent for a safe retry. - Surface local durable-write/read/cleanup failures as a non-terminal RefreshIntentIOError so the coordinator does not mark a valid credential needsReauth for broken local persistence. - Mark device-flow access_denied/expired_token as terminal consistently. - Handle non-JSON successful device-code bodies with the clear validation error instead of a raw JSON parse leak. - Redact raw values from malformed base-URL diagnostics. - Align the opaque-token docstring with the JWT scope gate. - Synchronize OAuth provider lists across en/ja/ko/ru/zh-cn docs. - Add regression coverage for all safety contracts. --- .../src/content/docs/guides/providers.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- src/oauth/index.ts | 6 +- src/oauth/nous.ts | 96 ++++++-- tests/nous-oauth.test.ts | 223 +++++++++++++++++- tests/oauth-refresh.test.ts | 30 +++ 8 files changed, 337 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 725b3e8339..a7540e1cb3 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 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 80cf8f64c3..425171f7e0 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、Nous Portal。 | +| `oauth` | 保存された OAuth アクセストークンを読み込み bearer キーとして使い、期限切れ前に自動更新します。 | xAI、Anthropic、Kimi、Kiro、Google Antigravity、Cursor、GitHub Copilot、Nous Portal。 | [`retryOn429`](/ja/reference/configuration/)(同一キーでの 429 リトライ)は API キー プロバイダー (`authMode: "key"`)のみに適用されます。OAuth・forward・ローカル プリセットは除外されます — diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index b0c4fe9710..ddeaf5f836 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, Nous Portal. | +| `oauth` | 저장된 OAuth 액세스 토큰을 불러와 bearer 키로 사용하며, 만료 전에 자동 갱신합니다. | xAI, Anthropic, Kimi, Kiro, Google Antigravity, Cursor, GitHub Copilot, Nous Portal. | [`retryOn429`](/ko/reference/configuration/)(동일 키 429 재시도)는 API 키 프로바이더 (`authMode: "key"`)에만 적용됩니다. OAuth·forward·로컬 프리셋은 제외됩니다 — 같은 토큰을 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 ab95f7e7f1..d23bbd75c5 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、Nous Portal。 | +| `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 绝不可重放,本地运行时 diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 5589ee4d00..1ef6900075 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -8,7 +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 } from "./nous"; +import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous"; import { loginChatGPT, refreshChatGPTToken } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; @@ -449,6 +449,10 @@ function terminal(error:unknown):boolean{ 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;} diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 57259e780d..5544693054 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -105,7 +105,7 @@ interface RefreshIntent { updatedAt: number; } -class RefreshIntentIOError extends Error { +export class RefreshIntentIOError extends Error { constructor(message: string, cause?: unknown) { super(message, cause ? { cause } : undefined); this.name = "RefreshIntentIOError"; @@ -121,6 +121,36 @@ function refreshIntentPath(refreshToken: string): string { 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 { @@ -128,7 +158,7 @@ function readRefreshIntent(refreshToken: string): RefreshIntent | undefined { // before reading, and treat any read/parse error as uncertain (fail-closed) // rather than silently absent. hardenExistingSecret(path); - return JSON.parse(readFileSync(path, "utf8")) as RefreshIntent; + 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") { @@ -200,7 +230,9 @@ function resolvePortalBaseUrl(): string { try { url = new URL(raw); } catch { - throw new NousTokenError(undefined, undefined, `Nous Portal base URL is not a valid URL: ${raw}`); + // 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})`); @@ -237,8 +269,9 @@ function nonEmptyString(value: unknown): string | 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 tokens yield no identity (account still - * works, single-account only). + * 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); @@ -429,7 +462,11 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{ signal: requestSignal(signal), }); if (!response.ok) throw tokenErrorFromPayload(response.status, await response.json().catch(() => ({}))); - const payload = (await response.json()) as NousDeviceAuthorizationResponse; + // 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); @@ -486,8 +523,12 @@ async function pollForToken( await sleep(waitMs, signal); continue; } - if (error === "expired_token") throw new NousTokenError(response.status, "expired_token", "Nous Portal device authorization expired"); - if (error === "access_denied") throw new NousTokenError(response.status, "access_denied", "Nous Portal device authorization denied"); + 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) { @@ -547,11 +588,13 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna try { writeRefreshIntent(refreshToken, "submitted"); } catch (ioErr) { - throw new NousTokenError( - undefined, - "refresh_intent_io", + // 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)", - { terminal: true, cause: ioErr }, + ioErr, ); } @@ -588,19 +631,34 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna } if (!response.ok) { - // Error before any rotation: the token was not consumed. Clear the intent - // so a retry can resubmit it. Fail-closed: if clearing fails, surface it. + const status = response.status; + const payload = await response.json().catch(() => ({})); + if (status >= 500) { + // Ambiguous server/gateway/backend failure (5xx): the request may have + // reached the Portal and rotated the token before the error was returned. + // We cannot prove non-consumption, so leave the submitted token BLOCKED + // as uncertain — never clear the intent and make it replayable. + 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); + } + // A 4xx client rejection definitively proves the presented token was NOT + // consumed (the server rejected the grant without rotating it). Clear the + // intent so a later retry can resubmit the still-valid token. Fail-closed: + // if clearing fails, surface an operational error rather than clearing blind. try { clearRefreshIntent(refreshToken); } catch (ioErr) { - throw new NousTokenError( - undefined, - "refresh_intent_io", + throw new RefreshIntentIOError( "Refresh failed and the refresh-intent could not be cleared for safe retry", - { terminal: true, cause: ioErr }, + ioErr, ); } - throw tokenErrorFromPayload(response.status, await response.json().catch(() => ({}))); + throw tokenErrorFromPayload(status, payload); } // The server responded 200 — the submitted token may now be consumed. If we diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts index e39aac265e..733e813bb5 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { identityFromNousTokens, loginNous, nousRefreshIntentBlocksReplay, refreshNousToken } from "../src/oauth/nous"; +import { identityFromNousTokens, loginNous, nousRefreshIntentBlocksReplay, refreshNousToken, RefreshIntentIOError } from "../src/oauth/nous"; import { getCredential, listAccounts, saveCredential } from "../src/oauth/store"; import type { OAuthController } from "../src/oauth/types"; @@ -25,6 +26,17 @@ function jwtPayloadOf(token: string): Record { 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 }); @@ -182,6 +194,7 @@ describe("Nous device-flow error handling", () => { 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 () => { @@ -199,6 +212,7 @@ describe("Nous device-flow error handling", () => { 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 () => { @@ -242,6 +256,15 @@ describe("Nous device-flow error handling", () => { 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", () => { @@ -317,6 +340,37 @@ describe("Nous Portal base URL hardening", () => { } } }); + + 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; + } + }); }); describe("Nous refresh token safety", () => { @@ -572,3 +626,168 @@ describe("Nous refresh failure-atomicity + terminal errors", () => { 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("a definitively safe 4xx client rejection clears the intent so a retry may resubmit", async () => { + const token = "http-4xx-token"; + const access = jwtWithClaims({ sub: "wired-user", exp: Math.floor(Date.now() / 1000) + 3600 }); + // Server returns a definitive 400 invalid_request (token not consumed). + globalThis.fetch = (async () => new Response(JSON.stringify({ + error: "invalid_request", + error_description: "malformed request", + }), { status: 400 })) as typeof fetch; + + await expect(refreshNousToken(token)).rejects.toThrow(); + // The 4xx proves non-consumption: the intent is cleared, so a later retry + // with the still-valid token is not blocked. + expect(nousRefreshIntentBlocksReplay(token)).toBe(false); + }); + + test("a durable-write failure before dispatch throws a non-terminal RefreshIntentIOError (no fetch)", async () => { + const token = "io-fail-token"; + // Make the refresh-intent directory uncreatable: plant a FILE where the + // directory should be so mkdirSync(...) fails. + const intentDir = join(TEST_DIR, ".nous-refresh-intent"); + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(intentDir, "not a directory", "utf8"); + + let fetchCalled = 0; + globalThis.fetch = (async () => { + fetchCalled += 1; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + + let err: unknown; + try { + await refreshNousToken(token); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(RefreshIntentIOError); + expect(fetchCalled).toBe(0); + }); +}); diff --git a/tests/oauth-refresh.test.ts b/tests/oauth-refresh.test.ts index 3b677d94b3..cb1a712fd1 100644 --- a/tests/oauth-refresh.test.ts +++ b/tests/oauth-refresh.test.ts @@ -4,6 +4,7 @@ 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 } from "../src/oauth/nous"; import { AnthropicTokenError } from "../src/oauth/anthropic"; import { credentialGeneration, getAccountCredential, getAccountSet, getAuthRefreshIntentPath, getCredential, markAccountNeedsReauth, readOAuthRefreshIntent, saveCredential, writeOAuthRefreshIntent } from "../src/oauth/store"; @@ -751,4 +752,33 @@ 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)!; + + // Break the refresh-intent directory: the config dir is tmp/ocx, and the + // intent dir is configDir/.nous-refresh-intent. Plant a FILE at that path so + // mkdirSync(...) fails before any network dispatch. + const intentDir = join(process.env.OPENCODEX_HOME!, ".nous-refresh-intent"); + mkdirSync(process.env.OPENCODEX_HOME!, { recursive: true }); + writeFileSync(intentDir, "not a directory", "utf8"); + + let fetchCalled = 0; + globalThis.fetch = (async () => { + fetchCalled += 1; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + + // The refresh aborts with a non-terminal operational error (not + // OAuthLoginRequiredError), fetch is never called, and the account is NOT + // marked needsReauth — local persistence breakage is not credential death. + 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(); + }); }); From bbc3758089275d7ceeaa6c892c458ee6af8a0720 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:24:07 +0200 Subject: [PATCH 09/14] test(oauth/nous): cover origin-only base URL normalization --- tests/nous-oauth.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts index 733e813bb5..fc0184d9f7 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -371,6 +371,25 @@ describe("Nous Portal base URL hardening", () => { 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", () => { From e10953be3768d685b192266b36eb636262532e28 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:09:42 +0200 Subject: [PATCH 10/14] test(oauth/nous): make intent-write failure tests platform-independent Planting a file at the intent-directory path made the guard read fail with ENOTDIR on Linux (treated as uncertain -> terminal) before any write could fail, so the test could not reach the non-terminal operational-error path. Force atomicWriteFile to fail via a spy instead, deterministically on every platform: the pre-dispatch write abort must surface RefreshIntentIOError, never call fetch, and leave the account valid. --- tests/nous-oauth.test.ts | 25 ++++++++++++------------- tests/oauth-refresh.test.ts | 33 ++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts index fc0184d9f7..81e59c6bdd 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -1,10 +1,11 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +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 { 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"; @@ -788,25 +789,23 @@ describe("Nous HTTP refresh failure-atomicity classification", () => { test("a durable-write failure before dispatch throws a non-terminal RefreshIntentIOError (no fetch)", async () => { const token = "io-fail-token"; - // Make the refresh-intent directory uncreatable: plant a FILE where the - // directory should be so mkdirSync(...) fails. - const intentDir = join(TEST_DIR, ".nous-refresh-intent"); - mkdirSync(TEST_DIR, { recursive: true }); - writeFileSync(intentDir, "not a directory", "utf8"); - let fetchCalled = 0; globalThis.fetch = (async () => { fetchCalled += 1; return new Response("{}", { status: 200 }); }) as typeof fetch; - let err: unknown; + // 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 refreshNousToken(token); - } catch (e) { - err = e; + await expect(refreshNousToken(token)).rejects.toBeInstanceOf(RefreshIntentIOError); + expect(fetchCalled).toBe(0); + } finally { + writeSpy.mockRestore(); } - expect(err).toBeInstanceOf(RefreshIntentIOError); - expect(fetchCalled).toBe(0); }); }); diff --git a/tests/oauth-refresh.test.ts b/tests/oauth-refresh.test.ts index cb1a712fd1..6b706c8c4e 100644 --- a/tests/oauth-refresh.test.ts +++ b/tests/oauth-refresh.test.ts @@ -7,6 +7,7 @@ import { getValidAccessToken, getValidAccessTokenForAccount, OAuthLoginRequiredE import { RefreshIntentIOError } 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 configModule from "../src/config"; const origHome = process.env.HOME; const origLocalAppData = process.env.LOCALAPPDATA; @@ -759,26 +760,28 @@ describe("oauth refresh hardening", () => { const id = getAccountSet("nous")!.activeAccountId; const credential = getAccountCredential("nous", id)!; - // Break the refresh-intent directory: the config dir is tmp/ocx, and the - // intent dir is configDir/.nous-refresh-intent. Plant a FILE at that path so - // mkdirSync(...) fails before any network dispatch. - const intentDir = join(process.env.OPENCODEX_HOME!, ".nous-refresh-intent"); - mkdirSync(process.env.OPENCODEX_HOME!, { recursive: true }); - writeFileSync(intentDir, "not a directory", "utf8"); - let fetchCalled = 0; globalThis.fetch = (async () => { fetchCalled += 1; return new Response("{}", { status: 200 }); }) as typeof fetch; - // The refresh aborts with a non-terminal operational error (not - // OAuthLoginRequiredError), fetch is never called, and the account is NOT - // marked needsReauth — local persistence breakage is not credential death. - 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(); + // 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(); + } }); }); From 2efb345053bca238af1a93338ae1b67185d36f8d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:30:54 +0200 Subject: [PATCH 11/14] fix(oauth/nous): fail closed on every ambiguous post-dispatch refresh outcome A non-2xx response does not prove the single-use refresh token was not consumed: 429 rate limits, unknown/custom 4xx, and gateway-generated client-class errors can be returned after the remote side already processed the token. Previously every 4xx cleared the durable refresh intent, which made a possibly-consumed RT-A locally replayable. Now every post-dispatch non-2xx response retains the intent as uncertain (previously only 5xx did), so the submitted token stays blocked and a later refresh is rejected before any fetch. The intent is cleared only after the rotated credential is durably persisted. Pre-dispatch local I/O failures remain distinct non-terminal operational errors. Replace the invented 'safe 4xx' test with regressions proving HTTP 429 and an unknown/custom 4xx both keep the old token blocked and reject a second attempt before fetch (exactly one token-endpoint call). --- src/oauth/nous.ts | 51 ++++++++++++++++++---------------------- tests/nous-oauth.test.ts | 50 ++++++++++++++++++++++++++++++--------- 2 files changed, 62 insertions(+), 39 deletions(-) diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 5544693054..1aea544ae8 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -28,7 +28,10 @@ * 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. + * 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"; @@ -557,10 +560,12 @@ export async function loginNous(ctrl: OAuthController): Promise { // Validate the OAuth base URL first (independent of the token): a malformed @@ -633,30 +638,20 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna if (!response.ok) { const status = response.status; const payload = await response.json().catch(() => ({})); - if (status >= 500) { - // Ambiguous server/gateway/backend failure (5xx): the request may have - // reached the Portal and rotated the token before the error was returned. - // We cannot prove non-consumption, so leave the submitted token BLOCKED - // as uncertain — never clear the intent and make it replayable. - 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); - } - // A 4xx client rejection definitively proves the presented token was NOT - // consumed (the server rejected the grant without rotating it). Clear the - // intent so a later retry can resubmit the still-valid token. Fail-closed: - // if clearing fails, surface an operational error rather than clearing blind. + // 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 { - clearRefreshIntent(refreshToken); - } catch (ioErr) { - throw new RefreshIntentIOError( - "Refresh failed and the refresh-intent could not be cleared for safe retry", - ioErr, - ); + 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); } diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts index 81e59c6bdd..4de7044a10 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -772,19 +772,47 @@ describe("Nous HTTP refresh failure-atomicity classification", () => { expect(fetchCalls).toBe(1); // only the first (5xx) call ever hit the network }); - test("a definitively safe 4xx client rejection clears the intent so a retry may resubmit", async () => { - const token = "http-4xx-token"; - const access = jwtWithClaims({ sub: "wired-user", exp: Math.floor(Date.now() / 1000) + 3600 }); - // Server returns a definitive 400 invalid_request (token not consumed). - globalThis.fetch = (async () => new Response(JSON.stringify({ - error: "invalid_request", - error_description: "malformed request", - }), { status: 400 })) as typeof fetch; + 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(); - // The 4xx proves non-consumption: the intent is cleared, so a later retry - // with the still-valid token is not blocked. - expect(nousRefreshIntentBlocksReplay(token)).toBe(false); + 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 () => { From 8e08936088ccc358916bd0bc905b6e9f2e16f097 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:01:06 +0200 Subject: [PATCH 12/14] fix(oauth/nous): post-persist intent cleanup is best-effort; docs/live-test/modelDiscovery cleanups - refreshGenericAccountWithLock: a failure to unlink the old-token refresh- intent file after mergeAccountCredential commits the rotation no longer fails the refresh or marks the account needsReauth. The stale intent keys the old token (no longer stored), so retaining it is safe; the failure is logged non-fatally with no credential material. - Add coordinator-level regressions: the happy path persists RT-B and clears the RT-A intent (nousRefreshIntentBlocksReplay(RT-A) === false), and a forced cleanup failure still resolves with the fresh access token while the stored credential stays RT-B and the account is not marked needsReauth. - Add the provider-level clear-after-persist regression in nous-oauth.test.ts. - English providers doc: after a terminal Nous refresh failure, run 'ocx login nous' to reauthenticate. - Live test: correct the privacy wording (opt-in; credentials go only to the intended Nous endpoints; token values never printed) and parse the live catalog defensively so malformed bodies yield an empty list instead of a crash. - Nous registry modelDiscovery: use path 'models' (resolves against effectiveBaseUrl to the same canonical /v1/models endpoint). --- .../src/content/docs/guides/providers.md | 2 + src/oauth/index.ts | 18 ++++- src/providers/registry.ts | 4 +- tests/nous-oauth-live.test.ts | 30 ++++++--- tests/nous-oauth.test.ts | 19 +++++- tests/oauth-refresh.test.ts | 67 ++++++++++++++++++- 6 files changed, 128 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a7540e1cb3..f296379501 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -119,6 +119,8 @@ ocx logout | `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/src/oauth/index.ts b/src/oauth/index.ts index 1ef6900075..4e315733eb 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -589,7 +589,23 @@ export async function refreshGenericAccountWithLock( throw new OAuthLoginRequiredError(provider); } logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId }); - if (provider === "nous") clearNousRefreshIntent(stored.refresh); + // 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; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 8440e87ced..1b673d01e5 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1090,7 +1090,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ 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: { - url: "https://inference-api.nousresearch.com/v1/models", + // 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, }, diff --git a/tests/nous-oauth-live.test.ts b/tests/nous-oauth-live.test.ts index 56b7298890..5635719b92 100644 --- a/tests/nous-oauth-live.test.ts +++ b/tests/nous-oauth-live.test.ts @@ -1,10 +1,12 @@ /** * Opt-in, NON-DESTRUCTIVE live verification for the Nous Portal provider. * - * This file is skipped unless `NOUS_LIVE_TEST=1` is set, so it never runs in - * CI and no credential ever travels off the local machine. It exists to let a - * reviewer (or the author) prove the real-account refresh path and live - * catalog discovery against the production Portal. + * 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 @@ -78,10 +80,22 @@ describe.skipIf(!LIVE)("Nous Portal live verification (opt-in, no key shared)", }); expect(res.status).toBe(200); const body = (await res.json()) as unknown; - const models = Array.isArray(body) - ? (body as Array<{ id?: string }>) - : ((body as { data?: Array<{ id?: string }> }).data ?? []); - const ids = models.map((m) => m.id).filter(Boolean) as string[]; + // 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; + return typeof id === "string" ? [id] : []; + }); 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 index 4de7044a10..c21ea1774d 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -2,7 +2,7 @@ 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 { identityFromNousTokens, loginNous, nousRefreshIntentBlocksReplay, refreshNousToken, RefreshIntentIOError } from "../src/oauth/nous"; +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"; @@ -557,6 +557,23 @@ describe("Nous refresh failure-atomicity + terminal errors", () => { 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"). diff --git a/tests/oauth-refresh.test.ts b/tests/oauth-refresh.test.ts index 6b706c8c4e..32aabb7c79 100644 --- a/tests/oauth-refresh.test.ts +++ b/tests/oauth-refresh.test.ts @@ -4,7 +4,8 @@ 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 } from "../src/oauth/nous"; +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 configModule from "../src/config"; @@ -784,4 +785,68 @@ describe("oauth refresh hardening", () => { writeSpy.mockRestore(); } }); + + function nousAccessJwt(sub: string): 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: "inference:invoke", + })).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(); + } + }); }); From 2a77b66ef068a074812e73b18cdf4a2ca367aa73 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:14:22 +0200 Subject: [PATCH 13/14] docs(providers): sync Nous Portal details across ja/ko/ru/zh-cn Add the missing ocx login nous command, the full ous provider table row (openai-chat adapter, inference endpoint, device-grant login, per-request inference JWT, live paid/:free discovery, single-use rotated refresh tokens), and the terminal-refresh reauthentication instruction to each translated provider guide, matching the English source. --- docs-site/src/content/docs/ja/guides/providers.md | 4 ++++ docs-site/src/content/docs/ko/guides/providers.md | 4 ++++ docs-site/src/content/docs/ru/guides/providers.md | 2 ++ docs-site/src/content/docs/zh-cn/guides/providers.md | 4 ++++ 4 files changed, 14 insertions(+) diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 425171f7e0..3a93aa623e 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -93,6 +93,7 @@ OAuth ログインを使うプロバイダープリセットは 8 つで、こ 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 ddeaf5f836..fc7a8cdea4 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -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 3714c8dc7e..3ad522b910 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -123,6 +123,8 @@ ocx logout | `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 d23bbd75c5..f4aa3dbb4b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -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。 From de819e6c4cbfae5ab6a86d1b3b03c4d56a4aac9e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:42:50 +0200 Subject: [PATCH 14/14] fix(oauth/nous): preserve rotated RT-B on terminal refresh errors; trim live-test model ids - refreshGenericAccountWithLock: when a terminal NousTokenError carries an already-issued rotated refresh token (e.g. access JWT lacks inference:invoke), persist RT-B generation-safely before forcing reauthentication. The unusable access token is never persisted as valid (empty placeholder, past expiry); RT-A's intent is cleared only after RT-B is durable (best-effort cleanup); persistence failure or a superseding concurrent generation never clears RT-A intent and never overwrites the newer credential; the account is marked needsReauth generation-safely and the caller receives OAuthLoginRequiredError. - Live catalog test: reject empty/whitespace-only model ids (trim before accept). - Coordinator regressions: RT-B preservation on insufficient_scope, RT-B persistence failure keeps RT-A intent blocking, superseded concurrent generation is not overwritten, cleanup failure after RT-B persistence keeps RT-B and marks needsReauth. --- src/oauth/index.ts | 74 +++++++++++++++++++ tests/nous-oauth-live.test.ts | 6 +- tests/oauth-refresh.test.ts | 131 +++++++++++++++++++++++++++++++++- 3 files changed, 208 insertions(+), 3 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 4e315733eb..116a67f082 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -476,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, @@ -610,6 +643,47 @@ export async function refreshGenericAccountWithLock( } 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/tests/nous-oauth-live.test.ts b/tests/nous-oauth-live.test.ts index 5635719b92..51b961b67a 100644 --- a/tests/nous-oauth-live.test.ts +++ b/tests/nous-oauth-live.test.ts @@ -94,7 +94,11 @@ describe.skipIf(!LIVE)("Nous Portal live verification (opt-in, no key shared)", const ids = models.flatMap((model) => { if (typeof model !== "object" || model === null) return []; const id = (model as { id?: unknown }).id; - return typeof id === "string" ? [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); diff --git a/tests/oauth-refresh.test.ts b/tests/oauth-refresh.test.ts index 32aabb7c79..d270901c8a 100644 --- a/tests/oauth-refresh.test.ts +++ b/tests/oauth-refresh.test.ts @@ -8,6 +8,7 @@ import { RefreshIntentIOError, nousRefreshIntentBlocksReplay } from "../src/oaut 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; @@ -786,12 +787,12 @@ describe("oauth refresh hardening", () => { } }); - function nousAccessJwt(sub: string): string { + 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: "inference:invoke", + scope, })).toString("base64url"); return `${header}.${payload}.sig`; } @@ -849,4 +850,130 @@ describe("oauth refresh hardening", () => { 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(); + } + }); });