diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 378dc215d2..5efac1c250 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -434,6 +434,13 @@ URL de base modifiée ressemblant à l'original n'est jamais sondée. Les crédi gratuits restants sont affichés sous forme de fenêtre en USD lorsque Command Code signale également les dépenses de la période. +Lors de la connexion à OrcaRouter par navigateur (`ocx login orcarouter-oauth`), le corps d’une +réponse réussie à l’échange de clé doit être un JSON UTF-8 valide d’au plus 64 KiB. Le délai existant +de 30 secondes pour cette requête couvre les en-têtes et la réception complète du corps ; tout corps +trop volumineux ou mal formé est rejeté avant l’enregistrement de la clé. Ces limites concernent +uniquement l’échange de clé à la connexion, pas les données des requêtes d’inférence. La validation +de `scope` reste inchangée : son absence est autorisée, mais une valeur explicitement invalide est rejetée. + **Découverte SambaNova Cloud.** Le préréglage lit la liste publique `/v1/models` de SambaNova Cloud depuis l'hôte API fixe, préserve les identifiants natifs du fournisseur et limite la découverte à 128 KiB et 128 lignes brutes. Le catalogue n'étant pas authentifié, le parcours de connexion de la CLI signale que la clé ne diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 94c006e724..73017fcd60 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -605,6 +605,13 @@ Both modes route to `https://api.orcarouter.ai/v1` and discover the public live whether Codex offers image attachments. Because the catalog itself is public, manual key setup reports validation as unknown instead of accepting that response as proof that the key works. +During OrcaRouter browser sign-in (`ocx login orcarouter-oauth`), a successful key-exchange +response body must be valid UTF-8 JSON no larger than 64 KiB. The request's existing 30-second +budget covers both the response headers and the full body; oversized or malformed bodies are +rejected before the key is saved. These limits apply only to the login key exchange, not inference +request payloads. Scope validation is unchanged: an omitted `scope` is allowed, while an explicitly +invalid `scope` is rejected. + For a one-origin self-hosted deployment, set the shared origin before the first PKCE login; the saved inference URL is derived from the same origin: diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 7b44ceb479..f7ed7ecf9a 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -289,6 +289,13 @@ discovery エンドポイントから取得します。チャットリクエス **Command Code の quota:** ダッシュボードと `ocx account refresh` は、正規ホスト `https://api.commandcode.ai` 上の `/alpha/billing/credits` ウィンドウ(5時間と週次)を照会します。OAuth プリセット (`command-code`) は保存済みアカウント bearer を使い、Provider-API キープリセット (`commandcode`) は設定済みの有効キーを使います。ユーザーが編集した類似ホストは照会しません。期間支出が返る場合は、残りの monthly / purchased / free credits を USD ウィンドウとして表示します。 +OrcaRouter のブラウザーログイン(`ocx login orcarouter-oauth`)では、キー交換の成功応答本文は +64 KiB 以下の有効な UTF-8 JSON である必要があります。このキー交換リクエストの既存の +30 秒制限には、応答ヘッダーと本文全体の受信が含まれ、サイズ超過または不正な本文はキーの +保存前に拒否されます。この制限はログイン時のキー交換にのみ適用され、推論リクエストの +ペイロードを制限するものではありません。`scope` の検証規則は変わらず、省略は許可され、 +明示された不正な値は拒否されます。 + **SambaNova Cloud の discovery:** preset は固定 API ホスト上の SambaNova Cloud の公開 `/v1/models` 一覧を読み、 プロバイダー固有の ID を保持し、discovery を 128 KiB と raw 128 行に制限します。カタログは認証不要のため、 CLI の login flow は公開レスポンスをキーの有効性の証拠にせず、キーを検証不能として報告します。chat リクエストは diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index af01c46b7f..873de7e631 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -286,6 +286,12 @@ CLI 사용자는 `~/.commandcode/auth.json`의 로컬 CLI 자격 증명을 가 **Command Code 할당량:** 대시보드와 `ocx account refresh`는 정규 호스트 `https://api.commandcode.ai`에서 `/alpha/billing/credits` 창(5시간 및 주간)을 조회합니다. OAuth 프리셋(`command-code`)은 저장된 계정 bearer를 사용하고, Provider-API 키 프리셋(`commandcode`)은 현재 설정된 활성 키를 사용합니다. 사용자가 바꾼 유사 base URL은 조회하지 않습니다. Command Code가 기간 사용량을 함께 반환하면 남은 monthly / purchased / free credits가 USD 창으로 표시됩니다. +OrcaRouter 브라우저 로그인(`ocx login orcarouter-oauth`)의 키 교환 성공 응답 본문은 64 KiB 이하의 +유효한 UTF-8 JSON이어야 합니다. 해당 키 교환 요청의 기존 30초 제한은 응답 헤더와 전체 본문 수신을 +포함하며, 크기를 초과하거나 형식이 잘못된 본문은 키를 저장하기 전에 거부합니다. 이 제한은 로그인 +키 교환에만 적용되며 추론 요청 본문의 제한이 아닙니다. `scope` 검사는 그대로 유지되어 생략은 +허용하고 명시적으로 잘못된 값은 거부합니다. + **SambaNova Cloud 검색:** 프리셋은 고정 API 호스트의 SambaNova Cloud 공개 `/v1/models` 목록을 읽고, 프로바이더 네이티브 ID를 보존하며 discovery를 128 KiB와 raw 행 128개로 제한합니다. 카탈로그에는 인증이 필요하지 않으므로 CLI 로그인 흐름은 공개 응답을 키 유효성의 증거로 사용하지 않고 키를 검증할 수 없는 것으로 보고합니다. chat 요청은 diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index f05e1a3394..fdcaf3cbac 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -322,6 +322,13 @@ Service token Nscale создаётся в [Nscale Console](https://console.nsca **Квота Command Code.** Дашборд и `ocx account refresh` опрашивают окна `/alpha/billing/credits` (5 часов и неделя) на каноническом хосте `https://api.commandcode.ai`. OAuth-пресет (`command-code`) использует сохранённый bearer аккаунта; пресет Provider-API ключа (`commandcode`) — активный настроенный ключ. Пользовательски изменённый похожий base URL не опрашивается. Если Command Code также сообщает расход за период, оставшиеся monthly / purchased / free credits показываются как USD-окно. +При входе в OrcaRouter через браузер (`ocx login orcarouter-oauth`) тело успешного ответа при обмене +кода на ключ должно быть корректным JSON в UTF-8 размером не более 64 KiB. Действующий 30-секундный +лимит этого запроса охватывает получение заголовков и всего тела ответа; слишком большое или +некорректное тело отклоняется до сохранения ключа. Эти ограничения относятся только к обмену кода +на ключ при входе, а не к содержимому запросов инференса. Проверка `scope` не меняется: отсутствие +поля допускается, а явно недопустимое значение отклоняется. + **Discovery для SambaNova Cloud.** Пресет читает общедоступный список SambaNova Cloud `/v1/models` на фиксированном API-хосте, сохраняет нативные id провайдера и ограничивает discovery размером 128 KiB и 128 исходными строками. Каталог не требует аутентификации, поэтому процедура входа CLI сообщает, что diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index b7914c4f42..418281a523 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -466,6 +466,13 @@ anahtarını kullanır. [Command Code Studio](https://commandcode.ai/studio/) **Command Code kotası.** Pano ve `ocx account refresh`, kanonik `https://api.commandcode.ai` ana bilgisayarında `/alpha/billing/credits` pencerelerini (5 saat ve haftalık) sorgular. OAuth önayarı (`command-code`) kayıtlı hesap bearer'ını kullanır; Provider-API anahtar önayarı (`commandcode`) etkin yapılandırılmış anahtarı kullanır. Kullanıcının değiştirdiği benzer bir temel URL asla sorgulanmaz. Command Code dönem harcamasını da bildirirse kalan monthly / purchased / free credits USD penceresi olarak gösterilir. +OrcaRouter tarayıcı oturum açma akışında (`ocx login orcarouter-oauth`), anahtar değişimi isteğinin +başarılı yanıt gövdesi en fazla 64 KiB boyutunda geçerli UTF-8 JSON olmalıdır. Bu isteğin mevcut +30 saniyelik süresi, yanıt başlıkları ile gövdenin tamamının alınmasını kapsar; sınırı aşan veya bozuk +gövdeler anahtar kaydedilmeden önce reddedilir. Bu sınırlar yalnızca oturum açma sırasındaki anahtar +değişimine uygulanır, çıkarım isteği yüklerine uygulanmaz. `scope` doğrulaması değişmez: alanın +bulunmamasına izin verilir, açıkça geçersiz bir değer ise reddedilir. + **SambaNova Cloud keşfi.** Önayar, sabit API ana bilgisayarından SambaNova Cloud'un genel `/v1/models` listesini okur, sağlayıcı yerel kimliklerini korur ve keşfi 128 KiB ve 128 ham satırla sınırlar. Katalog kimlik doğrulamasız 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 ade0047960..0dde3b721b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -287,6 +287,11 @@ inference key 可从 [Vultr Console](https://my.vultr.com) 的订阅概览复制 由于模型目录本身是公开的,手填 key 时会诚实显示“无法验证”,不会把公开目录的 200 响应误当成 密钥有效证明。 +OrcaRouter 浏览器登录(`ocx login orcarouter-oauth`)的密钥交换成功响应正文必须是不超过 +64 KiB 的有效 UTF-8 JSON。该交换请求现有的 30 秒时限覆盖响应头和完整正文的接收;超大或 +格式错误的正文会在保存密钥前被拒绝。这些限制仅适用于登录密钥交换,不是推理请求负载的 +限制。`scope` 校验规则保持不变:允许省略,明确无效的值仍会被拒绝。 + 单域名自托管环境可在第一次 PKCE 登录前设置统一 origin;推理地址会从同一个 origin 派生: ```bash diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index e1db617b1f..9c9a322af7 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -367,6 +367,11 @@ endpoint 取得。Chat request 使用設定的 Bearer key。可在 **Command Code 配額。** 儀表板與 `ocx account refresh` 會在正規主機 `https://api.commandcode.ai` 探測 `/alpha/billing/credits` 視窗(5 小時與每週)。OAuth preset (`command-code`) 使用已儲存的帳號 bearer;Provider-API key preset (`commandcode`) 使用目前設定的有效 key。使用者改寫過的仿冒 base URL 不會被探測。當 Command Code 同時回報週期消耗時,剩餘的 monthly / purchased / free credits 會顯示為 USD 視窗。 +OrcaRouter 瀏覽器登入(`ocx login orcarouter-oauth`)的金鑰交換成功回應本文必須是不超過 +64 KiB 的有效 UTF-8 JSON。此交換請求原有的 30 秒時限涵蓋回應標頭與完整本文的接收;過大或 +格式錯誤的本文會在儲存金鑰前被拒絕。這些限制只適用於登入時的金鑰交換,不是推論請求酬載的 +限制。`scope` 驗證規則維持不變:允許省略,明確無效的值仍會被拒絕。 + **SambaNova Cloud 探索。** preset 從固定 API host 讀取 SambaNova Cloud 公開的 `/v1/models` 列表,保留 provider-native id,並把 discovery 限制在 128 KiB/128 個 raw row。因 catalog 不需要認證,CLI login 流程會把 key 回報為 unverifiable,而不會把公開 response 當成有效 key 的證明。Chat request 仍使用 diff --git a/src/lib/bounded-body.ts b/src/lib/bounded-body.ts index 0975268560..0b3769eaac 100644 --- a/src/lib/bounded-body.ts +++ b/src/lib/bounded-body.ts @@ -129,9 +129,11 @@ export async function readBoundedResponseBytes( options: BoundedBytesOptions, ): Promise { const signal = options.signal; - if (signal?.aborted) throw signal.reason; - const body = response.body; + if (signal?.aborted) { + if (body) cancelBodyWithoutWaiting(body, signal.reason); + throw signal.reason; + } if (!body) return { bytes: new Uint8Array(0), oversized: false }; const reader = body.getReader(); diff --git a/src/oauth/orcarouter.ts b/src/oauth/orcarouter.ts index 6c8c8ccb03..870c032157 100644 --- a/src/oauth/orcarouter.ts +++ b/src/oauth/orcarouter.ts @@ -2,6 +2,7 @@ import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server"; import { generatePKCE } from "./pkce"; import type { OAuthController, OAuthCredentials } from "./types"; +import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body"; export const ORCAROUTER_DEFAULT_API_BASE_URL = "https://api.orcarouter.ai"; export const ORCAROUTER_DEFAULT_AUTH_BASE_URL = "https://www.orcarouter.ai"; @@ -147,6 +148,7 @@ export class OrcaRouterOAuthFlow extends OAuthCallbackFlow { async exchangeToken(code: string, _state: string, _redirectUri: string): Promise { if (!this.#verifier) throw new Error("OrcaRouter PKCE verifier was not initialized"); + const signal = requestSignal(this.ctrl.signal); let response: Response; try { response = await fetch(new URL("/api/v1/auth/keys", this.#authBaseUrl), { @@ -158,7 +160,7 @@ export class OrcaRouterOAuthFlow extends OAuthCallbackFlow { code_challenge_method: "S256", }), redirect: "error", - signal: requestSignal(this.ctrl.signal), + signal, }); } catch (error) { if (this.ctrl.signal?.aborted) { @@ -171,9 +173,20 @@ export class OrcaRouterOAuthFlow extends OAuthCallbackFlow { // never turn a code, verifier, or accidentally returned key into console output. throw new Error(`OrcaRouter key exchange failed with HTTP ${response.status}`); } + const { bytes, oversized } = await readBoundedResponseBytes(response, { + maxBytes: BOUNDED_BODY_MAX_BYTES, + signal, + }).catch(() => { + if (signal.aborted) throw signal.reason; + // Preserve the existing non-reflective error for response-body failures. + throw new Error("OrcaRouter key exchange returned invalid JSON"); + }); + if (oversized) { + throw new Error(`OrcaRouter key exchange response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`); + } let payload: unknown; try { - payload = await response.json(); + payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } catch { throw new Error("OrcaRouter key exchange returned invalid JSON"); } diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 3f2d3bf103..fa8dd6be49 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -7,6 +7,11 @@ third-party client's existing config without taking ownership of the rest of tha promise is reversibility: apply snapshots first, writes atomically, records exactly what it owns, and refuses refresh, disable, or restore when the current file cannot be classified safely. +Shared response support has a separate [bounded ingestion contract](../transports/inventory.md#bounded-response-ingestion-and-orcarouter-login): +raw-byte callers own their byte and deadline budgets and inherit best-effort cancellation. +The OrcaRouter login ceiling applies to its key exchange; client configuration files retain the +parsing and ownership rules below. + ## Module Responsibilities | Module | Responsibility | diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 8d7f5edf98..0b08ef825c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -113,7 +113,7 @@ be treated as implemented: `src/server/index.ts` authenticates and routes `/api/*`, then delegates to `src/server/management-api.ts`, which composes the route modules under `src/server/management/`. Codex account routes live in `src/codex/auth-api/routes.ts` because they own the credential store, not -because they are a different plane. +because they are a different plane. Upstream account response reads and OrcaRouter key exchange follow the [bounded ingestion contract](transports/inventory.md#bounded-response-ingestion-and-orcarouter-login). The registered route set is larger than the areas described below; the code is the route SOT. What this document owns is which module holds which area and what invariant that area must not break. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 1aa51abe58..c8c61a9f50 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -38,6 +38,11 @@ sharing a model-name fragment remain distinct from current Codex-native support. The Remote Hub guide distinguishes selected-runtime readiness from general runtime diagnostics; `tests/cli/cli-connect-readiness.test.ts` exercises that boundary and general status's single discovery pass with isolated executable fixtures. +The provider guide's OrcaRouter login section in English and all seven translated sources follows +the [bounded ingestion contract](../transports/inventory.md#bounded-response-ingestion-and-orcarouter-login): +64 KiB of valid UTF-8 JSON and one 30-second deadline covering headers and body. These are login +limits, so the public guide does not apply them to inference payloads. + ## GitHub Pages `.github/workflows/deploy-docs.yml` publishes the docs to: diff --git a/structure/overview.md b/structure/overview.md index 695869ab63..b09501fba1 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -76,6 +76,10 @@ opencodex state root does not undo those writes. Putting native Codex back is th | `$CODEX_HOME/models_cache.json` | Codex, invalidated by opencodex | Cache invalidated after model/catalog changes. | | `dist/`, `gui/dist/`, `node_modules/` | generated | Build output/dependencies. | +OrcaRouter login returns credentials for storage only after bounded response ingestion and payload +validation. The shared reader's cancellation contract and the login-specific byte/deadline limits +are defined in [bounded response ingestion](transports/inventory.md#bounded-response-ingestion-and-orcarouter-login). + ## Non-negotiable invariants Each invariant carries a stable id. A bound invariant names one test, and that test names the id diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 1043931311..7549aa648d 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -43,6 +43,9 @@ The shared Responses path follows the [bounded multipart recovery contract](../s on a per-provider FIXED loopback port, so every response it sends closes its connection. A retired flow that kept a pooled socket would capture the NEXT login's callback and reject it as a state mismatch; see `src/oauth/callback-server.ts`. + Provider token-body budgets are separate from this shared callback lifetime. The + [OrcaRouter bounded key-exchange contract](../transports/inventory.md#bounded-response-ingestion-and-orcarouter-login) + is owned by its login consumer and does not impose that budget on Grok token grants. - **Two-lock refresh transaction:** per-provider+account intent lock held across the IdP exchange plus a short global store-write lock + async mutation funnel around every `auth.json` load-merge-persist (`src/oauth/store.ts`); generation-guarded persist diff --git a/structure/runtime.md b/structure/runtime.md index 94c8ad9fc8..b079c9abfd 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -216,6 +216,10 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an ## Providers and adapters +OrcaRouter key exchange uses the shared raw-byte reader before returning a durable key. Its +64 KiB response ceiling, single 30-second header/body deadline, and cancellation behavior follow +the [bounded ingestion contract](transports/inventory.md#bounded-response-ingestion-and-orcarouter-login). + | Path | Responsibility | | --- | --- | | `src/providers/registry.ts` | Compatibility facade; canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata live in `src/providers/registry/entries-core.ts` and `entries-extended.ts`, with model seeds in `model-seeds.ts`. | diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index a229de333b..cc75e30cde 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -75,6 +75,9 @@ composition, including a turn beyond 32 MiB, late usage/output, slow readers, cancellation and read-error races. `tests/usage/request-log-nonstream.test.ts` binds the bounded non-stream wrapper to request-log status and metadata behavior. +Retaining whole response bodies is the separate concern of `src/lib/bounded-body.ts`, whose cap, +deadline, and cancellation rules are specified in the [bounded ingestion contract](inventory.md#bounded-response-ingestion-and-orcarouter-login). + Upstream API-key usage follows the [physical-attempt account attribution contract](../gui-and-management-api.md#upstream-key-account-attribution), independently of subscription quota observations. ## Terminal-continuation retention diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 10670c2eb1..e2a5d2d1a4 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -75,6 +75,44 @@ not cap process RSS or the conversation messages accumulated across completed me opaque metadata, normal tool passthrough, coalesced-tail accounting, and consumer cancellation on both execution paths. +## Bounded response ingestion and OrcaRouter login + +`src/lib/bounded-body.ts` owns `readBoundedResponseBytes`: it consumes the original response +body without cloning or teeing and retains at most the caller's `maxBytes`. An exact-cap body +requires EOF to succeed; observing an additional byte discards the retained prefix, returns an +empty byte view with `oversized: true`, and attempts to cancel the reader. The cap measures raw +bytes exposed by `response.body`, not characters, `Content-Length`, or total process memory. +The caller supplies the wall-clock deadline through `signal`; an inactivity deadline exists only +when explicitly requested. + +An already-aborted signal attempts to cancel the original body before any reader is attached, +then rejects with the same reason by identity. An abort during consumption likewise preserves +the signal reason. Cancellation is best-effort: synchronous throws and rejected cancellation +promises are observed, and a cancellation that never settles cannot extend the read's deadline. +After an attached read, cleanup removes the abort listener, cancels any inactivity timer, and +attempts to release the reader lock. `tests/server/bounded-body.test.ts` covers these paths. + +`src/oauth/orcarouter.ts` applies this reader to a successful `POST /api/v1/auth/keys` response +with a 65,536-byte (64 KiB) ceiling. One 30-second signal, combined with caller cancellation, +covers both fetching the response headers and consuming the body; no separate body or inactivity +budget is started. Only a complete body within the cap is decoded with fatal UTF-8 and parsed +as JSON before key, user identity, and optional scope validation can return credentials. + +Oversized bodies fail with a fixed size-limit error. Malformed UTF-8, malformed JSON, and ordinary +body-read failures share a fixed invalid-JSON error without upstream text or an error cause. +Body-phase aborts preserve the combined signal's reason by identity; fetch-phase timeout errors +retain the existing network-error wrapper. Non-success HTTP responses retain status-only errors +and do not enter this reader. These limits govern login key exchange, not inference payloads or +other providers' token grants. `tests/providers/orcarouter-provider.test.ts` covers the login +contract with synthetic responses and local callback fixtures, not live provider authentication. + +Other raw-byte consumers of this reader supply their own byte and deadline budgets and inherit the +same best-effort cancellation behavior. `src/server/responses/fetch-helpers.ts` keeps its own +transport budgets, so the 64 KiB login ceiling never caps Responses inference payloads. Because a +rejected body returns no credentials, an oversized, malformed, or aborted key response ends the +login before credential persistence or dashboard convergence, leaving only the fixed size-limit or +invalid-JSON message described above. + ## Provider diagnostic outbound safety Provider connection tests and live model discovery share the GET-only provider outbound wrapper. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 9963bc058e..e1fb9c9307 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -792,7 +792,7 @@ WebSocket metadata and compact are excluded. This does not disable upstream safe Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. -Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates; account quota surfaces use [safe probe diagnostics](inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. +Pool quota producers and account commands follow the [bounded raw-observation contract](../providers/openai-tiers.md#bounded-pool-quota-observations), separate from the latest display snapshot and capacity estimates; account quota surfaces use [safe probe diagnostics](inventory.md#account-quota-failure-diagnostics) separately from quota validity, credential health and routing authority. Raw-byte readers on this path supply their own byte and deadline budgets under the [bounded ingestion contract](inventory.md#bounded-response-ingestion-and-orcarouter-login). Live sideband admission and its bounded upstream handshake follow the [runtime contract](../runtime.md#live-sideband-handshake); the ordinary Responses WebSocket exchange remains separate. diff --git a/tests/providers/orcarouter-provider.test.ts b/tests/providers/orcarouter-provider.test.ts index a45ffde24a..ac5b7b4dc0 100644 --- a/tests/providers/orcarouter-provider.test.ts +++ b/tests/providers/orcarouter-provider.test.ts @@ -1,4 +1,4 @@ -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 { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -58,6 +58,13 @@ function registryEntry(id: "orcarouter" | "orcarouter-oauth") { return entry; } +function keyResponseBytes(size: number): Uint8Array { + const payload = { key: "sk-orca-boundary-test", user_id: "user-boundary", padding: "" }; + const encoder = new TextEncoder(); + const overhead = encoder.encode(JSON.stringify(payload)).byteLength; + return encoder.encode(JSON.stringify({ ...payload, padding: "x".repeat(size - overhead) })); +} + /** Keep the callback listener and PKCE exchange real; replace only the upstream response. */ async function exchangeThroughCallback(payload: unknown) { const abort = new AbortController(); @@ -242,6 +249,153 @@ describe("OrcaRouter dual authentication", () => { expect(message).not.toContain(verifier); }); + test("accepts key exchange JSON exactly at the 64 KiB cap", async () => { + const bytes = keyResponseBytes(65_536); + const response = new Response(bytes); + globalThis.fetch = (async () => response) as typeof fetch; + const flow = new OrcaRouterOAuthFlow({}); + await flow.generateAuthUrl("state", "http://127.0.0.1:51733/callback"); + expect(bytes.byteLength).toBe(65_536); + await expect(flow.exchangeToken("code", "state", "ignored")) + .resolves.toMatchObject({ access: "sk-orca-boundary-test", accountId: "user-boundary" }); + expect(response.body!.locked).toBe(false); + }); + + test("rejects key exchange JSON over the 64 KiB cap", async () => { + const response = new Response(keyResponseBytes(65_537)); + globalThis.fetch = (async () => response) as typeof fetch; + const flow = new OrcaRouterOAuthFlow({}); + await flow.generateAuthUrl("state", "http://127.0.0.1:51733/callback"); + await expect(flow.exchangeToken("code", "state", "ignored")) + .rejects.toThrow("OrcaRouter key exchange response exceeded the 65536-byte limit"); + expect(response.body!.locked).toBe(false); + }); + + test("stops an oversized open key body without draining its tail", async () => { + let pulls = 0; + let cancels = 0; + const response = new Response(new ReadableStream({ + pull(controller) { + pulls++; + if (pulls === 1) controller.enqueue(keyResponseBytes(65_536)); + else if (pulls === 2) controller.enqueue(new Uint8Array([0x20])); + // No EOF: reaching the byte ceiling must settle without waiting for a tail. + }, + cancel() { cancels++; }, + }, { highWaterMark: 0 })); + globalThis.fetch = (async () => response) as typeof fetch; + const flow = new OrcaRouterOAuthFlow({}); + await flow.generateAuthUrl("state", "http://127.0.0.1:51733/callback"); + await expect(flow.exchangeToken("code", "state", "ignored")) + .rejects.toThrow("OrcaRouter key exchange response exceeded the 65536-byte limit"); + expect(pulls).toBe(2); + expect(cancels).toBe(1); + expect(response.body!.locked).toBe(false); + }); + + test("rejects malformed UTF-8 inside otherwise valid key JSON", async () => { + const encoder = new TextEncoder(); + const bytes = new Uint8Array([ + ...encoder.encode('{"key":"sk-orca-utf8-test","user_id":"user-utf8","ignored":"'), + 0xff, + ...encoder.encode('"}'), + ]); + globalThis.fetch = (async () => new Response(bytes)) as typeof fetch; + const flow = new OrcaRouterOAuthFlow({}); + await flow.generateAuthUrl("state", "http://127.0.0.1:51733/callback"); + await expect(flow.exchangeToken("code", "state", "ignored")) + .rejects.toThrow("OrcaRouter key exchange returned invalid JSON"); + }); + + test("aborts before key body consumption without accepting credentials", async () => { + const abort = new AbortController(); + const reason = { code: "login-stopped-after-headers" }; + let pulls = 0; + const cancellations: unknown[] = []; + const response = new Response(new ReadableStream({ + pull(controller) { pulls++; controller.enqueue(keyResponseBytes(128)); controller.close(); }, + cancel(value) { cancellations.push(value); }, + }, { highWaterMark: 0 })); + globalThis.fetch = (async () => { abort.abort(reason); return response; }) as typeof fetch; + const flow = new OrcaRouterOAuthFlow({ signal: abort.signal }); + await flow.generateAuthUrl("state", "http://127.0.0.1:51733/callback"); + await expect(flow.exchangeToken("code", "state", "ignored")).rejects.toBe(reason); + expect(cancellations).toHaveLength(1); + expect(cancellations[0]).toBe(reason); + expect(pulls).toBe(0); + expect(response.body!.locked).toBe(false); + }); + + test("preserves a caller abort during key body consumption", async () => { + const abort = new AbortController(); + const reason = { code: "login-stopped-during-body" }; + const started = Promise.withResolvers(); + let cancelledWith: unknown; + const response = new Response(new ReadableStream({ + pull() { started.resolve(); }, + cancel(value) { cancelledWith = value; }, + }, { highWaterMark: 0 })); + globalThis.fetch = (async () => response) as typeof fetch; + const flow = new OrcaRouterOAuthFlow({ signal: abort.signal }); + await flow.generateAuthUrl("state", "http://127.0.0.1:51733/callback"); + const exchange = flow.exchangeToken("code", "state", "ignored"); + void exchange.catch(() => undefined); + await started.promise; + abort.abort(reason); + await expect(exchange).rejects.toBe(reason); + expect(cancelledWith).toBe(reason); + expect(response.body!.locked).toBe(false); + }); + + test("shares one 30-second request deadline across key headers and body", async () => { + const deadline = new AbortController(); + const reason = new DOMException("fixture deadline", "TimeoutError"); + const budgets: number[] = []; + const timeout = spyOn(AbortSignal, "timeout").mockImplementation(ms => { + budgets.push(ms); + return deadline.signal; + }); + const started = Promise.withResolvers(); + let cancelledWith: unknown; + const response = new Response(new ReadableStream({ + pull() { started.resolve(); }, + cancel(value) { cancelledWith = value; }, + }, { highWaterMark: 0 })); + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + expect(init?.signal).toBe(deadline.signal); + return response; + }) as typeof fetch; + try { + const flow = new OrcaRouterOAuthFlow({}); + await flow.generateAuthUrl("state", "http://127.0.0.1:51733/callback"); + const exchange = flow.exchangeToken("code", "state", "ignored"); + void exchange.catch(() => undefined); + await started.promise; + deadline.abort(reason); + await expect(exchange).rejects.toBe(reason); + expect(budgets).toEqual([30_000]); + expect(cancelledWith).toBe(reason); + expect(response.body!.locked).toBe(false); + } finally { + timeout.mockRestore(); + deadline.abort(reason); + } + }); + + test("does not reflect key body read failures into login errors", async () => { + const secret = ["sk", "orca", "body-failure-canary"].join("-"); + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { controller.error(new Error(secret)); }, + }))) as typeof fetch; + const flow = new OrcaRouterOAuthFlow({}); + await flow.generateAuthUrl("state", "http://127.0.0.1:51733/callback"); + const error = await flow.exchangeToken("code", "state", "ignored").catch(error => error); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("OrcaRouter key exchange returned invalid JSON"); + expect(error.cause).toBeUndefined(); + expect(error.message).not.toContain(secret); + }); + test("completes the real callback with documented key/user_id and no response scope", async () => { expect(await exchangeThroughCallback({ key: "sk-orca-callback-test", user_id: 123 })).toEqual({ access: "sk-orca-callback-test", diff --git a/tests/server/bounded-body.test.ts b/tests/server/bounded-body.test.ts index 0bf5e0ae1b..2a142719ae 100644 --- a/tests/server/bounded-body.test.ts +++ b/tests/server/bounded-body.test.ts @@ -461,6 +461,35 @@ describe("readBoundedResponseBody", () => { expect(Array.from(result.bytes)).toEqual(Array.from(expected)); }); + test.each(["resolve", "reject", "pending"] as const)( + "raw byte pre-aborted reads cancel the original body without waiting: %s", async mode => { + const parent = new AbortController(); + const reason = { code: "stopped-before-read" }; + const pendingCancel = Promise.withResolvers(); + const cancellations: unknown[] = []; + let pulls = 0; + const body = new ReadableStream({ + pull() { pulls++; }, + cancel(value) { + cancellations.push(value); + if (mode === "reject") return Promise.reject(new Error("cancel failed")); + if (mode === "pending") return pendingCancel.promise; + }, + }, { highWaterMark: 0 }); + parent.abort(reason); + try { + await expect(readBoundedResponseBytes(new Response(body), { maxBytes: 5, signal: parent.signal })) + .rejects.toBe(reason); + expect(cancellations).toHaveLength(1); + expect(cancellations[0]).toBe(reason); + expect(pulls).toBe(0); + expect(body.locked).toBe(false); + } finally { + pendingCancel.resolve(); + } + }, + ); + test("raw byte reads discard the prefix and cancel without draining the stream", async () => { let cancelled = false; let tailPulled = false;