From 6c57eded41ed6722a2a3ae1821bc9e2922512bfb Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Mon, 10 Aug 2026 05:56:15 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(providers):=20add=20Nous=20Portal=20(N?= =?UTF-8?q?ous=20Research)=20OAuth=20provider=20=E2=80=94=20device=20grant?= =?UTF-8?q?=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 917645092..81e99cbd0 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 607c7d5a8..6a2b0248e 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 219cdaa8e..e8d7b27ae 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 7389f7931..8017be65f 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 cb5a48a80..d8e85c538 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 6b6d027f2..58ec121f1 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 000000000..799c3b4bb --- /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 1598997eb..1ebbad0ae 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1052,6 +1052,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 000000000..7992df74f --- /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 049f05157..9e7d3e394 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 4e3e8ee94f6eee514c2477298c1d21c09d44b1af Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Mon, 10 Aug 2026 06:12:13 +0200 Subject: [PATCH 2/4] 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 1ebbad0ae..c57928cff 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1060,7 +1060,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", @@ -1072,12 +1078,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 6880f94f4c599063d6233a2a31cb62e35408e4d9 Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Mon, 10 Aug 2026 06:47:00 +0200 Subject: [PATCH 3/4] 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 7992df74f..407117831 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 e9d9d9ebe109629d0f5770d83265d789ae1a4133 Mon Sep 17 00:00:00 2001 From: Cheurteenyt Date: Mon, 10 Aug 2026 17:23:34 +0200 Subject: [PATCH 4/4] 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 8017be65f..38b1cce9f 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 799c3b4bb..007e2862e 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 000000000..f04d39378 --- /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 407117831..515a3fa74 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"); }); });