diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 396ea93a4d..cf0e587d4d 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -4,16 +4,6 @@ "count": 1 } }, - "open-sse/executors/claude-web-with-auto-refresh.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "open-sse/executors/claude-web.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, "open-sse/executors/claudeIdentity.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/docs/providers/CLAUDE_WEB.md b/docs/providers/CLAUDE_WEB.md index d7db5b6ad8..9d30901586 100644 --- a/docs/providers/CLAUDE_WEB.md +++ b/docs/providers/CLAUDE_WEB.md @@ -1,116 +1,185 @@ --- title: "Providers — Claude Web" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.49 +lastUpdated: 2026-07-14 --- # Providers — Claude Web -## claude-web +## `claude-web` -Web-cookie-based provider for **Claude AI** (`claude.ai`) using session cookie authentication. +`claude-web` sends OpenAI-format chat requests through an authenticated `claude.ai` +browser session. The executor normalizes the supplied cookie, resolves one authenticated +organization, prepares conversation state, selects a direct or browser transport, and +strictly translates the upstream SSE response. The orchestration is in +`open-sse/executors/claude-web.ts:320`. > **New to Web Cookie providers?** > > Read **`docs/getting-started/WEB-COOKIE-GUIDE.md`** for the general setup process, authentication guidance, limitations, and troubleshooting before following this provider-specific guide. -### How It Works +### Model catalog -1. User pastes their `claude.ai` session cookies into the OmniRoute dashboard -2. `ClaudeWebExecutor` transforms OpenAI-format requests to Claude Web API format -3. Requests are sent via **`tls-client-node`** with **Chrome 124 TLS fingerprint** to bypass Cloudflare Turnstile -4. Responses are streamed back via SSE (`text/event-stream`) +The provider registry currently exposes exactly these seven static model IDs +(`open-sse/config/providers/registry/claude/web/index.ts:11`): -### Required Cookies +| Model ID | Display name | +| --------------------------- | ----------------------- | +| `claude-fable-5` | Claude Fable 5 (web) | +| `claude-opus-4-8` | Claude Opus 4.8 (web) | +| `claude-sonnet-5` | Claude Sonnet 5 (web) | +| `claude-haiku-4-5-20251001` | Claude Haiku 4.5 (web) | +| `claude-opus-4-7` | Claude Opus 4.7 (web) | +| `claude-opus-4-6` | Claude Opus 4.6 (web) | +| `claude-sonnet-4-6` | Claude Sonnet 4.6 (web) | -| Cookie | Purpose | Source | -| -------------- | ------------------------------ | -------------------------------------- | -| `sessionKey` | Main authentication | `claude.ai` browser session | -| `routingHint` | Anthropic routing | `claude.ai` browser session | -| `cf_clearance` | Cloudflare Turnstile clearance | Auto-set by Cloudflare after challenge | -| `__cf_bm` | Cloudflare bot management | Auto-set by Cloudflare | -| `_cfuvid` | Cloudflare visitor ID | Auto-set by Cloudflare | +Dynamic model discovery is not implemented for this provider. The list above is the +runtime catalog. -> **Note**: `cf_clearance` is bound to the TLS fingerprint of the browser that solved Cloudflare's Turnstile challenge. The `tls-client-node` library (via `claudeTlsClient.ts`) spoofs a Chrome 124 TLS handshake so the clearance token works from the OmniRoute server. +### Credentials and organization resolution -### API Reference +Supply either the full `claude.ai` Cookie header or a bare session value. Bare values are +normalized to `sessionKey`; other cookies are preserved if supplied. The executor accepts the +cookie through `cookie` or `apiKey` and +reads optional `deviceId` and `orgId` values from the connection data +(`open-sse/executors/claude-web.ts:72`). -**Endpoint**: `POST /api/organizations/{orgId}/chat_conversations/{convId}/completion` +If `orgId` is absent, the executor calls `GET https://claude.ai/api/organizations` and uses the first +organization returned by the authenticated Claude Web session +(`open-sse/executors/claude-web.ts:141`). It fails closed when no valid organization is +returned, reports rejected session authorization as 401, and distinguishes a Cloudflare +challenge from an authentication failure. -**Required Headers**: +### Conversation operations -``` -accept: text/event-stream -anthropic-client-platform: web_claude_ai -anthropic-device-id: -content-type: application/json -Referer: https://claude.ai/chat/{convId} -``` +The optional top-level `claude_web` object is strict. Unknown fields are rejected. Its +accepted fields are defined in `open-sse/executors/claude-web/session.ts:50`: -**Request Body**: +| Field | Meaning | +| --------------------- | --------------------------------------------------------- | +| `operation` | `completion` by default; use `retry` for a retry turn | +| `conversation_id` | Explicit UUID for an existing conversation | +| `parent_message_uuid` | Explicit UUID for the parent assistant message | +| `timezone` | Valid IANA time-zone name | +| `locale` | Structurally valid locale | +| `tool_states` | Optional account tool-state array, limited to 128 entries | -```json -{ - "prompt": "user message", - "model": "claude-sonnet-4-6", - "timezone": "Asia/Jakarta", - "locale": "en-US", - "personalized_styles": [...], - "tools": [...], - "rendering_mode": "messages", - "create_conversation_params": { - "name": "", - "model": "claude-sonnet-4-6", - "is_temporary": false - } -} -``` +Prepared requests use one of two upstream endpoints +(`open-sse/executors/claude-web.ts:203`): -### Architecture +- A new or follow-up turn posts to + `POST https://claude.ai/api/organizations/{orgId}/chat_conversations/{conversationId}/completion`. +- A retry posts to + `POST https://claude.ai/api/organizations/{orgId}/chat_conversations/{conversationId}/retry_completion`. -``` -User Cookies (claude.ai) - ↓ -OmniRoute Dashboard - ↓ -ClaudeWebExecutor (open-sse/executors/claude-web.ts) - ↓ Request transformation (OpenAI → Claude Web format) - ↓ -tlsFetchClaude() (open-sse/services/claudeTlsClient.ts) - ↓ Chrome 124 TLS fingerprint spoofing - ↓ -tls-client-node (Go native binding, koffi) - ↓ -claude.ai API - ↓ SSE stream -``` +A new turn includes `create_conversation_params`. A cached or explicitly linked follow-up +includes `parent_message_uuid` and omits `create_conversation_params`. Retry requires both +conversation and parent-message state and sends no prompt +(`open-sse/executors/claude-web/session.ts:254`). New conversations open the authenticated +UI at `/new`; cached or explicitly linked follow-ups open the exact conversation page +(`open-sse/executors/claude-web/session.ts:324`). + +Conversation state is an in-memory cache keyed by a SHA-256 account scope and the canonical +caller transcript. Entries expire after 30 minutes and the cache is capped at 5,000 entries +(`open-sse/executors/claude-web/session.ts:12`). State is committed only after the strict +stream parser observes `message_stop`; process restarts discard it. On a cache miss, a +multi-message request is serialized into one recovery prompt instead of silently dropping +earlier messages. + +Locale and time zone use this precedence: request `claude_web` value, connection value, +runtime value, then `en-US` for locale or `UTC` for time zone +(`open-sse/executors/claude-web/session.ts:218`). + +### Tools and request payloads + +Direct requests transform only structurally valid OpenAI function tools supplied by the +caller. There is no fabricated static default tool list +(`open-sse/executors/claude-web/payload.ts:102`). + +Browser requests instead capture the authenticated UI request and retain its account tools, +tool states, and personalized styles. Prepared conversation, model, reasoning, prompt, and +message UUID fields still override the captured request +(`open-sse/executors/claude-web/browserTransport.ts:175`). Browser templates are scoped by a +hash of account, organization, cookie, locale, and time zone and expire after 30 minutes +(`open-sse/executors/claude-web/browserTransport.ts:11`, +`open-sse/executors/claude-web/browserTransport.ts:158`). When a direct request has no caller +tools, it can reuse that scoped template; explicit caller tools take precedence +(`open-sse/executors/claude-web/browserTransport.ts:214`). + +### Transport selection + +The default path is `sendClaudeWebDirect()`, which calls `tlsFetchClaude()` with the configured +Chrome 146 profile and the supplied cookie (`open-sse/services/claudeTlsClient.ts:23`). It does +not launch a solver or manufacture a replacement cookie. + +Set `WEB_COOKIE_USE_BROWSER` to `1`, `true`, or `on` to make the account-scoped browser +adapter the primary transport. Set `OMNIROUTE_BROWSER_POOL` to one of the same values to +allow a recognized Cloudflare 403 challenge to fall back from direct transport to the +browser adapter (`open-sse/executors/claude-web.ts:195`). Other HTTP failures do not trigger +that fallback. + +The browser adapter keeps cookies inside the same pooled Playwright context, uses the scoped +hashed key described above, and sends the completion from that context +(`open-sse/executors/claude-web/browserTransport.ts:444`). It never exports a browser-solved +cookie into the direct TLS client. Browser retries require a non-expired UI template bound to +the same actual Playwright context (`open-sse/executors/claude-web/browserTransport.ts:467`). +Browser response reads run incrementally in the authenticated page, honor request cancellation, +and cancel the upstream body as soon as it exceeds 16 MiB +(`open-sse/executors/claude-web/browserTransport.ts:259`). + +The executor returns a redacted audit projection to the shared request logger: organization, +conversation and message UUIDs, prompt text, tool definitions, cookies, and device identifiers +are excluded (`open-sse/executors/claude-web.ts:237`, +`open-sse/executors/claude-web.ts:252`). Transport exceptions also return a generic connection +error rather than the thrown message. + +### SSE behavior + +`createClaudeWebResponse()` handles LF or CRLF framing and multiline `data:` fields. It maps +text deltas to `content`, thinking deltas to `reasoning_content`, and known metadata events +to the `claude_web` response extension. Each metadata event is projected through its own field +allowlist (`open-sse/executors/claude-web/stream.ts:37`). The +conversation, parent-message, assistant-message, and operation metadata are also returned in +`X-OmniRoute-Claude-Web-*` headers (`open-sse/executors/claude-web/stream.ts:364`). + +The parser fails closed on malformed JSON, upstream `error` events, unknown event types, +invalid ordering, content-block mismatches, or EOF before `message_stop`. Streaming output +emits one finish chunk and one `[DONE]`; buffered output uses the same parser. The parser treats +`message_stop` as terminal immediately, cancels trailing upstream data, and propagates +downstream cancellation to the upstream reader (`open-sse/executors/claude-web/stream.ts:461`, +`open-sse/executors/claude-web/stream.ts:563`). Unterminated SSE lines and accumulated events +are capped at 1 MiB (`open-sse/executors/claude-web/stream.ts:17`, +`open-sse/executors/claude-web/stream.ts:62`). ### Files -| File | Purpose | -| ----------------------------------------------------- | ---------------------------------------------------- | -| `src/shared/constants/providers.ts` | Provider registration (WEB_COOKIE_PROVIDERS) | -| `src/lib/providers/webCookieAuth.ts` | Cookie utilities (normalize/extract session cookies) | -| `open-sse/executors/claude-web.ts` | Executor implementation | -| `open-sse/executors/index.ts` | Executor registration | -| `open-sse/services/claudeTlsClient.ts` | TLS fingerprint spoofing via tls-client-node | -| `open-sse/services/__tests__/claudeTlsClient.test.ts` | TLS client tests | -| `tests/unit/claude-web.test.ts` | Executor tests | +| File | Purpose | +| -------------------------------------------------------- | ------------------------------- | +| `open-sse/config/providers/registry/claude/web/index.ts` | Static provider model registry | +| `open-sse/executors/claude-web.ts` | Executor orchestration | +| `open-sse/executors/claude-web/payload.ts` | Payload and tool transformation | +| `open-sse/executors/claude-web/session.ts` | Turn state and transcript cache | +| `open-sse/executors/claude-web/transport.ts` | Direct transport adapter | +| `open-sse/executors/claude-web/browserTransport.ts` | Account-scoped browser adapter | +| `open-sse/executors/claude-web/stream.ts` | Strict SSE translation | +| `open-sse/services/claudeTlsClient.ts` | Native TLS transport | +| `open-sse/services/browserPool.ts` | Pooled Playwright contexts | ### Testing -```bash -# Unit tests -node --import tsx/esm --test tests/unit/claude-web.test.ts +Run the deterministic Claude Web suite without real credentials: -# TLS client tests -npx vitest run open-sse/services/__tests__/claudeTlsClient.test.ts +```powershell +node --import tsx/esm --test tests/unit/claude-web-auto-refresh.test.ts tests/unit/claude-web-browser-transport.test.ts tests/unit/claude-web-executor-split.test.ts tests/unit/claude-web-live-alignment.test.ts tests/unit/claude-web-payload-runtime.test.ts tests/unit/claude-web-session.test.ts tests/unit/claude-web-sonnet5-registry-6209.test.ts tests/unit/claude-web-stream.test.ts tests/unit/claude-web-transport.test.ts tests/unit/claude-web.test.ts tests/unit/issue-6662-repro.test.ts ``` +The Playwright-dependent cases in `tests/unit/claude-web-auto-refresh.test.ts` are explicitly +skipped. This repository does not currently define a credentialed Claude Web live-test +script, so those skipped cases are not runtime proof. + ### Setup -1. Start OmniRoute: `omniroute` -2. Go to Dashboard → Providers → Add Provider -3. Select "Web Cookie" category -4. Choose "Claude Web" -5. Paste your full cookie header from `claude.ai` browser DevTools (Network tab → Copy as fetch → Cookie header) +1. Start OmniRoute with `npm run dev` or a built installation. +2. Open Dashboard → Providers → Add Provider. +3. Select the Web Cookie category and Claude Web. +4. Paste the full Cookie header copied from an authenticated `claude.ai` request. diff --git a/open-sse/config/providers/registry/claude/web/index.ts b/open-sse/config/providers/registry/claude/web/index.ts index cf5bdb16b2..f3a62ca676 100644 --- a/open-sse/config/providers/registry/claude/web/index.ts +++ b/open-sse/config/providers/registry/claude/web/index.ts @@ -9,8 +9,16 @@ export const claude_webProvider: RegistryEntry = { authType: "apikey", authHeader: "cookie", models: [ - { id: "claude-sonnet-5", name: "Claude 5 Sonnet (web)", toolCalling: false }, - { id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet (web)", toolCalling: false }, - { id: "claude-haiku-4-5", name: "Claude 4.5 Haiku (web)", toolCalling: false }, + { id: "claude-fable-5", name: "Claude Fable 5 (web)", toolCalling: false }, + { id: "claude-opus-4-8", name: "Claude Opus 4.8 (web)", toolCalling: false }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7 (web)", toolCalling: false }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6 (web)", toolCalling: false }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5 (web)", toolCalling: false }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (web)", toolCalling: false }, + { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5 (web)", + toolCalling: false, + }, ], }; diff --git a/open-sse/executors/claude-web-with-auto-refresh.ts b/open-sse/executors/claude-web-with-auto-refresh.ts deleted file mode 100644 index 5c0fcd7f51..0000000000 --- a/open-sse/executors/claude-web-with-auto-refresh.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Claude Web Auto-Refresh Wrapper - * - * Enhances the existing ClaudeWebExecutor with automatic cf_clearance solving. - * Intercepts 403/401 responses and attempts Turnstile challenge solve. - */ - -import type { ExecuteInput } from "./base.ts"; -import { ClaudeWebExecutor } from "./claude-web.ts"; -import { getCfClearanceToken, getCacheStatus } from "../services/claudeTurnstileSolver.ts"; - -class ClaudeWebWithAutoRefresh extends ClaudeWebExecutor { - private retryCount = 0; - private maxRetries = 2; - - async execute(input: ExecuteInput) { - const { credentials, log } = input; - this.retryCount = 0; - return this.executeWithRetry(input); - } - - private async executeWithRetry(input: ExecuteInput) { - const { credentials, log } = input; - - // Execute request - let result = await super.execute(input); - - // If success (200), return immediately - if (result.response.status === 200) { - return result; - } - - // If challenge (403) or auth error (401), and retries remain - if ( - (result.response.status === 403 || result.response.status === 401) && - this.retryCount < this.maxRetries - ) { - this.retryCount++; - log?.warn?.( - "CLAUDE-WEB", - `HTTP ${result.response.status} detected - attempt ${this.retryCount}/${this.maxRetries}` - ); - - try { - // Get fresh cf_clearance - const cacheStatus = getCacheStatus(); - const shouldForce = this.retryCount > 1; - - log?.info?.( - "CLAUDE-WEB", - `Solving Turnstile (cache: ${cacheStatus.hasCached ? `${Math.round((cacheStatus.expiresIn || 0) / 1000)}s left` : "empty"})...` - ); - - const freshCfClearance = await getCfClearanceToken({ force: shouldForce }); - - // Update credentials - const rawCookie = String((credentials as any)?.cookie || ""); - const hasCfClearance = rawCookie.includes("cf_clearance="); - - let newCookie: string; - if (hasCfClearance) { - // Replace existing cf_clearance - newCookie = rawCookie.replace(/cf_clearance=[^;]+/, `cf_clearance=${freshCfClearance}`); - } else { - // Append new cf_clearance - newCookie = `${rawCookie}; cf_clearance=${freshCfClearance}`; - } - - log?.info?.("CLAUDE-WEB", "cf_clearance injected, retrying..."); - - // Retry with fresh cookie - const updatedInput: ExecuteInput = { - ...input, - credentials: { - ...credentials, - cookie: newCookie, - }, - }; - - result = await this.executeWithRetry(updatedInput); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - log?.error?.("CLAUDE-WEB", `Auto-refresh failed: ${msg}`); - // Fall through to return error response - } - } - - return result; - } - - async testConnection( - credentials: Record, - signal?: AbortSignal - ): Promise { - try { - // Try basic connection first - const basicTest = await super.testConnection(credentials, signal); - if (basicTest) return true; - - // Try with fresh cf_clearance - const rawCookie = String((credentials as any)?.cookie || ""); - if (!rawCookie.trim()) return false; - - const freshCfClearance = await getCfClearanceToken(); - const newCookie = rawCookie.includes("cf_clearance=") - ? rawCookie.replace(/cf_clearance=[^;]+/, `cf_clearance=${freshCfClearance}`) - : `${rawCookie}; cf_clearance=${freshCfClearance}`; - - return await super.testConnection({ ...credentials, cookie: newCookie }, signal); - } catch { - return false; - } - } -} - -export { ClaudeWebWithAutoRefresh }; -export const createClaudeWebExecutor = () => new ClaudeWebWithAutoRefresh(); diff --git a/open-sse/executors/claude-web.ts b/open-sse/executors/claude-web.ts index fd37c0ed00..4ce3779d45 100644 --- a/open-sse/executors/claude-web.ts +++ b/open-sse/executors/claude-web.ts @@ -1,113 +1,106 @@ /** - * ClaudeWebExecutor — Claude Web Session Provider + * Claude Web executor orchestration. * - * Routes requests through Claude's web interface using session credentials, - * translating between OpenAI chat completions format and Claude's real API format. - * - * Real API Structure: - * Endpoint: https://claude.ai/api/organizations/{orgId}/chat_conversations/{convId}/completion - * Method: POST - * Content-Type: application/json - * Accept: text/event-stream - * - * Auth pipeline (per request): - * 1. Extract session cookie and device ID from credentials - * 2. Build conversation URL with orgId and convId - * 3. Construct full request payload with model, tools, UUID references - * 4. Make authenticated POST request to Claude Web API - * 5. Handle SSE response stream with proper message parsing - * - * Response is streamed as server-sent events (SSE format). + * Request state, browser identity, direct TLS, and SSE parsing are owned by + * focused leaf modules. This host validates credentials and organization + * provenance, prepares one turn, selects a transport, and commits state only + * after the strict stream parser observes message_stop. */ -import { BaseExecutor, mergeAbortSignals, type ExecuteInput } from "./base.ts"; +import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; + +import { CLAUDE_WEB_FINGERPRINT } from "../config/claudeWebFingerprint.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { tlsFetchClaude } from "../services/claudeTlsClient.ts"; -import { getCfClearanceToken } from "../services/claudeTurnstileSolver.ts"; -import { CLAUDE_WEB_FINGERPRINT } from "../config/claudeWebFingerprint.ts"; -import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; -import { randomUUID } from "crypto"; -import { sanitizeErrorMessage } from "../utils/error.ts"; -import { tryBackedChat } from "../services/browserBackedChat.ts"; +import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts"; import { - type ClaudeWebRequestPayload, - transformToClaude, - transformFromClaude, -} from "./claude-web/payload.ts"; + BaseExecutor, + mergeAbortSignals, + type ExecuteInput, + type ExecutorLog, + type ProviderCredentials, +} from "./base.ts"; +import { + applyClaudeWebBrowserTemplate, + sendClaudeWebBrowser, + type ClaudeWebTransportRequest, + type ClaudeWebTransportResult, +} from "./claude-web/browserTransport.ts"; +import type { ClaudeWebRequestPayload } from "./claude-web/payload.ts"; +import { + commitClaudeWebTurn, + invalidateClaudeWebTurn, + prepareClaudeWebTurn, + type PreparedClaudeWebTurn, +} from "./claude-web/session.ts"; +import { createClaudeWebResponse } from "./claude-web/stream.ts"; +import { isClaudeWebChallenge, sendClaudeWebDirect } from "./claude-web/transport.ts"; -// ─── Constants ────────────────────────────────────────────────────────────── const CLAUDE_WEB_API_BASE = "https://claude.ai/api"; const CLAUDE_WEB_ORGS_URL = `${CLAUDE_WEB_API_BASE}/organizations`; - +const CLAUDE_SESSION_COOKIE_NAME = "sessionKey"; +const MAX_ERROR_BODY_BYTES = 64 * 1024; const CLAUDE_USER_AGENT = CLAUDE_WEB_FINGERPRINT.userAgent; -// Session cookie constants -const CLAUDE_SESSION_COOKIE_NAME = "sessionKey"; +type SendClaudeWebTransport = ( + request: ClaudeWebTransportRequest +) => Promise; -/** - * Read the Claude Web session cookie from the credentials object. - * - * Lookup order (most-specific first): - * 1. `credentials.cookie` — direct field (programmatic / older callers) - * 2. `credentials.apiKey` — what the dashboard form posts (the - * connection row's `api_key` column stores the cookie string after - * encryption; `getProviderCredentials()` decrypts and surfaces it - * back as `apiKey`) - * 3. `credentials.providerSpecificData.cookie` — escape hatch for - * callers that route the cookie through the per-provider metadata - * - * Without the apiKey fallback, the executor could never see a real - * cookie through the standard /api/v1/chat/completions path — the - * dashboard posts the cookie in the connection's `apiKey` field, but - * this executor was historically reading `cookie` only. - */ -function readClaudeWebCookie(credentials: unknown): string { - if (!credentials || typeof credentials !== "object") return ""; - const c = credentials as Record; - const direct = typeof c.cookie === "string" ? c.cookie : ""; - if (direct.trim()) return direct; - const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; - if (apiKey.trim()) return apiKey; - const psd = c.providerSpecificData; - if (psd && typeof psd === "object") { - const nested = (psd as Record).cookie; - if (typeof nested === "string" && nested.trim()) return nested; - } - return ""; +type OrganizationResolution = { + organizationId: string | null; + failure: "authentication" | "challenge" | "unavailable" | null; +}; + +export interface ClaudeWebExecutorDeps { + sendDirect?: SendClaudeWebTransport; + sendBrowser?: SendClaudeWebTransport; } -/** - * Read the optional Claude Web device ID from the credentials object. - * Mirrors `readClaudeWebCookie` so callers can use the same priority - * chain (direct → apiKey → providerSpecificData). - */ -function readClaudeWebDeviceId(credentials: unknown): string | undefined { +function readCredentialString(credentials: unknown, key: string): string | undefined { if (!credentials || typeof credentials !== "object") return undefined; - const c = credentials as Record; - if (typeof c.deviceId === "string" && c.deviceId.trim()) return c.deviceId; - const psd = c.providerSpecificData; - if (psd && typeof psd === "object") { - const nested = (psd as Record).deviceId; - if (typeof nested === "string" && nested.trim()) return nested; + const record = credentials as Record; + const direct = record[key]; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const providerData = record.providerSpecificData; + if (providerData && typeof providerData === "object" && !Array.isArray(providerData)) { + const nested = (providerData as Record)[key]; + if (typeof nested === "string" && nested.trim()) return nested.trim(); } return undefined; } -// ─── Helper Functions ─────────────────────────────────────────────────────── +function readClaudeWebCookie(credentials: unknown): string { + const direct = readCredentialString(credentials, "cookie"); + if (direct) return direct; + return readCredentialString(credentials, "apiKey") ?? ""; +} -/** - * Build browser-like headers for Claude Web API - */ -function getBrowserHeaders(deviceId?: string): Record { +function readClaudeWebDeviceId(credentials: unknown): string | undefined { + return readCredentialString(credentials, "deviceId"); +} + +function readClaudeWebOrganizationId(credentials: unknown): string | undefined { + return readCredentialString(credentials, "orgId"); +} + +function normalizeClaudeSessionCookie(rawValue: string): string { + return normalizeSessionCookieHeader(rawValue, CLAUDE_SESSION_COOKIE_NAME); +} + +function getBrowserHeaders( + deviceId?: string, + referer = "https://claude.ai/new", + locale = "en-US" +): Record { const headers: Record = { Accept: "text/event-stream", "Accept-Encoding": "gzip, deflate, br, zstd", - "Accept-Language": "en-US,en;q=0.9", + "Accept-Language": `${locale},en;q=0.9`, "Cache-Control": "no-cache", "Content-Type": "application/json", Origin: "https://claude.ai", Pragma: "no-cache", Priority: "u=1, i", - Referer: "https://claude.ai/new", + Referer: referer, "Sec-Ch-Ua": CLAUDE_WEB_FINGERPRINT.secChUa, "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": CLAUDE_WEB_FINGERPRINT.secChUaPlatform, @@ -115,748 +108,390 @@ function getBrowserHeaders(deviceId?: string): Record { "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "User-Agent": CLAUDE_USER_AGENT, - // Anthropic-specific headers "anthropic-client-platform": "web_claude_ai", }; - - if (deviceId) { - headers["anthropic-device-id"] = deviceId; - } - + if (deviceId) headers["anthropic-device-id"] = deviceId; return headers; } -/** - * Normalize cookie header for Claude Web API - */ -function normalizeClaudeSessionCookie(rawValue: string): string { - return normalizeSessionCookieHeader(rawValue, CLAUDE_SESSION_COOKIE_NAME); -} -/** - * Normalize cookie and auto-inject cf_clearance if missing - */ -async function normalizeClaudeSessionCookieWithAutoRefresh( - rawValue: string, - options?: { allowAutoSolve?: boolean; log?: any } -): Promise { - let normalized = normalizeClaudeSessionCookie(rawValue); - - // Check if cf_clearance is already in the cookie - if (normalized.includes("cf_clearance=")) { - return normalized; - } - - // If auto-solve is enabled, try to solve Turnstile and get fresh cf_clearance - if (options?.allowAutoSolve !== false) { - try { - options?.log?.info?.("CLAUDE-WEB", "cf_clearance missing, attempting to solve Turnstile..."); - const cfClearance = await getCfClearanceToken(); - - // Append cf_clearance to existing cookies - const cfCookie = `cf_clearance=${cfClearance}`; - normalized = normalized ? `${normalized}; ${cfCookie}` : cfCookie; - - options?.log?.info?.("CLAUDE-WEB", "cf_clearance injected successfully"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - options?.log?.warn?.("CLAUDE-WEB", `cf_clearance injection failed: ${message}`); - // Continue anyway - the retry wrapper will handle 403 - } - } - - return normalized; +function combineWithTimeout(signal?: AbortSignal | null): AbortSignal { + const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); + return signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; } -/** - * Verify session is still valid by checking if the organizations endpoint - * returns a successful response. Claude's API does not have a /api/auth/session - * endpoint (unlike ChatGPT), so we use /api/organizations which requires a - * valid session cookie and returns 200 only with valid credentials. - */ async function verifyCookieValidity( cookieHeader: string, deviceId: string | undefined, signal?: AbortSignal ): Promise { try { - const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); - const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; const response = await tlsFetchClaude(CLAUDE_WEB_ORGS_URL, { method: "GET", - headers: { - ...getBrowserHeaders(deviceId), - Cookie: cookieHeader, - }, + headers: { ...getBrowserHeaders(deviceId), Cookie: cookieHeader }, timeoutMs: FETCH_TIMEOUT_MS, - signal: combinedSignal, + signal: combineWithTimeout(signal), }); return response.status === 200; - } catch (error) { + } catch { return false; } } -/** - * Get user's organization ID from session - */ async function getOrganizationId( cookieHeader: string, deviceId: string | undefined, - signal?: AbortSignal -): Promise { + signal?: AbortSignal | null +): Promise { try { - const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); - const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; - const response = await tlsFetchClaude(CLAUDE_WEB_ORGS_URL, { method: "GET", - headers: { - ...getBrowserHeaders(deviceId), - Cookie: cookieHeader, - }, + headers: { ...getBrowserHeaders(deviceId), Cookie: cookieHeader }, timeoutMs: FETCH_TIMEOUT_MS, - signal: combinedSignal, + signal: combineWithTimeout(signal), }); + if (response.status === 401) { + return { organizationId: null, failure: "authentication" }; + } + if (response.status === 403) { + const bodyText = response.text ?? ""; + if ( + isClaudeWebChallenge({ + status: response.status, + headers: response.headers, + body: null, + bodyText, + }) + ) { + return { organizationId: null, failure: "challenge" }; + } + return { organizationId: null, failure: "authentication" }; + } if (response.status !== 200) { - return null; + return { organizationId: null, failure: "unavailable" }; } - const data = JSON.parse(response.text ?? "[]") as Array<{ - id: string; - uuid?: string; - [key: string]: unknown; - }>; - return data?.[0]?.uuid || data?.[0]?.id || null; - } catch (error) { - return null; + const parsed = JSON.parse(response.text ?? "[]") as unknown; + if (!Array.isArray(parsed) || parsed.length === 0) { + return { organizationId: null, failure: "unavailable" }; + } + const organization = parsed[0]; + if (!organization || typeof organization !== "object" || Array.isArray(organization)) { + return { organizationId: null, failure: "unavailable" }; + } + const record = organization as Record; + const identifier = record.uuid ?? record.id; + return typeof identifier === "string" && identifier.trim() + ? { organizationId: identifier.trim(), failure: null } + : { organizationId: null, failure: "unavailable" }; + } catch { + return { organizationId: null, failure: "unavailable" }; } } -function shouldUseBrowserBacked(): boolean { - const flag = process.env.WEB_COOKIE_USE_BROWSER; - if (flag === "1" || flag === "true" || flag === "on") return true; - const poolFlag = process.env.OMNIROUTE_BROWSER_POOL; - return poolFlag === "on" || poolFlag === "1" || poolFlag === "true"; +function isEnabledFlag(value: string | undefined): boolean { + return value === "1" || value === "true" || value === "on"; } -function extractLastUserText(body: Record): string { - const messages = Array.isArray(body.messages) - ? (body.messages as Array>) - : []; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") { - const content = messages[i].content; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .map((c) => (typeof c === "object" && c && "text" in c ? String(c.text) : "")) - .filter(Boolean) - .join("\n"); - } - } +function forceBrowserTransport(): boolean { + return isEnabledFlag(process.env.WEB_COOKIE_USE_BROWSER); +} + +function browserFallbackEnabled(): boolean { + return forceBrowserTransport() || isEnabledFlag(process.env.OMNIROUTE_BROWSER_POOL); +} + +function makeCompletionUrl(turn: PreparedClaudeWebTurn, organizationId: string): string { + return ( + `${CLAUDE_WEB_API_BASE}/organizations/${encodeURIComponent(organizationId)}` + + `/chat_conversations/${encodeURIComponent(turn.conversationId)}/${turn.endpointSuffix}` + ); +} + +function makeErrorResponse( + status: number, + message: string, + options?: { + details?: unknown; + type?: string; + code?: string; } - return "Reply with OK"; +): Response { + const body = buildErrorBody(status, message, options?.details); + if (options?.type) body.error.type = options.type; + if (options?.code) body.error.code = options.code; + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); } -/** - * Read Claude Web SSE chunks from an upstream response body and pipe them - * through transformFromClaude to produce OpenAI chat.completion.chunk SSE. - * - * The upstream body may arrive as a ReadableStream directly (tlsBody) or - * via upstreamResp.body (browser-backed path, where the Response type is - * accurate). tlsBody takes priority when non-null. - * - * Returns a Response whose body is the transformed SSE stream. The client - * receives standard OpenAI-format streaming chunks. - */ -async function buildClaudeStreamingResponse( - upstreamResp: Response, +function makeExecutionResult( + response: Response, + transformedBody: Record | ClaudeWebRequestPayload, + url = "", + headers: Record = {} +) { + return { response, url, headers, transformedBody }; +} + +function makeAuditUrl(turn: PreparedClaudeWebTurn): string { + return ( + `${CLAUDE_WEB_API_BASE}/organizations//chat_conversations/` + + `/${turn.endpointSuffix}` + ); +} + +function makeAuditHeaders(): Record { + return { + Accept: "text/event-stream", + "Content-Type": "application/json", + "anthropic-client-platform": "web_claude_ai", + }; +} + +function makeAuditBody( model: string, - log: - | { warn?: (tag: string, msg: string) => void; error?: (tag: string, msg: string) => void } - | null - | undefined, - tlsBody: ReadableStream | null | undefined -): Promise { - const src = tlsBody ?? upstreamResp.body; - if (!src) { - return new Response( - JSON.stringify({ - error: { - message: "No upstream response body available", - type: "upstream_error", - }, - }), - { status: 502, headers: { "Content-Type": "application/json" } } - ); - } - - let finished = false; - - const transformed = new ReadableStream({ - async start(controller) { - const reader = src!.getReader(); - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - let buffer = ""; - - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - // SSE lines are separated by \n or \r\n; Claude sends - // data: {...}\n\n (double newline separating events). - const lines = buffer.split("\n"); - // Keep the last potentially-incomplete line in the buffer. - buffer = lines.pop() ?? ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith("data: ")) continue; - - const jsonStr = trimmed.slice(6); // strip "data: " - try { - const parsed = JSON.parse(jsonStr) as Record; - - // Content block start — signals the beginning of a thinking - // block. Emit an empty reasoning_content chunk so clients that - // key off the field's presence (not just its text) see the - // thinking panel open immediately, mirroring the real-Anthropic - // translator's content_block_start handling (#6662). - if (parsed.type === "content_block_start") { - const block = parsed.content_block as Record | undefined; - if (block?.type === "thinking") { - const chunk = transformFromClaude("", model, undefined, "reasoning"); - const out = `data: ${JSON.stringify(chunk)}\n\n`; - controller.enqueue(encoder.encode(out)); - } - } - // Content block delta — contains the actual text, or (for a - // thinking block) the extended-thinking text. Claude's real SSE - // shape uses `delta.text` for text_delta and `delta.thinking` - // for thinking_delta — never both — so a plain field check is - // enough to route each to the right OpenAI delta field. - else if (parsed.type === "content_block_delta") { - const delta = parsed.delta as Record | undefined; - const text = delta?.text as string | undefined; - const thinking = delta?.thinking as string | undefined; - if (text) { - const chunk = transformFromClaude(text, model); - const out = `data: ${JSON.stringify(chunk)}\n\n`; - controller.enqueue(encoder.encode(out)); - } else if (thinking) { - const chunk = transformFromClaude(thinking, model, undefined, "reasoning"); - const out = `data: ${JSON.stringify(chunk)}\n\n`; - controller.enqueue(encoder.encode(out)); - } - } - // message_stop — final event from Claude. - else if (parsed.type === "message_stop") { - const chunk = transformFromClaude("", model, "end_turn"); - const out = `data: ${JSON.stringify(chunk)}\n\n`; - controller.enqueue(encoder.encode(out)); - finished = true; - } - // message_delta — may carry a stop_reason. - else if (parsed.type === "message_delta") { - const delta = parsed.delta as Record | undefined; - const stopReason = delta?.stop_reason as string | undefined; - if (stopReason) { - const chunk = transformFromClaude("", model, stopReason); - const out = `data: ${JSON.stringify(chunk)}\n\n`; - controller.enqueue(encoder.encode(out)); - } - } - } catch { - // Skip lines that aren't valid JSON (metadata, ping, etc.) - } - } - } - - // Flush remaining buffer. - if (buffer.trim()) { - const trimmed = buffer.trim(); - if (trimmed.startsWith("data: ")) { - const jsonStr = trimmed.slice(6); - try { - const parsed = JSON.parse(jsonStr) as Record; - if (parsed.type === "content_block_delta") { - const delta = parsed.delta as Record | undefined; - const text = delta?.text as string | undefined; - const thinking = delta?.thinking as string | undefined; - if (text) { - const chunk = transformFromClaude(text, model); - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`) - ); - } else if (thinking) { - const chunk = transformFromClaude(thinking, model, undefined, "reasoning"); - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`) - ); - } - } - } catch { - /* skip */ - } - } - } - - // Send terminal DONE marker if we haven't sent a message_stop. - if (!finished) { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - } - } catch (err) { - log?.error?.("CLAUDE-WEB-STREAM", `Stream error: ${String(err)}`); - controller.error(err); - } finally { - try { - reader.releaseLock(); - } catch { - /* ok */ - } - try { - controller.close(); - } catch {} - } + stream: boolean, + operation?: PreparedClaudeWebTurn["operation"] +): Record { + return { + model, + stream, + claude_web: { + provider: "claude-web", + ...(operation ? { operation } : {}), }, - }); - - return new Response(transformed, { - status: 200, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); + }; } -// ─── Main Executor Class ──────────────────────────────────────────────────── +async function readTransportErrorText(result: ClaudeWebTransportResult): Promise { + if (result.bodyText !== undefined) return result.bodyText; + if (!result.body) return ""; + const reader = result.body.getReader(); + const decoder = new TextDecoder(); + let total = 0; + let output = ""; + try { + while (total < MAX_ERROR_BODY_BYTES) { + const { value, done } = await reader.read(); + if (done || !value) break; + const remaining = MAX_ERROR_BODY_BYTES - total; + const chunk = value.byteLength > remaining ? value.slice(0, remaining) : value; + total += chunk.byteLength; + output += decoder.decode(chunk, { stream: total < MAX_ERROR_BODY_BYTES }); + if (chunk.byteLength < value.byteLength) break; + } + output += decoder.decode(); + return output; + } finally { + await reader.cancel().catch(() => {}); + try { + reader.releaseLock(); + } catch { + // The reader may already be released by cancel(). + } + } +} + +async function errorResponseForTransport( + result: ClaudeWebTransportResult, + turn: PreparedClaudeWebTurn +): Promise { + const bodyText = await readTransportErrorText(result); + if (result.status === 401) { + invalidateClaudeWebTurn(turn, "conversation"); + return makeErrorResponse(401, "Session expired or invalid"); + } + if (result.status === 429) { + return makeErrorResponse(429, "Rate limited by Claude Web API"); + } + if (isClaudeWebChallenge({ ...result, bodyText })) { + return makeErrorResponse(403, "Claude Web returned a Cloudflare browser challenge", { + type: "cloudflare_challenge", + code: "cf_mitigated_challenge", + }); + } + return makeErrorResponse( + result.status >= 400 && result.status <= 599 ? result.status : 502, + `Claude Web API error (${result.status || 502})` + ); +} export class ClaudeWebExecutor extends BaseExecutor { - constructor() { - super("claude-web", { - baseUrl: CLAUDE_WEB_API_BASE, - }); + private readonly sendDirect: SendClaudeWebTransport; + private readonly sendBrowser: SendClaudeWebTransport; + + constructor(deps: ClaudeWebExecutorDeps = {}) { + super("claude-web", { baseUrl: CLAUDE_WEB_API_BASE }); + this.sendDirect = deps.sendDirect ?? sendClaudeWebDirect; + this.sendBrowser = deps.sendBrowser ?? sendClaudeWebBrowser; } - /** - * Test connection to Claude Web API - */ async testConnection( credentials: Record, signal?: AbortSignal ): Promise { try { const rawCookie = readClaudeWebCookie(credentials); - if (!rawCookie.trim()) { - return false; - } - - const cookieHeader = await normalizeClaudeSessionCookieWithAutoRefresh(rawCookie, { - allowAutoSolve: false, - }); - const deviceId = readClaudeWebDeviceId(credentials); - - return await verifyCookieValidity(cookieHeader, deviceId, signal); - } catch (error) { + if (!rawCookie.trim()) return false; + const cookieHeader = normalizeClaudeSessionCookie(rawCookie); + return verifyCookieValidity(cookieHeader, readClaudeWebDeviceId(credentials), signal); + } catch { return false; } } - /** - * Get user's organization ID from session - */ - async execute({ model, body, stream: _stream, credentials, signal, log }: ExecuteInput) { - const bodyObj = (body || {}) as Record; + async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) { + const initialAuditBody = makeAuditBody(model, stream); + const bodyObj = + body && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : null; + if (!bodyObj) { + return makeExecutionResult(makeErrorResponse(400, "Invalid request body"), {}); + } + if (!credentials || typeof credentials !== "object") { + return makeExecutionResult(makeErrorResponse(400, "Invalid credentials"), initialAuditBody); + } + + const rawCookie = readClaudeWebCookie(credentials); + if (!rawCookie.trim()) { + return makeExecutionResult( + makeErrorResponse(401, "Missing session cookie"), + initialAuditBody + ); + } + + let cookieHeader: string; + try { + cookieHeader = normalizeClaudeSessionCookie(rawCookie); + } catch (error) { + return makeExecutionResult( + makeErrorResponse(401, sanitizeErrorMessage(error)), + initialAuditBody + ); + } + + const deviceId = readClaudeWebDeviceId(credentials); + let organizationId = readClaudeWebOrganizationId(credentials); + if (!organizationId) { + const resolution = await getOrganizationId(cookieHeader, deviceId, signal); + organizationId = resolution.organizationId ?? undefined; + if (resolution.failure === "authentication") { + log?.warn?.("CLAUDE-WEB", "Organization discovery rejected the authenticated session"); + return makeExecutionResult( + makeErrorResponse(401, "Session expired or invalid"), + initialAuditBody, + CLAUDE_WEB_ORGS_URL, + makeAuditHeaders() + ); + } + if (resolution.failure === "challenge") { + log?.warn?.("CLAUDE-WEB", "Organization discovery encountered a browser challenge"); + return makeExecutionResult( + makeErrorResponse(403, "Claude Web returned a Cloudflare browser challenge", { + type: "cloudflare_challenge", + code: "cf_mitigated_challenge", + }), + initialAuditBody, + CLAUDE_WEB_ORGS_URL, + makeAuditHeaders() + ); + } + } + if (!organizationId) { + log?.warn?.("CLAUDE-WEB", "Authenticated organization could not be resolved"); + return makeExecutionResult( + makeErrorResponse(502, "Unable to determine the authenticated Claude Web organization"), + initialAuditBody, + CLAUDE_WEB_ORGS_URL, + makeAuditHeaders() + ); + } + + let turn: PreparedClaudeWebTurn; + try { + turn = prepareClaudeWebTurn({ + body: bodyObj, + model, + credentials: credentials as ProviderCredentials, + organizationId, + normalizedCookie: cookieHeader, + }); + } catch (error) { + return makeExecutionResult( + makeErrorResponse(400, sanitizeErrorMessage(error)), + initialAuditBody + ); + } + + const url = makeCompletionUrl(turn, organizationId); + const headers = getBrowserHeaders(deviceId, turn.pageUrl, turn.payload.locale); + const transportRequest: ClaudeWebTransportRequest = { + scopeKey: turn.accountScope, + organizationId, + conversationId: turn.conversationId, + endpointSuffix: turn.endpointSuffix, + pageUrl: turn.pageUrl, + url, + cookieString: cookieHeader, + headers, + payload: turn.payload, + locale: turn.payload.locale, + timezone: turn.payload.timezone, + signal, + }; + const auditBody = makeAuditBody(model, stream, turn.operation); + const auditUrl = makeAuditUrl(turn); + const auditHeaders = makeAuditHeaders(); try { - // Validate input - if (!credentials || typeof credentials !== "object") { - const errorResp = new Response( - JSON.stringify({ - error: { - message: "Invalid credentials", - type: "invalid_request_error", - }, - }), - { - status: 400, - statusText: "Bad Request", - headers: { "Content-Type": "application/json" }, - } - ); - return { - response: errorResp, - url: "", - headers: {}, - transformedBody: bodyObj, - }; + let transportResult: ClaudeWebTransportResult; + if (forceBrowserTransport()) { + transportResult = await this.sendBrowser(transportRequest); + } else { + const directRequest = applyClaudeWebBrowserTemplate(transportRequest); + transportResult = await this.sendDirect(directRequest); + if (isClaudeWebChallenge(transportResult) && browserFallbackEnabled()) { + transportResult = await this.sendBrowser(directRequest); + } } - const rawCookie = readClaudeWebCookie(credentials); - if (!rawCookie.trim()) { - const errorResp = new Response( - JSON.stringify({ - error: { - message: "Missing session cookie", - type: "authentication_error", - }, - }), - { - status: 401, - statusText: "Unauthorized", - headers: { "Content-Type": "application/json" }, - } + if (transportResult.status < 200 || transportResult.status >= 300) { + return makeExecutionResult( + await errorResponseForTransport(transportResult, turn), + auditBody, + auditUrl, + auditHeaders + ); + } + if (!transportResult.body) { + invalidateClaudeWebTurn(turn); + return makeExecutionResult( + makeErrorResponse(502, "Claude Web returned no response body"), + auditBody, + auditUrl, + auditHeaders ); - return { - response: errorResp, - url: "", - headers: {}, - transformedBody: bodyObj, - }; } - const cookieHeader = await normalizeClaudeSessionCookieWithAutoRefresh(rawCookie, { - allowAutoSolve: true, + const response = await createClaudeWebResponse(transportResult.body, { + model, + stream, + responseMetadata: turn.responseMetadata, + onComplete: ({ assistantText }) => commitClaudeWebTurn(turn, assistantText), + onFailure: () => invalidateClaudeWebTurn(turn), log, }); - const deviceId = readClaudeWebDeviceId(credentials); - - // Transform request to Claude format - let claudePayload: ClaudeWebRequestPayload; - try { - claudePayload = transformToClaude(bodyObj, model); - } catch (transformError) { - const errorResp = new Response( - JSON.stringify({ - error: { - message: - transformError instanceof Error ? transformError.message : "Invalid request format", - type: "invalid_request_error", - }, - }), - { - status: 400, - statusText: "Bad Request", - headers: { "Content-Type": "application/json" }, - } - ); - return { - response: errorResp, - url: "", - headers: {}, - transformedBody: bodyObj, - }; - } - - // Get organization and conversation IDs - let orgId = (credentials as any)?.orgId as string | undefined; - let conversationId = (credentials as any)?.conversationId as string | undefined; - - if (!orgId) { - orgId = await getOrganizationId(cookieHeader, deviceId, signal); - if (!orgId) { - log?.warn?.("CLAUDE-WEB", "Could not retrieve organization ID, using fallback"); - // Fallback: use empty org ID, API might create conversation - orgId = ""; - } - } - - if (!conversationId) { - // Generate a new conversation ID if not provided - conversationId = randomUUID(); - } - - // Prepare browser-emulated headers (used by both paths) - const headers = getBrowserHeaders(deviceId); - - // Browser-backed path: opt-in via OMNIROUTE_BROWSER_POOL=on or - // WEB_COOKIE_USE_BROWSER=1. Routes the chat through a shared - // Playwright/Cloakbrowser page with the user's session cookies - // injected, so Claude's Cloudflare Turnstile / session fingerprint - // checks are satisfied by a real browser context. This is the - // only way to get HTTP 200 from a sandbox/VPS IP where the - // pasted cf_clearance is bound to a different fingerprint and - // Cloudflare refuses the request. The browser's fetch hits the - // /completion endpoint directly (the conversation id is created - // by the page itself), so the placeholder in the matcher is - // harmless. - if (shouldUseBrowserBacked()) { - const userText = extractLastUserText(bodyObj); - const completionUrl = orgId - ? `${CLAUDE_WEB_API_BASE}/organizations/${orgId}/chat_conversations/PLACEHOLDER/completion` - : `${CLAUDE_WEB_API_BASE}/chat_conversations/PLACEHOLDER/completion`; - const result = await tryBackedChat({ - poolKey: "claude-web", - chatPageUrl: "https://claude.ai/new", - chatUrl: completionUrl, - chatUrlMatchDomain: "claude.ai", - cookieString: rawCookie, - cookieDomain: ".claude.ai", - userMessage: userText, - inputSelector: "div[contenteditable='true']", - postSubmitWaitMs: 15000, - signal: signal ?? null, - }); - if (result.status > 0) { - // Wrap captured SSE body as a Response so the existing - // stream parser (transformFromClaude) can be reused. - const upstreamResp = new Response(result.body, { - status: result.status, - headers: { - "Content-Type": result.contentType || "text/event-stream", - }, - }); - // Reuse the streaming transformer from below. - return { - response: await buildClaudeStreamingResponse(upstreamResp, model, log, null), - url: completionUrl, - headers, - transformedBody: claudePayload, - }; - } - const errorResp = new Response( - JSON.stringify({ - error: { - message: `Claude Web browser-backed chat captured no upstream response (timing: ${JSON.stringify( - result.timing - )})`, - type: "upstream_error", - }, - }), - { - status: 502, - headers: { "Content-Type": "application/json" }, - } - ); - return { - response: errorResp, - url: completionUrl, - headers, - transformedBody: claudePayload, - }; - } - - // Build completion URL - const completionUrl = - orgId && conversationId - ? `${CLAUDE_WEB_API_BASE}/organizations/${orgId}/chat_conversations/${conversationId}/completion` - : `${CLAUDE_WEB_API_BASE}/chat_conversations/new/completion`; - - // Prepare request - const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); - const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; - - log?.debug?.("CLAUDE-WEB", `Making request to ${completionUrl}`); - - // cf_clearance is already injected via normalizeClaudeSessionCookieWithAutoRefresh above - - const fetchResponse = await tlsFetchClaude(completionUrl, { - method: "POST", - headers: { - ...headers, - Cookie: cookieHeader, - }, - body: JSON.stringify(claudePayload), - timeoutMs: FETCH_TIMEOUT_MS, - stream: true, - signal: combinedSignal, - }); - - // Handle errors - if (fetchResponse.status < 200 || fetchResponse.status >= 300) { - log?.error?.("CLAUDE-WEB", `HTTP ${fetchResponse.status}`); - - if (fetchResponse.status === 401) { - const errorResp = new Response( - JSON.stringify({ - error: { - message: "Session expired or invalid", - type: "authentication_error", - }, - }), - { - status: 401, - statusText: "Unauthorized", - headers: { "Content-Type": "application/json" }, - } - ); - return { - response: errorResp, - url: completionUrl, - headers, - transformedBody: claudePayload, - }; - } - - if (fetchResponse.status === 429) { - const errorResp = new Response( - JSON.stringify({ - error: { - message: "Rate limited by Claude Web API", - type: "rate_limit_error", - }, - }), - { - status: 429, - statusText: "Too Many Requests", - headers: { "Content-Type": "application/json" }, - } - ); - return { - response: errorResp, - url: completionUrl, - headers, - transformedBody: claudePayload, - }; - } - - // Read the upstream body. `tlsFetchClaude` writes non-SSE error - // bodies (Cloudflare challenge pages, HTML rate-limit responses, - // Anthropic JSON errors) into `body`, not `text` — `text` is only - // populated for non-streaming requests. Reading the wrong field - // here produced the cryptic "[403]: Claude Web API error: " with - // no diagnostic info. The earlier code did exactly that and made - // 403 from Cloudflare look indistinguishable from a real Claude - // rejection. - // - // `body` may be a ReadableStream (SSE path) even when status is - // non-2xx — drain a small prefix so we can classify the failure - // (Cloudflare challenge vs upstream JSON) without buffering the - // whole response. If the read fails or times out, fall back to an - // empty string and let the generic error message stand. - let errorText = ""; - let cfMitigated: string | null = null; - try { - if (fetchResponse.body) { - const reader = fetchResponse.body.getReader(); - const decoder = new TextDecoder(); - const chunks: Uint8Array[] = []; - let total = 0; - const maxBytes = 2048; // enough to identify a Cloudflare challenge - while (total < maxBytes) { - const { value, done } = await reader.read(); - if (done || !value) break; - chunks.push(value); - total += value.byteLength; - } - // Release the reader so the underlying tls-client connection - // can be reused for the next request. Without cancel() the - // connection can hang on a still-open stream. - try { - reader.releaseLock(); - } catch { - /* already released */ - } - if (chunks.length === 1) { - errorText = decoder.decode(chunks[0]); - } else { - const combined = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - combined.set(chunk, offset); - offset += chunk.byteLength; - } - errorText = decoder.decode(combined); - } - } else if (fetchResponse.text) { - errorText = fetchResponse.text; - } - } catch { - errorText = ""; - } - - // Surface Cloudflare challenge pages as a distinct, actionable - // error so dashboards / logs don't show an empty "Claude Web API - // error:" body. The most common cause is a sandbox / data-center - // IP that Cloudflare has flagged; the cf_clearance cookie bound - // to a different IP won't pass the challenge. - cfMitigated = fetchResponse.headers.get("cf-mitigated"); - const isCloudflareChallenge = - fetchResponse.status === 403 && - (cfMitigated === "challenge" || - /\s*Just a moment/i.test(errorText) || - /<title>\s*Attention Required/i.test(errorText)); - - let errorMessage: string; - if (isCloudflareChallenge) { - errorMessage = - "Claude Web returned a Cloudflare bot-management challenge " + - `(cf-mitigated=${cfMitigated ?? "challenge"}). ` + - "The sandbox / VPS IP appears to be flagged; the cf_clearance " + - "cookie pasted from a residential IP won't pass. Probe from a " + - "residential network, or use the official Anthropic API " + - "(provider: 'claude') instead."; - } else { - // Trim the body to keep the error message compact but still - // useful — most real Claude errors are short JSON; Cloudflare - // HTML bodies are caught above. - const trimmed = errorText.trim().slice(0, 500); - errorMessage = trimmed - ? `Claude Web API error (${fetchResponse.status}): ${trimmed}` - : `Claude Web API error (${fetchResponse.status}) with no response body`; - } - - const errorResp = new Response( - JSON.stringify({ - error: { - message: errorMessage, - type: isCloudflareChallenge ? "cloudflare_challenge" : "api_error", - code: isCloudflareChallenge - ? "cf_mitigated_challenge" - : `HTTP_${fetchResponse.status}`, - ...(cfMitigated ? { cfMitigated } : {}), - }, - }), - { - status: fetchResponse.status, - statusText: "HTTP Error", - headers: { "Content-Type": "application/json" }, - } - ); - return { - response: errorResp, - url: completionUrl, - headers, - transformedBody: claudePayload, - }; - } - - // Stream the response (shared with the browser-backed path). - return { - response: await buildClaudeStreamingResponse(fetchResponse, model, log, fetchResponse.body), - url: completionUrl, - headers, - transformedBody: claudePayload, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log?.error?.("CLAUDE-WEB", `Fetch failed: ${errorMessage}`); - - const errorResp = new Response( - JSON.stringify({ - error: { - message: `Claude Web connection failed: ${sanitizeErrorMessage(errorMessage)}`, - type: "api_connection_error", - }, - }), - { - status: 500, - statusText: "Internal Server Error", - headers: { "Content-Type": "application/json" }, - } + return makeExecutionResult(response, auditBody, auditUrl, auditHeaders); + } catch { + invalidateClaudeWebTurn(turn); + log?.error?.("CLAUDE-WEB", "Transport failed"); + return makeExecutionResult( + makeErrorResponse(502, "Claude Web connection failed"), + auditBody, + auditUrl, + auditHeaders ); - - return { - response: errorResp, - url: "", - headers: {}, - transformedBody: bodyObj, - }; } } } diff --git a/open-sse/executors/claude-web/browserTransport.ts b/open-sse/executors/claude-web/browserTransport.ts new file mode 100644 index 0000000000..d17cd885d9 --- /dev/null +++ b/open-sse/executors/claude-web/browserTransport.ts @@ -0,0 +1,494 @@ +import { createHash } from "crypto"; + +import { + acquireBrowserContext, + openPage, + type BrowserPoolContextOptions, + type PooledContext, +} from "../../services/browserPool.ts"; +import type { ClaudeWebRequestPayload } from "./payload.ts"; + +const CLAUDE_WEB_TEMPLATE_TTL_MS = 30 * 60 * 1000; +const CLAUDE_WEB_TEMPLATE_MAX = 5000; +const MAX_CLAUDE_WEB_BROWSER_RESPONSE_BYTES = 16 * 1024 * 1024; +const CLAUDE_WEB_INPUT_SELECTOR = "div[contenteditable='true']"; + +type Page = import("playwright").Page; + +type BrowserTemplate = { + tools: ClaudeWebRequestPayload["tools"]; + toolStates?: unknown[]; + personalizedStyles: ClaudeWebRequestPayload["personalized_styles"]; +}; + +type CachedBrowserTemplate = { + template: BrowserTemplate; + context: PooledContext["context"]; + expiresAt: number; +}; + +const browserTemplateCache = new Map<string, CachedBrowserTemplate>(); +let testNow: number | null = null; + +export interface ClaudeWebTransportRequest { + scopeKey: string; + organizationId: string; + conversationId: string; + endpointSuffix: "completion" | "retry_completion"; + pageUrl: string; + url: string; + cookieString: string; + headers: Record<string, string>; + payload: ClaudeWebRequestPayload; + locale: string; + timezone: string; + signal?: AbortSignal | null; +} + +export interface ClaudeWebTransportResult { + status: number; + headers: Headers; + body: ReadableStream<Uint8Array> | null; + /** Buffered only for non-success responses; never returned directly to clients. */ + bodyText?: string; +} + +export interface ClaudeWebBrowserDeps { + acquireContext(key: string, options: BrowserPoolContextOptions): Promise<PooledContext>; + openPage(pooled: PooledContext): Promise<Page>; + fetchResponse( + page: Page, + input: ClaudeWebBrowserFetchInput + ): Promise<{ + status: number; + headers: Record<string, string>; + body: Uint8Array; + }>; +} + +export interface ClaudeWebBrowserFetchInput { + url: string; + headers: Record<string, string>; + body: string; + maxBytes: number; +} + +const defaultDeps: ClaudeWebBrowserDeps = { + acquireContext: acquireBrowserContext, + openPage, + fetchResponse: fetchClaudeWebPageResponse, +}; + +function now(): number { + return testNow ?? Date.now(); +} + +function cloneValue<T>(value: T): T { + return structuredClone(value); +} + +function expectedRequestUrl(request: ClaudeWebTransportRequest): string { + return ( + "https://claude.ai/api/organizations/" + + `${encodeURIComponent(request.organizationId)}/chat_conversations/` + + `${encodeURIComponent(request.conversationId)}/${request.endpointSuffix}` + ); +} + +function verifyRequestUrl(request: ClaudeWebTransportRequest): void { + if (request.url !== expectedRequestUrl(request)) { + throw new Error("Claude Web browser request endpoint does not match prepared state"); + } +} + +function extractBrowserTemplate(uiPayload: Record<string, unknown>): BrowserTemplate { + return { + tools: Array.isArray(uiPayload.tools) + ? cloneValue(uiPayload.tools as ClaudeWebRequestPayload["tools"]) + : [], + ...(Array.isArray(uiPayload.tool_states) + ? { toolStates: cloneValue(uiPayload.tool_states) } + : {}), + personalizedStyles: Array.isArray(uiPayload.personalized_styles) + ? cloneValue(uiPayload.personalized_styles as ClaudeWebRequestPayload["personalized_styles"]) + : [], + }; +} + +function pruneExpiredTemplates(): void { + const currentTime = now(); + for (const [key, entry] of browserTemplateCache) { + if (currentTime >= entry.expiresAt) browserTemplateCache.delete(key); + } +} + +function rememberBrowserTemplate( + poolKey: string, + template: BrowserTemplate, + context: PooledContext["context"] +): void { + pruneExpiredTemplates(); + if (browserTemplateCache.has(poolKey)) browserTemplateCache.delete(poolKey); + while (browserTemplateCache.size >= CLAUDE_WEB_TEMPLATE_MAX) { + const oldestKey = browserTemplateCache.keys().next().value; + if (typeof oldestKey !== "string") break; + browserTemplateCache.delete(oldestKey); + } + browserTemplateCache.set(poolKey, { + template: cloneValue(template), + context, + expiresAt: now() + CLAUDE_WEB_TEMPLATE_TTL_MS, + }); +} + +function lookupBrowserTemplate(poolKey: string): CachedBrowserTemplate | null { + const entry = browserTemplateCache.get(poolKey); + if (!entry) return null; + if (now() >= entry.expiresAt) { + browserTemplateCache.delete(poolKey); + return null; + } + return { + template: cloneValue(entry.template), + context: entry.context, + expiresAt: entry.expiresAt, + }; +} + +export function buildClaudeWebBrowserPoolKey(input: { + scopeKey: string; + organizationId: string; + cookieString: string; + locale: string; + timezone: string; +}): string { + const digest = createHash("sha256") + .update( + [input.scopeKey, input.organizationId, input.cookieString, input.locale, input.timezone].join( + String.fromCharCode(31) + ) + ) + .digest("hex"); + return `claude-web:${digest}`; +} + +export function mergeClaudeWebBrowserPayload( + uiPayload: Record<string, unknown>, + preparedPayload: ClaudeWebRequestPayload +): ClaudeWebRequestPayload { + const template = extractBrowserTemplate(uiPayload); + const merged = { + ...uiPayload, + ...preparedPayload, + tools: template.tools, + personalized_styles: template.personalizedStyles, + ...(template.toolStates ? { tool_states: template.toolStates } : {}), + } as unknown as ClaudeWebRequestPayload; + + if (!("parent_message_uuid" in preparedPayload)) { + delete merged.parent_message_uuid; + } + if (!("create_conversation_params" in preparedPayload)) { + delete merged.create_conversation_params; + } + if (!template.toolStates && !("tool_states" in preparedPayload)) { + delete merged.tool_states; + } + return merged; +} + +function mergeTemplateIntoPrepared( + template: BrowserTemplate, + preparedPayload: ClaudeWebRequestPayload +): ClaudeWebRequestPayload { + return { + ...preparedPayload, + tools: cloneValue(template.tools), + personalized_styles: cloneValue(template.personalizedStyles), + ...(!("tool_states" in preparedPayload) && template.toolStates + ? { tool_states: cloneValue(template.toolStates) } + : {}), + }; +} + +export function applyClaudeWebBrowserTemplate( + request: ClaudeWebTransportRequest +): ClaudeWebTransportRequest { + if (request.payload.tools.length > 0) return request; + const cached = lookupBrowserTemplate(buildClaudeWebBrowserPoolKey(request)); + if (!cached) return request; + return { + ...request, + payload: mergeTemplateIntoPrepared(cached.template, request.payload), + }; +} + +function browserFetchHeaders(headers: Record<string, string>): Record<string, string> { + const forbidden = new Set([ + "accept-encoding", + "connection", + "content-length", + "cookie", + "host", + "origin", + "referer", + "user-agent", + ]); + const filtered: Record<string, string> = {}; + for (const [name, value] of Object.entries(headers)) { + const normalized = name.toLowerCase(); + if (forbidden.has(normalized) || normalized.startsWith("sec-")) continue; + filtered[name] = value; + } + return filtered; +} + +function makeBrowserFetchInput( + request: ClaudeWebTransportRequest, + payload: ClaudeWebRequestPayload, + capturedHeaders: Record<string, string> = {} +): ClaudeWebBrowserFetchInput { + return { + url: request.url, + headers: browserFetchHeaders({ ...capturedHeaders, ...request.headers }), + body: JSON.stringify(payload), + maxBytes: MAX_CLAUDE_WEB_BROWSER_RESPONSE_BYTES, + }; +} + +export async function fetchClaudeWebPageResponse( + page: Page, + input: ClaudeWebBrowserFetchInput +): Promise<{ status: number; headers: Record<string, string>; body: Uint8Array }> { + const captured = await page.evaluate(async ({ url, headers, body, maxBytes }) => { + const response = await fetch(url, { + method: "POST", + headers, + body, + credentials: "include", + }); + const responseHeaders = Object.fromEntries(response.headers.entries()); + const declaredLength = Number.parseInt(response.headers.get("content-length") ?? "", 10); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + await response.body?.cancel().catch(() => {}); + throw new Error("Claude Web browser response exceeded the size limit"); + } + + const reader = response.body?.getReader(); + const bodyChunks: string[] = []; + let totalBytes = 0; + const encodeBase64 = (bytes: Uint8Array): string => { + let binary = ""; + const sliceSize = 32 * 1024; + for (let offset = 0; offset < bytes.byteLength; offset += sliceSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + sliceSize)); + } + return btoa(binary); + }; + + try { + while (reader) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel().catch(() => {}); + throw new Error("Claude Web browser response exceeded the size limit"); + } + bodyChunks.push(encodeBase64(value)); + } + } finally { + try { + reader?.releaseLock(); + } catch { + // The reader may already be released after cancellation. + } + } + + return { + status: response.status, + headers: responseHeaders, + bodyChunks, + }; + }, input); + + const chunks = captured.bodyChunks.map((chunk) => Buffer.from(chunk, "base64")); + return { + status: captured.status, + headers: captured.headers, + body: Buffer.concat(chunks), + }; +} + +function throwIfAborted(signal?: AbortSignal | null): void { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); +} + +async function withAbort<T>(promise: Promise<T>, signal?: AbortSignal | null): Promise<T> { + if (!signal) return promise; + throwIfAborted(signal); + let abortListener: (() => void) | undefined; + const aborted = new Promise<never>((_, reject) => { + abortListener = () => reject(new DOMException("Aborted", "AbortError")); + signal.addEventListener("abort", abortListener, { once: true }); + }); + try { + return await Promise.race([promise, aborted]); + } finally { + if (abortListener) signal.removeEventListener("abort", abortListener); + } +} + +async function captureCompletion( + page: Page, + request: ClaudeWebTransportRequest, + poolKey: string, + context: PooledContext["context"] +): Promise<ClaudeWebBrowserFetchInput> { + let resolveInterception: ((input: ClaudeWebBrowserFetchInput) => void) | undefined; + let rejectInterception: ((error: Error) => void) | undefined; + const intercepted = new Promise<ClaudeWebBrowserFetchInput>((resolve, reject) => { + resolveInterception = resolve; + rejectInterception = reject; + }); + + const matchesPreparedRoute = (url: URL): boolean => { + if (!("create_conversation_params" in request.payload)) { + return url.toString() === request.url; + } + if (url.origin !== "https://claude.ai" || url.search || url.hash) return false; + const segments = url.pathname.split("/").filter(Boolean); + if ( + segments.length !== 6 || + segments[0] !== "api" || + segments[1] !== "organizations" || + segments[3] !== "chat_conversations" || + !segments[4] || + segments[5] !== "completion" + ) { + return false; + } + try { + return decodeURIComponent(segments[2]) === request.organizationId; + } catch { + return false; + } + }; + + await page.route(matchesPreparedRoute, async (route) => { + try { + const outgoing = route.request(); + if (outgoing.method() !== "POST" || !matchesPreparedRoute(new URL(outgoing.url()))) { + throw new Error("Claude Web browser interception target changed"); + } + const rawPayload = outgoing.postData(); + if (!rawPayload) throw new Error("Claude Web browser request body is missing"); + const uiPayload = JSON.parse(rawPayload) as unknown; + if (!uiPayload || typeof uiPayload !== "object" || Array.isArray(uiPayload)) { + throw new Error("Claude Web browser request body is invalid"); + } + const capturedHeaders = await outgoing.allHeaders(); + const template = extractBrowserTemplate(uiPayload as Record<string, unknown>); + const merged = mergeClaudeWebBrowserPayload( + uiPayload as Record<string, unknown>, + request.payload + ); + await route.abort(); + rememberBrowserTemplate(poolKey, template, context); + resolveInterception?.(makeBrowserFetchInput(request, merged, capturedHeaders)); + } catch { + await route.abort().catch(() => {}); + rejectInterception?.(new Error("Claude Web browser request interception failed")); + } + }); + + const guardedInterception = withAbort(intercepted, request.signal); + void guardedInterception.catch(() => {}); + const input = page.locator(CLAUDE_WEB_INPUT_SELECTOR).first(); + try { + await withAbort(input.waitFor({ state: "visible", timeout: 10000 }), request.signal); + await withAbort(input.fill(request.payload.prompt), request.signal); + await withAbort(page.keyboard.press("Enter"), request.signal); + return await guardedInterception; + } catch (error) { + rejectInterception?.(new Error("Claude Web browser request capture failed")); + await guardedInterception.catch(() => {}); + throw error; + } finally { + await page.unroute(matchesPreparedRoute).catch(() => {}); + } +} + +function captureRetry( + request: ClaudeWebTransportRequest, + template: BrowserTemplate +): ClaudeWebBrowserFetchInput { + const payload = mergeTemplateIntoPrepared(template, request.payload); + return makeBrowserFetchInput(request, payload); +} + +function toTransportResult(captured: { + status: number; + headers: Record<string, string>; + body: Uint8Array; +}): ClaudeWebTransportResult { + const bytes = new Uint8Array(captured.body); + return { + status: captured.status, + headers: new Headers(captured.headers), + body: new Response(bytes).body, + }; +} + +export async function sendClaudeWebBrowser( + request: ClaudeWebTransportRequest, + deps: ClaudeWebBrowserDeps = defaultDeps +): Promise<ClaudeWebTransportResult> { + verifyRequestUrl(request); + throwIfAborted(request.signal); + const poolKey = buildClaudeWebBrowserPoolKey(request); + const retryEntry = + request.endpointSuffix === "retry_completion" ? lookupBrowserTemplate(poolKey) : null; + if (request.endpointSuffix === "retry_completion" && !retryEntry) { + throw new Error("Claude Web browser retry requires a scoped UI template"); + } + + const pooled = await deps.acquireContext(poolKey, { + cookieDomain: ".claude.ai", + cookieString: request.cookieString, + warmupUrl: request.pageUrl, + locale: request.locale, + timezone: request.timezone, + proxyProviderKey: "claude-web", + }); + const page = await deps.openPage(pooled); + try { + if (retryEntry && retryEntry.context !== pooled.context) { + throw new Error("Claude Web browser context no longer matches scoped UI template"); + } + await page.goto(request.pageUrl, { + waitUntil: "domcontentloaded", + timeout: 60000, + }); + throwIfAborted(request.signal); + const fetchInput = retryEntry + ? captureRetry(request, retryEntry.template) + : await captureCompletion(page, request, poolKey, pooled.context); + const captured = await withAbort(deps.fetchResponse(page, fetchInput), request.signal); + if (captured.body.byteLength > MAX_CLAUDE_WEB_BROWSER_RESPONSE_BYTES) { + throw new Error("Claude Web browser response exceeded the size limit"); + } + return toTransportResult(captured); + } finally { + await page.close().catch(() => {}); + } +} + +export function __resetClaudeWebBrowserTemplatesForTesting(): void { + browserTemplateCache.clear(); +} + +export function __setClaudeWebBrowserNowForTesting(value: number | null): void { + testNow = value; +} diff --git a/open-sse/executors/claude-web/payload.ts b/open-sse/executors/claude-web/payload.ts index fb1e873e64..f933f977c1 100644 --- a/open-sse/executors/claude-web/payload.ts +++ b/open-sse/executors/claude-web/payload.ts @@ -5,6 +5,20 @@ import { randomUUID } from "crypto"; // Default model when not specified export const DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"; +export type ClaudeWebOperation = "completion" | "retry_completion"; + +export interface ClaudeWebTurnFields { + operation: ClaudeWebOperation; + prompt: string; + timezone: string; + locale: string; + parentMessageUuid?: string; + humanMessageUuid?: string; + assistantMessageUuid: string; + isNewConversation: boolean; + toolStates?: unknown[]; +} + export interface ClaudeWebRequestPayload { prompt: string; model: string; @@ -29,16 +43,18 @@ export interface ClaudeWebRequestPayload { type?: string; }>; turn_message_uuids: { - human_message_uuid: string; + human_message_uuid?: string; assistant_message_uuid: string; }; + parent_message_uuid?: string; attachments: unknown[]; effort: string; files: unknown[]; sync_sources: unknown[]; rendering_mode: string; thinking_mode: string; - create_conversation_params: { + tool_states?: unknown[]; + create_conversation_params?: { name: string; model: string; include_conversation_preferences: boolean; @@ -66,6 +82,34 @@ export interface ClaudeWebStreamChunk { [key: string]: unknown; } +type ClaudeWebTool = ClaudeWebRequestPayload["tools"][number]; + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function transformOpenAiTool(candidate: unknown): ClaudeWebTool | null { + if (!isRecord(candidate) || candidate.type !== "function" || !isRecord(candidate.function)) { + return null; + } + + const definition = candidate.function; + if (typeof definition.name !== "string" || !definition.name.trim()) return null; + + const description = + typeof definition.description === "string" && definition.description.trim() + ? definition.description + : undefined; + const schemaCandidate = definition.parameters ?? definition.input_schema; + const inputSchema = isRecord(schemaCandidate) ? schemaCandidate : undefined; + + return { + name: definition.name.trim(), + ...(description ? { description } : {}), + ...(inputSchema ? { input_schema: inputSchema } : {}), + }; +} + /** * Generate UUIDs for turn message tracking */ @@ -77,61 +121,22 @@ export function generateMessageUUIDs() { } /** - * Get default tool definitions for Claude Web API + * Convert caller-provided OpenAI function tools to Claude Web tool definitions. + * + * Claude's own browser tools are account- and rollout-dependent. Inventing a + * static list makes the request diverge from the authenticated UI, so only + * explicit, structurally valid caller tools are forwarded here. */ -export function getDefaultTools(): ClaudeWebRequestPayload["tools"] { - return [ - { - name: "show_widget", - description: "Display interactive widgets and visualizations", - input_schema: { - type: "object", - properties: { - widget_type: { - type: "string", - description: "Type of widget to display", - }, - }, - }, - integration_name: "visualize", - is_mcp_app: true, - }, - { - name: "read_me", - description: "Read and reference documents", - input_schema: { - type: "object", - properties: { - file_path: { - type: "string", - description: "Path to the file to read", - }, - }, - }, - integration_name: "visualize", - is_mcp_app: false, - }, - { - type: "web_search_v0", - name: "web_search", - }, - { - type: "artifacts_v0", - name: "artifacts", - }, - { - type: "repl_v0", - name: "repl", - }, - { type: "widget", name: "weather_fetch" }, - { type: "widget", name: "recipe_display_v0" }, - { type: "widget", name: "places_map_display_v0" }, - { type: "widget", name: "message_compose_v1" }, - { type: "widget", name: "ask_user_input_v0" }, - { type: "widget", name: "recommend_claude_apps" }, - { type: "widget", name: "places_search" }, - { type: "widget", name: "fetch_sports_data" }, - ]; +export function transformOpenAiTools(tools: unknown): ClaudeWebRequestPayload["tools"] { + if (!Array.isArray(tools)) return []; + + const transformed: ClaudeWebRequestPayload["tools"] = []; + for (const candidate of tools) { + const transformedTool = transformOpenAiTool(candidate); + if (transformedTool) transformed.push(transformedTool); + } + + return transformed; } /** @@ -167,19 +172,114 @@ export function getDefaultPersonalizedStyle(): ClaudeWebRequestPayload["personal * because it was never requested in the first place (#6662). */ export function wantsExtendedThinking(body: Record<string, unknown>): boolean { + return resolveClaudeWebReasoningEffort(body) !== null; +} + +const CLAUDE_WEB_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; + +/** + * Resolve the caller's explicit reasoning level into the effort values + * accepted by Claude Web. An explicit `none` disables thinking, while a + * native `thinking: { type: "enabled" }` request uses the existing low + * default when no graduated effort was supplied. + */ +export function resolveClaudeWebReasoningEffort( + body: Record<string, unknown> +): (typeof CLAUDE_WEB_REASONING_EFFORTS)[number] | null { const reasoning = body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning) ? (body.reasoning as Record<string, unknown>) : null; const effort = body.reasoning_effort ?? reasoning?.effort; - if (typeof effort === "string" && effort.trim() && effort.toLowerCase() !== "none") { - return true; + if (typeof effort === "string" && effort.trim()) { + const normalized = effort.trim().toLowerCase(); + if (normalized === "none") return null; + if ((CLAUDE_WEB_REASONING_EFFORTS as readonly string[]).includes(normalized)) { + return normalized as (typeof CLAUDE_WEB_REASONING_EFFORTS)[number]; + } } const thinking = body.thinking; if (thinking && typeof thinking === "object" && !Array.isArray(thinking)) { - if ((thinking as Record<string, unknown>).type === "enabled") return true; + if ((thinking as Record<string, unknown>).type === "enabled") return "low"; } - return false; + return null; +} + +function contentPartText(part: unknown): string { + if (!isRecord(part)) return ""; + return typeof part.text === "string" ? part.text : ""; +} + +function messageText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.map(contentPartText).filter(Boolean).join("\n"); +} + +function latestUserPrompt(messages: unknown[]): string { + let prompt = ""; + for (const candidate of messages) { + if (!isRecord(candidate) || candidate.role !== "user") continue; + prompt = messageText(candidate.content); + } + return prompt; +} + +function defaultTurn(prompt: string): ClaudeWebTurnFields { + const messageUuids = generateMessageUUIDs(); + return { + operation: "completion", + prompt, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + locale: "en-US", + humanMessageUuid: messageUuids.human_message_uuid, + assistantMessageUuid: messageUuids.assistant_message_uuid, + isNewConversation: true, + }; +} + +function createConversationParams(model: string) { + return { + name: "", + model, + include_conversation_preferences: true, + paprika_mode: null, + compass_mode: null, + is_temporary: false, + enabled_imagine: true, + tool_search_mode: "auto", + }; +} + +function buildClaudeWebPayload( + body: Record<string, unknown>, + model: string, + reasoningEffort: ReturnType<typeof resolveClaudeWebReasoningEffort>, + turn: ClaudeWebTurnFields +): ClaudeWebRequestPayload { + return { + prompt: turn.prompt, + model, + timezone: turn.timezone, + personalized_styles: getDefaultPersonalizedStyle(), + locale: turn.locale, + tools: transformOpenAiTools(body.tools), + turn_message_uuids: { + ...(turn.humanMessageUuid ? { human_message_uuid: turn.humanMessageUuid } : {}), + assistant_message_uuid: turn.assistantMessageUuid, + }, + ...(turn.parentMessageUuid ? { parent_message_uuid: turn.parentMessageUuid } : {}), + attachments: [], + effort: reasoningEffort ?? "low", + files: [], + sync_sources: [], + rendering_mode: "messages", + thinking_mode: reasoningEffort ? "extended" : "off", + ...(turn.toolStates ? { tool_states: turn.toolStates } : {}), + ...(turn.isNewConversation + ? { create_conversation_params: createConversationParams(model) } + : {}), + }; } /** @@ -187,50 +287,19 @@ export function wantsExtendedThinking(body: Record<string, unknown>): boolean { */ export function transformToClaude( body: Record<string, unknown>, - model: string + model: string, + turn?: ClaudeWebTurnFields ): ClaudeWebRequestPayload { const messages = Array.isArray(body.messages) ? body.messages : []; + const reasoningEffort = resolveClaudeWebReasoningEffort(body); + const resolvedModel = model || DEFAULT_CLAUDE_MODEL; + const resolvedTurn = turn ?? defaultTurn(latestUserPrompt(messages)); - // Extract the last user message as the prompt - let prompt = ""; - for (const msg of messages) { - if (typeof msg === "object" && msg !== null) { - const message = msg as Record<string, unknown>; - if (message.role === "user") { - prompt = String(message.content || ""); - } - } - } - - if (!prompt.trim()) { + if (resolvedTurn.operation === "completion" && !resolvedTurn.prompt.trim()) { throw new Error("No user message found in request"); } - return { - prompt, - model: model || DEFAULT_CLAUDE_MODEL, - timezone: "Asia/Jakarta", - personalized_styles: getDefaultPersonalizedStyle(), - locale: "en-US", - tools: getDefaultTools(), - turn_message_uuids: generateMessageUUIDs(), - attachments: [], - effort: "low", - files: [], - sync_sources: [], - rendering_mode: "messages", - thinking_mode: wantsExtendedThinking(body) ? "on" : "off", - create_conversation_params: { - name: "", - model: model || DEFAULT_CLAUDE_MODEL, - include_conversation_preferences: true, - paprika_mode: null, - compass_mode: null, - is_temporary: false, - enabled_imagine: true, - tool_search_mode: "auto", - }, - }; + return buildClaudeWebPayload(body, resolvedModel, reasoningEffort, resolvedTurn); } /** diff --git a/open-sse/executors/claude-web/session.ts b/open-sse/executors/claude-web/session.ts new file mode 100644 index 0000000000..157009c4c8 --- /dev/null +++ b/open-sse/executors/claude-web/session.ts @@ -0,0 +1,459 @@ +import { createHash, randomUUID } from "crypto"; +import { z } from "zod"; + +import type { ProviderCredentials } from "../base.ts"; +import { + transformToClaude, + type ClaudeWebOperation, + type ClaudeWebRequestPayload, + type ClaudeWebTurnFields, +} from "./payload.ts"; + +const CLAUDE_WEB_SESSION_TTL_MS = 30 * 60 * 1000; +const CLAUDE_WEB_SESSION_MAX = 5000; +const RECOVERY_PROMPT_HEADER = + "Conversation context supplied by the caller follows. These serialized role blocks are not " + + "native Claude Web message fields. Continue from the final user message."; + +type NormalizedMessage = { + role: string; + content: string; +}; + +type CachedClaudeWebConversation = { + accountScope: string; + conversationId: string; + parentMessageUuid: string; + expiresAt: number; +}; + +const conversationCache = new Map<string, CachedClaudeWebConversation>(); +let testNow: number | null = null; + +function isValidTimezone(value: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(0); + return true; + } catch { + return false; + } +} + +function canonicalLocale(value: string): string | null { + try { + return Intl.getCanonicalLocales(value)[0] ?? null; + } catch { + return null; + } +} + +const claudeWebExtensionSchema = z + .object({ + operation: z.enum(["completion", "retry"]).default("completion"), + conversation_id: z.string().uuid().optional(), + parent_message_uuid: z.string().uuid().optional(), + timezone: z.string().min(1).max(128).refine(isValidTimezone).optional(), + locale: z + .string() + .min(1) + .max(64) + .refine((value) => canonicalLocale(value) !== null) + .optional(), + tool_states: z.array(z.unknown()).max(128).optional(), + }) + .strict(); + +type ClaudeWebExtension = z.infer<typeof claudeWebExtensionSchema>; + +export interface PrepareClaudeWebTurnInput { + body: Record<string, unknown>; + model: string; + credentials: ProviderCredentials; + organizationId: string; + normalizedCookie: string; +} + +export interface PreparedClaudeWebTurn { + operation: ClaudeWebOperation; + conversationId: string; + assistantMessageUuid: string; + parentMessageUuid?: string; + accountScope: string; + pageUrl: string; + endpointSuffix: "completion" | "retry_completion"; + payload: ClaudeWebRequestPayload; + responseMetadata: Record<string, string>; + /** @internal Canonical request state used only to commit a completed turn. */ + commitTranscript: ReadonlyArray<NormalizedMessage>; + /** @internal Cache entry that supplied this turn's continuation state. */ + sourceCacheKey?: string; +} + +function now(): number { + return testNow ?? Date.now(); +} + +function hash(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function normalizeContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) { + if (content && typeof content === "object") { + const record = content as Record<string, unknown>; + if (typeof record.text === "string") return record.text; + if ("content" in record) return normalizeContent(record.content); + } + return ""; + } + + return content + .map((part) => { + if (typeof part === "string") return part; + if (!part || typeof part !== "object" || Array.isArray(part)) return ""; + const record = part as Record<string, unknown>; + if (typeof record.text === "string") return record.text; + if ("content" in record) return normalizeContent(record.content); + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function normalizeMessages(body: Record<string, unknown>): NormalizedMessage[] { + if (!Array.isArray(body.messages)) return []; + const normalized: NormalizedMessage[] = []; + for (const candidate of body.messages) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + const message = candidate as Record<string, unknown>; + if (typeof message.role !== "string" || !message.role.trim()) continue; + normalized.push({ + role: message.role.trim().toLowerCase(), + content: normalizeContent(message.content), + }); + } + return normalized; +} + +function canonicalizeTranscript(messages: ReadonlyArray<NormalizedMessage>): string { + return messages + .map( + ({ role, content }) => + `${role.length}:${role}${String.fromCharCode(30)}${content.length}:${content}` + ) + .join(String.fromCharCode(31)); +} + +function makeAccountScope(input: PrepareClaudeWebTurnInput): string { + const connectionId = input.credentials.connectionId?.trim(); + const credentialScope = connectionId + ? `connection:${connectionId}` + : `cookie:${hash(input.normalizedCookie)}`; + return hash( + `${credentialScope}${String.fromCharCode(31)}${input.organizationId}${String.fromCharCode(31)}${input.model}` + ); +} + +function makeCacheKey(accountScope: string, messages: ReadonlyArray<NormalizedMessage>): string { + return hash(`${accountScope}${String.fromCharCode(31)}${canonicalizeTranscript(messages)}`); +} + +function lookupCache(key: string): CachedClaudeWebConversation | null { + const entry = conversationCache.get(key); + if (!entry) return null; + if (now() >= entry.expiresAt) { + conversationCache.delete(key); + return null; + } + return entry; +} + +function pruneExpiredEntries(): void { + const currentTime = now(); + for (const [key, entry] of conversationCache) { + if (currentTime >= entry.expiresAt) conversationCache.delete(key); + } +} + +function rememberCache(key: string, entry: Omit<CachedClaudeWebConversation, "expiresAt">): void { + pruneExpiredEntries(); + if (conversationCache.has(key)) conversationCache.delete(key); + while (conversationCache.size >= CLAUDE_WEB_SESSION_MAX) { + const oldestKey = conversationCache.keys().next().value; + if (typeof oldestKey !== "string") break; + conversationCache.delete(oldestKey); + } + conversationCache.set(key, { + ...entry, + expiresAt: now() + CLAUDE_WEB_SESSION_TTL_MS, + }); +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function buildRecoveryPrompt(messages: ReadonlyArray<NormalizedMessage>): string { + const blocks = messages.map( + ({ role, content }) => `<message role="${escapeXml(role)}">\n${escapeXml(content)}\n</message>` + ); + return `${RECOVERY_PROMPT_HEADER}\n\n${blocks.join("\n\n")}`; +} + +function readExtension(body: Record<string, unknown>): ClaudeWebExtension { + return claudeWebExtensionSchema.parse(body.claude_web ?? {}); +} + +function readProviderString(credentials: ProviderCredentials, key: string): string | undefined { + const value = credentials.providerSpecificData?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function resolveTimezone(extension: ClaudeWebExtension, credentials: ProviderCredentials): string { + if (extension.timezone) return extension.timezone; + const providerTimezone = readProviderString(credentials, "timezone"); + if (providerTimezone && isValidTimezone(providerTimezone)) return providerTimezone; + const runtimeTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return runtimeTimezone && isValidTimezone(runtimeTimezone) ? runtimeTimezone : "UTC"; +} + +function resolveLocale(extension: ClaudeWebExtension, credentials: ProviderCredentials): string { + if (extension.locale) return canonicalLocale(extension.locale) ?? "en-US"; + const providerLocale = readProviderString(credentials, "locale"); + const canonicalProviderLocale = providerLocale ? canonicalLocale(providerLocale) : null; + if (canonicalProviderLocale) return canonicalProviderLocale; + const runtimeLocale = Intl.DateTimeFormat().resolvedOptions().locale; + return (runtimeLocale && canonicalLocale(runtimeLocale)) || "en-US"; +} + +function readLegacyConversationId(credentials: ProviderCredentials): string | undefined { + const candidate = (credentials as ProviderCredentials & { conversationId?: unknown }) + .conversationId; + return typeof candidate === "string" && candidate.trim() ? candidate.trim() : undefined; +} + +function latestUserIndex(messages: ReadonlyArray<NormalizedMessage>): number { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index].role === "user") return index; + } + return -1; +} + +function retryCommitTranscript(messages: ReadonlyArray<NormalizedMessage>): NormalizedMessage[] { + const copy = messages.map((message) => ({ ...message })); + if (copy.at(-1)?.role === "assistant") copy.pop(); + return copy; +} + +interface PrepareTurnContext { + extension: ClaudeWebExtension; + messages: NormalizedMessage[]; + accountScope: string; + timezone: string; + locale: string; + explicitConversationId?: string; + explicitParentMessageUuid?: string; + assistantMessageUuid: string; +} + +function prepareTurnContext(input: PrepareClaudeWebTurnInput): PrepareTurnContext { + const extension = readExtension(input.body); + return { + extension, + messages: normalizeMessages(input.body), + accountScope: makeAccountScope(input), + timezone: resolveTimezone(extension, input.credentials), + locale: resolveLocale(extension, input.credentials), + explicitConversationId: + extension.conversation_id ?? readLegacyConversationId(input.credentials), + explicitParentMessageUuid: extension.parent_message_uuid, + assistantMessageUuid: randomUUID(), + }; +} + +function prepareRetryTurn( + input: PrepareClaudeWebTurnInput, + context: PrepareTurnContext +): PreparedClaudeWebTurn { + const retryKey = makeCacheKey(context.accountScope, context.messages); + const cached = lookupCache(retryKey); + const conversationId = context.explicitConversationId ?? cached?.conversationId; + const parentMessageUuid = context.explicitParentMessageUuid ?? cached?.parentMessageUuid; + if (!conversationId || !parentMessageUuid) { + throw new Error("Claude Web retry requires both conversation and parent message state"); + } + + const operation: ClaudeWebOperation = "retry_completion"; + const turnFields: ClaudeWebTurnFields = { + operation, + prompt: "", + timezone: context.timezone, + locale: context.locale, + parentMessageUuid, + assistantMessageUuid: context.assistantMessageUuid, + isNewConversation: false, + toolStates: context.extension.tool_states, + }; + const responseMetadata = { + operation, + conversation_id: conversationId, + parent_message_uuid: parentMessageUuid, + assistant_message_uuid: context.assistantMessageUuid, + }; + + return { + operation, + conversationId, + assistantMessageUuid: context.assistantMessageUuid, + parentMessageUuid, + accountScope: context.accountScope, + pageUrl: `https://claude.ai/chat/${encodeURIComponent(conversationId)}`, + endpointSuffix: "retry_completion", + payload: transformToClaude(input.body, input.model, turnFields), + responseMetadata, + commitTranscript: retryCommitTranscript(context.messages), + ...(cached ? { sourceCacheKey: retryKey } : {}), + }; +} + +interface CompletionConversationState { + conversationId: string; + parentMessageUuid?: string; + isNewConversation: boolean; + sourceCacheKey?: string; +} + +function resolveCompletionConversation( + context: PrepareTurnContext, + userIndex: number +): CompletionConversationState { + const lookupKey = makeCacheKey(context.accountScope, context.messages.slice(0, userIndex)); + const cached = lookupCache(lookupKey); + const cacheMatchesExplicitConversation = + !context.explicitConversationId || cached?.conversationId === context.explicitConversationId; + const cachedParent = cacheMatchesExplicitConversation ? cached?.parentMessageUuid : undefined; + const conversationId = context.explicitConversationId ?? cached?.conversationId ?? randomUUID(); + const parentMessageUuid = context.explicitParentMessageUuid ?? cachedParent; + + if ( + context.explicitParentMessageUuid && + !context.explicitConversationId && + !cached?.conversationId + ) { + throw new Error("Claude Web parent message state requires a conversation"); + } + + return { + conversationId, + parentMessageUuid, + isNewConversation: !parentMessageUuid, + ...(cached && cacheMatchesExplicitConversation ? { sourceCacheKey: lookupKey } : {}), + }; +} + +function prepareCompletionTurn( + input: PrepareClaudeWebTurnInput, + context: PrepareTurnContext +): PreparedClaudeWebTurn { + const userIndex = latestUserIndex(context.messages); + if (userIndex < 0 || !context.messages[userIndex].content.trim()) { + throw new Error("No user message found in request"); + } + + const conversation = resolveCompletionConversation(context, userIndex); + const prompt = + conversation.isNewConversation && context.messages.length > 1 + ? buildRecoveryPrompt(context.messages) + : context.messages[userIndex].content; + const humanMessageUuid = randomUUID(); + const operation: ClaudeWebOperation = "completion"; + const turnFields: ClaudeWebTurnFields = { + operation, + prompt, + timezone: context.timezone, + locale: context.locale, + parentMessageUuid: conversation.parentMessageUuid, + humanMessageUuid, + assistantMessageUuid: context.assistantMessageUuid, + isNewConversation: conversation.isNewConversation, + toolStates: context.extension.tool_states, + }; + const responseMetadata = { + operation, + conversation_id: conversation.conversationId, + ...(conversation.parentMessageUuid + ? { parent_message_uuid: conversation.parentMessageUuid } + : {}), + assistant_message_uuid: context.assistantMessageUuid, + }; + + return { + operation, + conversationId: conversation.conversationId, + assistantMessageUuid: context.assistantMessageUuid, + ...(conversation.parentMessageUuid + ? { parentMessageUuid: conversation.parentMessageUuid } + : {}), + accountScope: context.accountScope, + pageUrl: conversation.isNewConversation + ? "https://claude.ai/new" + : `https://claude.ai/chat/${encodeURIComponent(conversation.conversationId)}`, + endpointSuffix: "completion", + payload: transformToClaude(input.body, input.model, turnFields), + responseMetadata, + commitTranscript: context.messages.map((message) => ({ ...message })), + ...(conversation.sourceCacheKey ? { sourceCacheKey: conversation.sourceCacheKey } : {}), + }; +} + +export function prepareClaudeWebTurn(input: PrepareClaudeWebTurnInput): PreparedClaudeWebTurn { + const context = prepareTurnContext(input); + return context.extension.operation === "retry" + ? prepareRetryTurn(input, context) + : prepareCompletionTurn(input, context); +} + +export function commitClaudeWebTurn(turn: PreparedClaudeWebTurn, assistantText: string): void { + const completedTranscript = [ + ...turn.commitTranscript.map((message) => ({ ...message })), + { role: "assistant", content: assistantText }, + ]; + const cacheKey = makeCacheKey(turn.accountScope, completedTranscript); + if (turn.operation === "retry_completion" && turn.sourceCacheKey) { + conversationCache.delete(turn.sourceCacheKey); + } + rememberCache(cacheKey, { + accountScope: turn.accountScope, + conversationId: turn.conversationId, + parentMessageUuid: turn.assistantMessageUuid, + }); +} + +export function invalidateClaudeWebTurn( + turn: PreparedClaudeWebTurn, + scope: "turn" | "conversation" = "turn" +): void { + // A failed in-flight branch has not committed cache state, so its normal + // invalidation is intentionally a no-op. Authentication failures are the + // only callers that invalidate every cached branch for the conversation. + if (scope === "turn") return; + for (const [key, entry] of conversationCache) { + if (entry.accountScope === turn.accountScope && entry.conversationId === turn.conversationId) { + conversationCache.delete(key); + } + } +} + +export function __resetClaudeWebSessionForTesting(): void { + conversationCache.clear(); +} + +export function __setClaudeWebSessionNowForTesting(value: number | null): void { + testNow = value; +} diff --git a/open-sse/executors/claude-web/stream.ts b/open-sse/executors/claude-web/stream.ts new file mode 100644 index 0000000000..e880cace9d --- /dev/null +++ b/open-sse/executors/claude-web/stream.ts @@ -0,0 +1,686 @@ +import { randomUUID } from "crypto"; + +import { buildErrorBody } from "../../utils/error.ts"; +import type { ExecutorLog } from "../base.ts"; + +export interface ClaudeWebStreamOptions { + model: string; + stream: boolean; + responseMetadata: Record<string, string>; + onComplete(result: { assistantText: string; stopReason: string }): void; + onFailure(): void; + log?: ExecutorLog | null; +} + +type StreamPhase = "awaiting_message" | "in_message" | "stopped" | "failed"; +type BlockKind = "thinking" | "text" | "other"; +const MAX_CLAUDE_WEB_SSE_PENDING_CHARS = 1024 * 1024; +type SemanticEvent = + | { kind: "content"; text: string } + | { kind: "reasoning"; text: string } + | { kind: "metadata"; eventType: string; data: Record<string, unknown> } + | { kind: "finish"; stopReason: string }; + +const KNOWN_METADATA_EVENTS = new Set([ + "ping", + "completion", + "message_limit", + "content_block_retract", + "model_fallback", + "model_update", + "compaction_status", + "conversation_ready", + "cache_performance", + "tool_approval", +]); + +const METADATA_EVENT_FIELDS: Record<string, readonly string[]> = { + ping: ["latency_ms"], + completion: [], + message_limit: ["remaining", "limit", "reset_at"], + content_block_retract: ["index"], + model_fallback: ["model", "fallback_model"], + model_update: ["model"], + compaction_status: ["status"], + conversation_ready: ["status"], + cache_performance: ["hit", "read_tokens", "write_tokens"], + tool_approval: ["status"], +}; + +interface StreamControl { + reader: ReadableStreamDefaultReader<Uint8Array> | null; + cancelled: boolean; +} + +class ClaudeWebProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "ClaudeWebProtocolError"; + } +} + +async function* decodeSseData( + source: ReadableStream<Uint8Array>, + control: StreamControl +): AsyncGenerator<string, void, void> { + const reader = source.getReader(); + control.reader = reader; + const decoder = new TextDecoder(); + let buffer = ""; + let dataLines: string[] = []; + let dataChars = 0; + let reachedEof = false; + + const consumeLine = (rawLine: string): string | null => { + if (rawLine.length > MAX_CLAUDE_WEB_SSE_PENDING_CHARS) { + throw new ClaudeWebProtocolError("SSE line exceeded the size limit"); + } + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + if (line === "") { + if (dataLines.length === 0) return null; + const data = dataLines.join("\n"); + dataLines = []; + dataChars = 0; + return data; + } + if (line.startsWith(":")) return null; + + const separatorIndex = line.indexOf(":"); + const field = separatorIndex < 0 ? line : line.slice(0, separatorIndex); + let value = separatorIndex < 0 ? "" : line.slice(separatorIndex + 1); + if (value.startsWith(" ")) value = value.slice(1); + if (field === "data") { + dataChars += value.length + (dataLines.length > 0 ? 1 : 0); + if (dataChars > MAX_CLAUDE_WEB_SSE_PENDING_CHARS) { + throw new ClaudeWebProtocolError("SSE event exceeded the size limit"); + } + dataLines.push(value); + } + return null; + }; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + reachedEof = true; + break; + } + buffer += decoder.decode(value, { stream: true }); + + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex >= 0) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + const frame = consumeLine(line); + if (frame !== null) yield frame; + newlineIndex = buffer.indexOf("\n"); + } + if (buffer.length > MAX_CLAUDE_WEB_SSE_PENDING_CHARS) { + throw new ClaudeWebProtocolError("SSE line exceeded the size limit"); + } + } + + buffer += decoder.decode(); + if (buffer) { + const frame = consumeLine(buffer); + if (frame !== null) yield frame; + } + const finalFrame = consumeLine(""); + if (finalFrame !== null) yield finalFrame; + } finally { + if (!reachedEof) await reader.cancel().catch(() => {}); + if (control.reader === reader) control.reader = null; + try { + reader.releaseLock(); + } catch { + // The source may already have released its reader after an abort. + } + } +} + +function safeMetadataValue(value: unknown): string | number | boolean | null | undefined { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.length <= 128 && /^[A-Za-z0-9._:+/@-]+$/.test(value)) { + return value; + } + return undefined; +} + +function projectMetadataEvent( + eventType: string, + event: Record<string, unknown> +): Record<string, unknown> { + const projected: Record<string, unknown> = { type: eventType }; + for (const field of METADATA_EVENT_FIELDS[eventType] ?? []) { + const value = safeMetadataValue(event[field]); + if (value !== undefined) projected[field] = value; + } + return projected; +} + +function requireRecord(value: unknown, context: string): Record<string, unknown> { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ClaudeWebProtocolError(`${context} must be an object`); + } + return value as Record<string, unknown>; +} + +function requireBlockIndex(event: Record<string, unknown>): number { + if (!Number.isInteger(event.index) || (event.index as number) < 0) { + throw new ClaudeWebProtocolError("Content block index is invalid"); + } + return event.index as number; +} + +function deltaText(delta: Record<string, unknown>, fields: string[]): string { + for (const field of fields) { + const value = delta[field]; + if (typeof value === "string") return value; + } + throw new ClaudeWebProtocolError("Content delta text is invalid"); +} + +interface ProtocolState { + phase: StreamPhase; + openBlocks: Map<number, BlockKind>; + stopReason: string; +} + +function protocolFailure(state: ProtocolState, message: string): never { + state.phase = "failed"; + throw new ClaudeWebProtocolError(message); +} + +function assertInMessage( + state: ProtocolState, + eventType: string, + blocksMustBeClosed = false +): void { + if (state.phase !== "in_message" || (blocksMustBeClosed && state.openBlocks.size > 0)) { + protocolFailure(state, `${eventType} is out of order`); + } +} + +function parseProtocolEvent( + data: string, + state: ProtocolState +): { event: Record<string, unknown>; eventType: string } { + let event: Record<string, unknown>; + try { + event = requireRecord(JSON.parse(data), "SSE event"); + } catch (error) { + if (error instanceof ClaudeWebProtocolError) throw error; + protocolFailure(state, "SSE event contains malformed JSON"); + } + + const eventType = event.type; + if (typeof eventType !== "string" || !eventType) { + protocolFailure(state, "SSE event type is missing"); + } + return { event, eventType }; +} + +function handleMessageStart(state: ProtocolState): null { + if (state.phase !== "awaiting_message") { + protocolFailure(state, "message_start is out of order"); + } + state.phase = "in_message"; + return null; +} + +function blockKind(block: Record<string, unknown>): BlockKind { + if (block.type === "thinking") return "thinking"; + if (block.type === "text") return "text"; + return "other"; +} + +function handleContentBlockStart( + event: Record<string, unknown>, + state: ProtocolState +): SemanticEvent | null { + assertInMessage(state, "content_block_start"); + const index = requireBlockIndex(event); + if (state.openBlocks.has(index)) protocolFailure(state, "Content block was opened twice"); + + const kind = blockKind(requireRecord(event.content_block, "content_block")); + state.openBlocks.set(index, kind); + return kind === "thinking" ? { kind: "reasoning", text: "" } : null; +} + +function handleContentBlockDelta( + event: Record<string, unknown>, + state: ProtocolState +): SemanticEvent { + assertInMessage(state, "content_block_delta"); + const block = state.openBlocks.get(requireBlockIndex(event)); + if (!block) protocolFailure(state, "Content delta has no open block"); + + const delta = requireRecord(event.delta, "delta"); + if (delta.type === "text_delta" && block === "text") { + return { kind: "content", text: deltaText(delta, ["text"]) }; + } + if (delta.type === "thinking_delta" && block === "thinking") { + return { kind: "reasoning", text: deltaText(delta, ["thinking", "text"]) }; + } + if (delta.type === "thinking_summary_delta" && block === "thinking") { + return { kind: "reasoning", text: deltaText(delta, ["summary", "text", "thinking"]) }; + } + return protocolFailure(state, "Content delta type does not match its block"); +} + +function handleContentBlockStop(event: Record<string, unknown>, state: ProtocolState): null { + assertInMessage(state, "content_block_stop"); + if (!state.openBlocks.delete(requireBlockIndex(event))) { + protocolFailure(state, "Content block stop has no open block"); + } + return null; +} + +function handleMessageDelta(event: Record<string, unknown>, state: ProtocolState): null { + assertInMessage(state, "message_delta", true); + const delta = requireRecord(event.delta, "message_delta.delta"); + const stopReason = delta.stop_reason; + if (stopReason === null || stopReason === undefined) return null; + if (typeof stopReason !== "string" || !stopReason) { + protocolFailure(state, "Stop reason is invalid"); + } + state.stopReason = stopReason; + return null; +} + +function handleMessageStop(state: ProtocolState): SemanticEvent { + assertInMessage(state, "message_stop", true); + state.phase = "stopped"; + return { kind: "finish", stopReason: state.stopReason }; +} + +function dispatchProtocolEvent( + eventType: string, + event: Record<string, unknown>, + state: ProtocolState +): SemanticEvent | null { + switch (eventType) { + case "message_start": + return handleMessageStart(state); + case "content_block_start": + return handleContentBlockStart(event, state); + case "content_block_delta": + return handleContentBlockDelta(event, state); + case "content_block_stop": + return handleContentBlockStop(event, state); + case "message_delta": + return handleMessageDelta(event, state); + case "message_stop": + return handleMessageStop(state); + case "error": + return protocolFailure(state, "Upstream reported a stream error"); + default: + return protocolFailure(state, "Unknown Claude Web stream event"); + } +} + +async function* parseClaudeWebEvents( + source: ReadableStream<Uint8Array>, + control: StreamControl +): AsyncGenerator<SemanticEvent, void, void> { + const state: ProtocolState = { + phase: "awaiting_message", + openBlocks: new Map(), + stopReason: "end_turn", + }; + + for await (const data of decodeSseData(source, control)) { + if (data === "[DONE]") { + protocolFailure(state, "DONE arrived before message_stop"); + } + + const { event, eventType } = parseProtocolEvent(data, state); + if (KNOWN_METADATA_EVENTS.has(eventType)) { + yield { kind: "metadata", eventType, data: projectMetadataEvent(eventType, event) }; + continue; + } + + const semanticEvent = dispatchProtocolEvent(eventType, event, state); + if (!semanticEvent) continue; + yield semanticEvent; + if (semanticEvent.kind === "finish") return; + } + + if (control.cancelled) return; + throw new ClaudeWebProtocolError("Claude Web stream ended before message_stop"); +} + +function openAiFinishReason(stopReason: string): string { + if (stopReason === "max_tokens") return "length"; + if (stopReason === "tool_use") return "tool_calls"; + return "stop"; +} + +function makeChunk( + id: string, + created: number, + options: ClaudeWebStreamOptions, + delta: Record<string, unknown>, + finishReason: string | null, + event?: { type: string; data: Record<string, unknown> } +): Record<string, unknown> { + return { + id, + object: "chat.completion.chunk", + created, + model: options.model, + choices: [ + { + index: 0, + delta, + finish_reason: finishReason, + logprobs: null, + }, + ], + claude_web: { + ...options.responseMetadata, + ...(event ? { event } : {}), + }, + }; +} + +function protocolErrorBody(): Record<string, unknown> { + const body = buildErrorBody(502, "Claude Web stream protocol error"); + body.error.type = "upstream_protocol_error"; + body.error.code = "claude_web_protocol_error"; + return body as unknown as Record<string, unknown>; +} + +function responseHeaders(contentType: string, metadata: Record<string, string>): Headers { + const headers = new Headers({ + "Content-Type": contentType, + "Cache-Control": "no-cache", + }); + const headerNames: Record<string, string> = { + operation: "X-OmniRoute-Claude-Web-Operation", + conversation_id: "X-OmniRoute-Claude-Web-Conversation-Id", + parent_message_uuid: "X-OmniRoute-Claude-Web-Parent-Message-Uuid", + assistant_message_uuid: "X-OmniRoute-Claude-Web-Assistant-Message-Uuid", + }; + for (const [key, headerName] of Object.entries(headerNames)) { + const value = metadata[key]; + if (value) headers.set(headerName, value.replace(/[\r\n]/g, "")); + } + return headers; +} + +function notifyFailure(options: ClaudeWebStreamOptions): void { + try { + options.onFailure(); + } catch { + options.log?.error?.("CLAUDE-WEB-STREAM", "Failure callback threw an error"); + } +} + +function notifyComplete( + options: ClaudeWebStreamOptions, + result: { assistantText: string; stopReason: string } +): void { + try { + options.onComplete(result); + } catch { + options.log?.error?.("CLAUDE-WEB-STREAM", "Completion callback threw an error"); + } +} + +async function createBufferedResponse( + source: ReadableStream<Uint8Array>, + options: ClaudeWebStreamOptions +): Promise<Response> { + const id = `chatcmpl-${randomUUID()}`; + const created = Math.floor(Date.now() / 1000); + let assistantText = ""; + let reasoningText = ""; + let stopReason = "end_turn"; + const metadataEvents: Array<{ type: string; data: Record<string, unknown> }> = []; + const control: StreamControl = { reader: null, cancelled: false }; + + try { + for await (const event of parseClaudeWebEvents(source, control)) { + if (event.kind === "content") assistantText += event.text; + if (event.kind === "reasoning") reasoningText += event.text; + if (event.kind === "metadata") { + metadataEvents.push({ type: event.eventType, data: event.data }); + } + if (event.kind === "finish") stopReason = event.stopReason; + } + notifyComplete(options, { assistantText, stopReason }); + return new Response( + JSON.stringify({ + id, + object: "chat.completion", + created, + model: options.model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: assistantText, + ...(reasoningText ? { reasoning_content: reasoningText } : {}), + }, + finish_reason: openAiFinishReason(stopReason), + logprobs: null, + }, + ], + claude_web: { + ...options.responseMetadata, + events: metadataEvents, + }, + }), + { + status: 200, + headers: responseHeaders("application/json", options.responseMetadata), + } + ); + } catch { + options.log?.error?.("CLAUDE-WEB-STREAM", "Claude Web stream protocol validation failed"); + notifyFailure(options); + return new Response(JSON.stringify(protocolErrorBody()), { + status: 502, + headers: responseHeaders("application/json", options.responseMetadata), + }); + } +} + +interface StreamingState { + encoder: TextEncoder; + id: string; + created: number; + control: StreamControl; + iterator: AsyncIterator<SemanticEvent, void, void>; + pendingChunks: Uint8Array[]; + assistantText: string; + outcome: "pending" | "completed" | "failed"; + terminal: boolean; + closed: boolean; +} + +function encodeStreamEvent(state: StreamingState, value: Record<string, unknown>): Uint8Array { + return state.encoder.encode(`data: ${JSON.stringify(value)}\n\n`); +} + +function failStreamOnce(state: StreamingState, options: ClaudeWebStreamOptions): void { + if (state.outcome !== "pending") return; + state.outcome = "failed"; + notifyFailure(options); +} + +function closeStreamIfDrained( + state: StreamingState, + controller: ReadableStreamDefaultController<Uint8Array> +): void { + if (!state.closed && state.terminal && state.pendingChunks.length === 0) { + state.closed = true; + controller.close(); + } +} + +function flushStreamChunk( + state: StreamingState, + controller: ReadableStreamDefaultController<Uint8Array> +): boolean { + const chunk = state.pendingChunks.shift(); + if (!chunk) { + closeStreamIfDrained(state, controller); + return false; + } + controller.enqueue(chunk); + closeStreamIfDrained(state, controller); + return true; +} + +async function queueSemanticEvent( + state: StreamingState, + event: SemanticEvent, + options: ClaudeWebStreamOptions +): Promise<void> { + if (event.kind === "content") { + state.assistantText += event.text; + state.pendingChunks.push( + encodeStreamEvent( + state, + makeChunk(state.id, state.created, options, { content: event.text }, null) + ) + ); + return; + } + if (event.kind === "reasoning") { + state.pendingChunks.push( + encodeStreamEvent( + state, + makeChunk(state.id, state.created, options, { reasoning_content: event.text }, null) + ) + ); + return; + } + if (event.kind === "metadata") { + state.pendingChunks.push( + encodeStreamEvent( + state, + makeChunk(state.id, state.created, options, {}, null, { + type: event.eventType, + data: event.data, + }) + ) + ); + return; + } + + await state.iterator.return?.(); + state.outcome = "completed"; + notifyComplete(options, { assistantText: state.assistantText, stopReason: event.stopReason }); + state.pendingChunks.push( + encodeStreamEvent( + state, + makeChunk(state.id, state.created, options, {}, openAiFinishReason(event.stopReason)) + ) + ); + state.pendingChunks.push(state.encoder.encode("data: [DONE]\n\n")); + state.terminal = true; +} + +function queueStreamFailure(state: StreamingState, options: ClaudeWebStreamOptions): void { + options.log?.error?.("CLAUDE-WEB-STREAM", "Claude Web stream protocol validation failed"); + failStreamOnce(state, options); + state.pendingChunks.push(encodeStreamEvent(state, protocolErrorBody())); + state.pendingChunks.push(state.encoder.encode("data: [DONE]\n\n")); + state.terminal = true; +} + +async function pullStreamingChunk( + state: StreamingState, + options: ClaudeWebStreamOptions, + controller: ReadableStreamDefaultController<Uint8Array> +): Promise<void> { + if (state.closed || flushStreamChunk(state, controller)) return; + if (state.terminal) { + closeStreamIfDrained(state, controller); + return; + } + + try { + while (!state.terminal) { + const next = await state.iterator.next(); + if (state.control.cancelled) return; + if (next.done) { + throw new ClaudeWebProtocolError("Claude Web stream ended without a terminal event"); + } + await queueSemanticEvent(state, next.value, options); + if (state.pendingChunks.length > 0) { + flushStreamChunk(state, controller); + return; + } + } + } catch { + if (state.control.cancelled) return; + queueStreamFailure(state, options); + flushStreamChunk(state, controller); + } +} + +async function cancelStreaming( + state: StreamingState, + options: ClaudeWebStreamOptions, + reason: unknown +): Promise<void> { + if (state.closed) return; + state.terminal = true; + state.control.cancelled = true; + failStreamOnce(state, options); + if (state.control.reader) await state.control.reader.cancel(reason).catch(() => {}); + try { + await state.iterator.return?.(); + } catch { + // Cancellation is best-effort; the source reader was already cancelled. + } + state.closed = true; +} + +function createStreamingResponse( + source: ReadableStream<Uint8Array>, + options: ClaudeWebStreamOptions +): Response { + const control: StreamControl = { reader: null, cancelled: false }; + const state: StreamingState = { + encoder: new TextEncoder(), + id: `chatcmpl-${randomUUID()}`, + created: Math.floor(Date.now() / 1000), + control, + iterator: parseClaudeWebEvents(source, control)[Symbol.asyncIterator](), + pendingChunks: [], + assistantText: "", + outcome: "pending", + terminal: false, + closed: false, + }; + + const output = new ReadableStream<Uint8Array>({ + pull(controller) { + return pullStreamingChunk(state, options, controller); + }, + cancel(reason) { + return cancelStreaming(state, options, reason); + }, + }); + + const headers = responseHeaders("text/event-stream", options.responseMetadata); + headers.set("Connection", "keep-alive"); + return new Response(output, { status: 200, headers }); +} + +export async function createClaudeWebResponse( + source: ReadableStream<Uint8Array>, + options: ClaudeWebStreamOptions +): Promise<Response> { + return options.stream + ? createStreamingResponse(source, options) + : createBufferedResponse(source, options); +} diff --git a/open-sse/executors/claude-web/transport.ts b/open-sse/executors/claude-web/transport.ts new file mode 100644 index 0000000000..d659b76855 --- /dev/null +++ b/open-sse/executors/claude-web/transport.ts @@ -0,0 +1,104 @@ +import { FETCH_TIMEOUT_MS } from "../../config/constants.ts"; +import { + tlsFetchClaude, + type TlsFetchOptions, + type TlsFetchResult, +} from "../../services/claudeTlsClient.ts"; +import type { ClaudeWebTransportRequest, ClaudeWebTransportResult } from "./browserTransport.ts"; + +const MAX_ERROR_BODY_BYTES = 64 * 1024; + +export interface ClaudeWebDirectDeps { + tlsFetch(url: string, options: TlsFetchOptions): Promise<TlsFetchResult>; +} + +const defaultDeps: ClaudeWebDirectDeps = { + tlsFetch: tlsFetchClaude, +}; + +function expectedRequestUrl(request: ClaudeWebTransportRequest): string { + return ( + "https://claude.ai/api/organizations/" + + `${encodeURIComponent(request.organizationId)}/chat_conversations/` + + `${encodeURIComponent(request.conversationId)}/${request.endpointSuffix}` + ); +} + +async function readErrorBody( + body: ReadableStream<Uint8Array> | null, + text: string | null +): Promise<string> { + if (!body) return text ?? ""; + const reader = body.getReader(); + const decoder = new TextDecoder(); + let total = 0; + let output = ""; + try { + while (total < MAX_ERROR_BODY_BYTES) { + const { value, done } = await reader.read(); + if (done || !value) break; + const remaining = MAX_ERROR_BODY_BYTES - total; + const chunk = value.byteLength > remaining ? value.slice(0, remaining) : value; + total += chunk.byteLength; + output += decoder.decode(chunk, { stream: total < MAX_ERROR_BODY_BYTES }); + if (chunk.byteLength < value.byteLength) break; + } + output += decoder.decode(); + return output; + } finally { + await reader.cancel().catch(() => {}); + try { + reader.releaseLock(); + } catch { + // The stream may already have released the reader after cancel(). + } + } +} + +export function isClaudeWebChallenge(result: ClaudeWebTransportResult): boolean { + if (result.status !== 403) return false; + if (result.headers.get("cf-mitigated")?.toLowerCase() === "challenge") return true; + const body = result.bodyText ?? ""; + return ( + /<title>\s*Just a moment/i.test(body) || + /<title>\s*Attention Required/i.test(body) || + /\b(?:cf-chl|challenge-platform)\b/i.test(body) + ); +} + +export async function sendClaudeWebDirect( + request: ClaudeWebTransportRequest, + deps: ClaudeWebDirectDeps = defaultDeps +): Promise<ClaudeWebTransportResult> { + if (request.url !== expectedRequestUrl(request)) { + throw new Error("Claude Web direct request endpoint does not match prepared state"); + } + + const response = await deps.tlsFetch(request.url, { + method: "POST", + headers: { + ...request.headers, + Cookie: request.cookieString, + }, + body: JSON.stringify(request.payload), + timeoutMs: FETCH_TIMEOUT_MS, + stream: true, + signal: request.signal, + }); + + if (response.status >= 200 && response.status < 300) { + return { + status: response.status, + headers: response.headers, + body: response.body, + }; + } + + const bodyText = await readErrorBody(response.body, response.text); + return { + status: response.status, + headers: response.headers, + body: bodyText ? new Response(bodyText).body : null, + bodyText, + }; +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 0218a1eed2..5cab182a16 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -34,7 +34,6 @@ import { AuggieExecutor } from "./auggie.ts"; import { DeepSeekWebExecutor } from "./deepseek-web.ts"; import { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts"; import { AdaptaWebExecutor } from "./adapta-web.ts"; -import { ClaudeWebWithAutoRefresh } from "./claude-web-with-auto-refresh.ts"; import { CopilotWebExecutor } from "./copilot-web.ts"; import { CopilotM365WebExecutor } from "./copilot-m365-web.ts"; import { MicrosoftDesignerWebExecutor } from "./microsoft-designer-web.ts"; @@ -109,8 +108,8 @@ const executors = { "perplexity-web": new PerplexityWebExecutor(), "pplx-web": new PerplexityWebExecutor(), // Alias "grok-web": new GrokWebExecutor(), - "claude-web": new ClaudeWebWithAutoRefresh(), - "cw-web": new ClaudeWebWithAutoRefresh(), // Alias + "claude-web": new ClaudeWebExecutor(), + "cw-web": new ClaudeWebExecutor(), // Alias "gemini-web": new GeminiWebExecutor(), gweb: new GeminiWebExecutor(), // Alias "gemini-business": new GeminiBusinessExecutor(), diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index 27dfba51d8..9c07f72ff5 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -9,7 +9,7 @@ * so the server rejects the request. * * This pool keeps one Chromium instance warm and serves "browser contexts" - * (one per provider) on demand. Each context owns one or more pages; the + * (one per caller-defined isolation key) on demand. Each context owns one or more pages; the * caller is expected to be polite (one page per request, close on done). * * The pool prefers `cloakbrowser` (npm) when available — its binary-level @@ -38,6 +38,7 @@ export interface BrowserPoolContextOptions { locale?: string; timezone?: string; preferCloakbrowser?: boolean; + proxyProviderKey?: string; } export interface PooledContext { @@ -149,8 +150,7 @@ function evictStaleContexts(): void { for (const [key, pooled] of state.contexts) { if (now - pooled.lastUsed > CONTEXT_TTL_MS) { console.log( - "[BrowserPool] Evicted stale context:", - key, + "[BrowserPool] Evicted stale context", "(idle", ((now - pooled.lastUsed) / 1000).toFixed(0) + "s)" ); @@ -214,6 +214,14 @@ export async function resolvePlaywrightProxy( } } +export async function resolveBrowserContextProxy( + contextKey: string, + options: Pick<BrowserPoolContextOptions, "proxyProviderKey">, + deps?: ResolvePlaywrightProxyDeps +): Promise<import("playwright").LaunchOptions["proxy"] | undefined> { + return resolvePlaywrightProxy(options.proxyProviderKey ?? contextKey, deps); +} + async function launchBrowser(): Promise<Browser> { if (state.browser) return state.browser; if (state.launching) return state.launching; @@ -329,7 +337,10 @@ export async function acquireBrowserContext( if (pending) return pending; const createPromise = (async (): Promise<PooledContext> => { - const [browser, proxy] = await Promise.all([launchBrowser(), resolvePlaywrightProxy(key)]); + const [browser, proxy] = await Promise.all([ + launchBrowser(), + resolveBrowserContextProxy(key, options), + ]); const isStealth = state.cloakLaunch !== null; const context = await browser.newContext({ userAgent: options.userAgent || DEFAULT_USER_AGENT, diff --git a/open-sse/services/claudeTlsClient.ts b/open-sse/services/claudeTlsClient.ts index 0e785af9f5..3e8d90a199 100644 --- a/open-sse/services/claudeTlsClient.ts +++ b/open-sse/services/claudeTlsClient.ts @@ -20,7 +20,8 @@ import { randomUUID } from "node:crypto"; let clientPromise: Promise<unknown> | null = null; let exitHookInstalled = false; -const CLAUDE_PROFILE = "chrome_146"; // closest supported wreq-js profile (chrome_149 absent in 2.3.1, #5591) +export const CLAUDE_TLS_BROWSER_MAJOR_VERSION = "146"; +const CLAUDE_PROFILE = `chrome_${CLAUDE_TLS_BROWSER_MAJOR_VERSION}`; const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS || "", 10) || 60_000; // Grace period added to the binding's wire-level timeout before our JS-level @@ -246,7 +247,7 @@ export function __setTlsFetchOverrideForTesting(fn: typeof testOverride): void { } /** - * Make a single HTTP request to claude.ai with a Firefox-like TLS fingerprint. + * Make a single HTTP request to claude.ai with the configured Chrome TLS profile. * * Throws TlsClientUnavailableError if the native binary failed to load. */ diff --git a/scripts/check/check-fabricated-docs.mjs b/scripts/check/check-fabricated-docs.mjs index 13dcc42ab1..a1516cc42b 100644 --- a/scripts/check/check-fabricated-docs.mjs +++ b/scripts/check/check-fabricated-docs.mjs @@ -402,9 +402,9 @@ function allScanFiles(root = ROOT) { const files = []; for (const p of SCAN_PATHS) walkMarkdown(p, files, root); return files.filter((f) => { - const rel = path.relative(root, f); + const rel = path.relative(root, f).split(path.sep).join("/"); for (const skip of SKIP_DOC_FILES) { - if (rel === skip || rel.startsWith(skip + path.sep)) return false; + if (rel === skip || rel.startsWith(skip + "/")) return false; } return true; }); @@ -861,9 +861,13 @@ export function formatHumanReport(result) { return lines.join("\n"); } +export function isDirectExecution(moduleUrl, argvEntry) { + if (!argvEntry) return false; + return path.resolve(fileURLToPath(moduleUrl)) === path.resolve(argvEntry); +} + // CLI entry — only run when invoked directly (not when imported for tests). -const isMain = import.meta.url === `file://${process.argv[1]}`; -if (isMain) { +if (isDirectExecution(import.meta.url, process.argv[1])) { main(); } diff --git a/tests/unit/browserPool-proxy.test.ts b/tests/unit/browserPool-proxy.test.ts index c7484f7273..e1de3a5f73 100644 --- a/tests/unit/browserPool-proxy.test.ts +++ b/tests/unit/browserPool-proxy.test.ts @@ -1,6 +1,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { resolvePlaywrightProxy } from "../../open-sse/services/browserPool.ts"; +import { + resolveBrowserContextProxy, + resolvePlaywrightProxy, +} from "../../open-sse/services/browserPool.ts"; describe("resolvePlaywrightProxy", () => { it("returns undefined when no proxy is configured", async () => { @@ -84,4 +87,21 @@ describe("resolvePlaywrightProxy", () => { }); assert.strictEqual(capturedKey, "gemini-web"); }); + + it("allows a scoped context key to use its stable provider key for proxy lookup", async () => { + let capturedKey = ""; + const proxy = await resolveBrowserContextProxy( + "claude-web:account-scope", + { proxyProviderKey: "claude-web" }, + { + resolveProxy: async (id) => { + capturedKey = id; + return { type: "http", host: "proxy.example.com", port: 8080 }; + }, + } + ); + + assert.equal(capturedKey, "claude-web"); + assert.deepEqual(proxy, { server: "http://proxy.example.com:8080" }); + }); }); diff --git a/tests/unit/check-fabricated-docs.test.ts b/tests/unit/check-fabricated-docs.test.ts index 8b37114a24..bcb07a4811 100644 --- a/tests/unit/check-fabricated-docs.test.ts +++ b/tests/unit/check-fabricated-docs.test.ts @@ -3,10 +3,12 @@ import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs"; import os from "node:os"; +import { pathToFileURL } from "node:url"; import { runFabricatedDocsCheck, formatHumanReport, + isDirectExecution, } from "../../scripts/check/check-fabricated-docs.mjs"; // ── Fixture helpers ───────────────────────────────────────────────────────── @@ -63,6 +65,20 @@ test("runFabricatedDocsCheck: runs without throwing on the real repo", () => { assert.ok(result.index.cliCommands instanceof Set); }); +test("runFabricatedDocsCheck: real documentation has no fabricated claims", () => { + const result = runFabricatedDocsCheck(); + assert.equal(result.totalFindings, 0, formatHumanReport(result)); +}); + +test("isDirectExecution: matches a module URL to its filesystem argv path", () => { + const scriptPath = path.resolve("scripts/check/check-fabricated-docs.mjs"); + const testPath = path.resolve("tests/unit/check-fabricated-docs.test.ts"); + + assert.equal(isDirectExecution(pathToFileURL(scriptPath).href, scriptPath), true); + assert.equal(isDirectExecution(pathToFileURL(scriptPath).href, testPath), false); + assert.equal(isDirectExecution(pathToFileURL(scriptPath).href, undefined), false); +}); + test("runFabricatedDocsCheck: index contains real OmniRoute routes", () => { const result = runFabricatedDocsCheck(); // The real repo has /api/v1/chat/completions — a known truth diff --git a/tests/unit/claude-web-browser-transport.test.ts b/tests/unit/claude-web-browser-transport.test.ts new file mode 100644 index 0000000000..0adb429364 --- /dev/null +++ b/tests/unit/claude-web-browser-transport.test.ts @@ -0,0 +1,510 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + __resetClaudeWebBrowserTemplatesForTesting, + __setClaudeWebBrowserNowForTesting, + applyClaudeWebBrowserTemplate, + buildClaudeWebBrowserPoolKey, + mergeClaudeWebBrowserPayload, + sendClaudeWebBrowser, + type ClaudeWebBrowserDeps, + type ClaudeWebTransportRequest, +} from "../../open-sse/executors/claude-web/browserTransport.ts"; +import { + transformToClaude, + type ClaudeWebRequestPayload, +} from "../../open-sse/executors/claude-web/payload.ts"; + +const ORGANIZATION_ID = "organization-secret-a"; +const CONVERSATION_ID = "00000000-0000-4000-8000-000000000010"; +const PARENT_UUID = "00000000-0000-4000-8000-000000000011"; +const HUMAN_UUID = "00000000-0000-4000-8000-000000000012"; +const ASSISTANT_UUID = "00000000-0000-4000-8000-000000000013"; + +function preparedPayload( + operation: "completion" | "retry_completion" = "completion" +): ClaudeWebRequestPayload { + return transformToClaude( + { + messages: [{ role: "user", content: "prepared prompt" }], + reasoning_effort: "high", + }, + "claude-opus-4-8", + { + operation, + prompt: operation === "retry_completion" ? "" : "prepared prompt", + timezone: "Asia/Seoul", + locale: "ko-KR", + parentMessageUuid: operation === "retry_completion" ? PARENT_UUID : undefined, + humanMessageUuid: operation === "completion" ? HUMAN_UUID : undefined, + assistantMessageUuid: ASSISTANT_UUID, + isNewConversation: operation === "completion", + } + ); +} + +function uiPayload(): Record<string, unknown> { + return { + prompt: "ui prompt", + model: "ui-model", + timezone: "America/New_York", + locale: "en-US", + effort: "low", + thinking_mode: "off", + tools: [{ name: "account_tool", input_schema: { type: "object" } }], + tool_states: [{ name: "account_tool", enabled: true }], + personalized_styles: [{ type: "default", key: "AccountStyle" }], + turn_message_uuids: { + human_message_uuid: "ui-human", + assistant_message_uuid: "ui-assistant", + }, + parent_message_uuid: "ui-parent", + create_conversation_params: { model: "ui-model" }, + attachments: [], + files: [], + sync_sources: [], + rendering_mode: "messages", + }; +} + +function request( + endpointSuffix: "completion" | "retry_completion" = "completion", + overrides: Partial<ClaudeWebTransportRequest> = {} +): ClaudeWebTransportRequest { + const url = `https://claude.ai/api/organizations/${ORGANIZATION_ID}/chat_conversations/${CONVERSATION_ID}/${endpointSuffix}`; + const payload = preparedPayload(endpointSuffix); + return { + scopeKey: "account-scope-digest-a", + organizationId: ORGANIZATION_ID, + conversationId: CONVERSATION_ID, + endpointSuffix, + pageUrl: + endpointSuffix === "completion" + ? "https://claude.ai/new" + : `https://claude.ai/chat/${CONVERSATION_ID}`, + url, + cookieString: "sessionKey=secret-cookie; cf_clearance=browser-cookie", + headers: { + Accept: "text/event-stream", + "Content-Type": "application/json", + Cookie: "must-not-be-forwarded-by-page-fetch", + }, + payload, + locale: payload.locale, + timezone: payload.timezone, + signal: null, + ...overrides, + }; +} + +type RouteMatcher = string | RegExp | ((url: URL) => boolean); + +function matcherAccepts(matcher: RouteMatcher, url: string): boolean { + if (typeof matcher === "string") return matcher === url; + if (matcher instanceof RegExp) return matcher.test(url); + return matcher(new URL(url)); +} + +function createBrowserHarness( + initialUiPayload = uiPayload(), + contextIdentity: object = {}, + responseBody = new TextEncoder().encode('data: {"type":"message_stop"}\n\n') +): { + deps: ClaudeWebBrowserDeps; + acquired: Array<{ key: string; options: Record<string, unknown> }>; + continued: Array<{ url?: string; postData?: string }>; + evaluated: Array<Record<string, unknown>>; + navigated: string[]; + filled: string[]; + routeMatchers: RouteMatcher[]; + aborted: () => number; + closed: () => number; + responseWaits: () => number; +} { + const acquired: Array<{ key: string; options: Record<string, unknown> }> = []; + const continued: Array<{ url?: string; postData?: string }> = []; + const evaluated: Array<Record<string, unknown>> = []; + const navigated: string[] = []; + const filled: string[] = []; + const routeMatchers: RouteMatcher[] = []; + let closeCount = 0; + let abortCount = 0; + let responseWaitCount = 0; + let routeHandler: + | ((route: { + request(): { + url(): string; + method(): string; + postData(): string; + allHeaders(): Promise<Record<string, string>>; + }; + continue(options: { url?: string; postData?: string }): Promise<void>; + abort(): Promise<void>; + }) => Promise<void>) + | undefined; + const page = { + async goto(url: string): Promise<void> { + navigated.push(url); + }, + async route(matcher: RouteMatcher, handler: typeof routeHandler): Promise<void> { + routeMatchers.push(matcher); + routeHandler = handler; + }, + async unroute(): Promise<void> {}, + waitForResponse(): Promise<unknown> { + responseWaitCount += 1; + return new Promise(() => {}); + }, + locator() { + return { + first() { + return { + async waitFor(): Promise<void> {}, + async fill(value: string): Promise<void> { + filled.push(value); + }, + }; + }, + }; + }, + keyboard: { + async press(): Promise<void> { + assert.ok(routeHandler, "completion must install an interception route"); + await routeHandler({ + request: () => ({ + url: () => request().url, + method: () => "POST", + postData: () => JSON.stringify(initialUiPayload), + async allHeaders() { + return { "x-ui-session": "scoped" }; + }, + }), + async continue(options) { + continued.push(options); + }, + async abort() { + abortCount += 1; + }, + }); + }, + }, + async evaluate(_fn: unknown, argument: Record<string, unknown>): Promise<void> { + evaluated.push(argument); + }, + async close(): Promise<void> { + closeCount += 1; + }, + }; + + const deps: ClaudeWebBrowserDeps = { + async acquireContext(key, options) { + acquired.push({ key, options: options as unknown as Record<string, unknown> }); + return { + id: key, + context: contextIdentity, + warmupPage: null, + lastUsed: 0, + isStealth: false, + } as never; + }, + async openPage() { + return page as never; + }, + async fetchResponse(_page, input) { + evaluated.push(input as unknown as Record<string, unknown>); + return { + status: 200, + headers: { "content-type": "text/event-stream", "x-browser": "scoped" }, + body: responseBody, + }; + }, + }; + + return { + deps, + acquired, + continued, + evaluated, + navigated, + filled, + routeMatchers, + aborted: () => abortCount, + closed: () => closeCount, + responseWaits: () => responseWaitCount, + }; +} + +beforeEach(() => { + __resetClaudeWebBrowserTemplatesForTesting(); + __setClaudeWebBrowserNowForTesting(1_000_000); +}); + +describe("Claude Web account-scoped browser transport", () => { + it("hashes account and organization scope without exposing either identifier", () => { + const base = request(); + const first = buildClaudeWebBrowserPoolKey(base); + const otherAccount = buildClaudeWebBrowserPoolKey({ ...base, scopeKey: "other-account" }); + const otherOrganization = buildClaudeWebBrowserPoolKey({ + ...base, + organizationId: "organization-secret-b", + }); + const otherCookie = buildClaudeWebBrowserPoolKey({ + ...base, + cookieString: "sessionKey=rotated-cookie", + }); + const otherProfile = buildClaudeWebBrowserPoolKey({ + ...base, + locale: "en-US", + timezone: "America/New_York", + }); + + assert.notEqual(first, otherAccount); + assert.notEqual(first, otherOrganization); + assert.notEqual(first, otherCookie); + assert.notEqual(first, otherProfile); + assert.match(first, /^claude-web:[a-f0-9]{64}$/); + assert.doesNotMatch(first, /connection|cookie|organization|secret/); + }); + + it("preserves account tools, states, and styles while replacing prepared turn state", () => { + const merged = mergeClaudeWebBrowserPayload(uiPayload(), preparedPayload("retry_completion")); + + assert.deepEqual(merged.tools, uiPayload().tools); + assert.deepEqual(merged.tool_states, uiPayload().tool_states); + assert.deepEqual(merged.personalized_styles, uiPayload().personalized_styles); + assert.equal(merged.model, "claude-opus-4-8"); + assert.equal(merged.effort, "high"); + assert.equal(merged.thinking_mode, "extended"); + assert.equal(merged.prompt, ""); + assert.equal(merged.parent_message_uuid, PARENT_UUID); + assert.deepEqual(merged.turn_message_uuids, { assistant_message_uuid: ASSISTANT_UUID }); + assert.equal("create_conversation_params" in merged, false); + }); + + it("intercepts only a verified-organization new completion and rewrites its endpoint", async () => { + const harness = createBrowserHarness(); + const transportRequest = request(); + const result = await sendClaudeWebBrowser(transportRequest, harness.deps); + + const expectedPoolKey = buildClaudeWebBrowserPoolKey(transportRequest); + assert.equal(harness.acquired[0].key, expectedPoolKey); + assert.equal(harness.acquired[0].options.proxyProviderKey, "claude-web"); + assert.equal(harness.acquired[0].options.cookieString, transportRequest.cookieString); + assert.equal(harness.acquired[0].options.locale, "ko-KR"); + assert.equal(harness.acquired[0].options.timezone, "Asia/Seoul"); + assert.equal(matcherAccepts(harness.routeMatchers[0], transportRequest.url), true); + assert.equal( + matcherAccepts( + harness.routeMatchers[0], + transportRequest.url.replace(ORGANIZATION_ID, "other-organization") + ), + false + ); + assert.equal( + matcherAccepts( + harness.routeMatchers[0], + transportRequest.url.replace(CONVERSATION_ID, "00000000-0000-4000-8000-000000000099") + ), + true + ); + assert.equal( + matcherAccepts( + harness.routeMatchers[0], + transportRequest.url.replace("/completion", "/retry_completion") + ), + false + ); + assert.equal(harness.continued.length, 0); + assert.equal(harness.aborted(), 1); + const continuedPayload = JSON.parse(String(harness.evaluated[0].body ?? "{}")) as Record< + string, + unknown + >; + assert.deepEqual(continuedPayload.tools, uiPayload().tools); + assert.equal(continuedPayload.prompt, "prepared prompt"); + assert.deepEqual(harness.navigated, [transportRequest.pageUrl]); + assert.deepEqual(harness.filled, ["prepared prompt"]); + assert.equal(harness.evaluated[0].url, transportRequest.url); + assert.equal(harness.evaluated[0].maxBytes, 16 * 1024 * 1024); + const fetchedHeaders = harness.evaluated[0].headers as Record<string, string>; + assert.equal(fetchedHeaders["x-ui-session"], "scoped"); + assert.equal("Cookie" in fetchedHeaders, false); + assert.equal(harness.closed(), 1); + assert.equal(result.status, 200); + assert.equal(result.headers.get("x-browser"), "scoped"); + assert.equal(await new Response(result.body).text(), 'data: {"type":"message_stop"}\n\n'); + assert.equal("cookieString" in result, false); + }); + + it("reuses only a non-expired same-scope UI template for retry", async () => { + const sharedContext = {}; + const completionHarness = createBrowserHarness(uiPayload(), sharedContext); + await sendClaudeWebBrowser(request(), completionHarness.deps); + + const retryHarness = createBrowserHarness(uiPayload(), sharedContext); + const retryRequest = request("retry_completion"); + await sendClaudeWebBrowser(retryRequest, retryHarness.deps); + + assert.equal(retryHarness.acquired[0].key, completionHarness.acquired[0].key); + assert.equal(retryHarness.continued.length, 0); + assert.equal(retryHarness.evaluated.length, 1); + const evaluatedPayload = JSON.parse(String(retryHarness.evaluated[0].body)) as Record< + string, + unknown + >; + assert.deepEqual(evaluatedPayload.tools, uiPayload().tools); + assert.equal(evaluatedPayload.prompt, ""); + const evaluatedHeaders = retryHarness.evaluated[0].headers as Record<string, string>; + assert.equal("Cookie" in evaluatedHeaders, false); + + await assert.rejects( + () => sendClaudeWebBrowser(retryRequest, createBrowserHarness(uiPayload(), {}).deps), + /browser context|scoped UI template/i + ); + + await assert.rejects( + () => + sendClaudeWebBrowser( + request("retry_completion", { scopeKey: "different-account-scope" }), + createBrowserHarness().deps + ), + /scoped UI template/i + ); + + __setClaudeWebBrowserNowForTesting(1_000_000 + 30 * 60 * 1000 + 1); + await assert.rejects( + () => sendClaudeWebBrowser(retryRequest, createBrowserHarness().deps), + /scoped UI template/i + ); + }); + + it("reuses a scoped browser template for direct requests only when caller tools are absent", async () => { + const sharedContext = {}; + await sendClaudeWebBrowser(request(), createBrowserHarness(uiPayload(), sharedContext).deps); + + const withoutCallerTools = applyClaudeWebBrowserTemplate(request()); + assert.deepEqual(withoutCallerTools.payload.tools, uiPayload().tools); + + const callerTool = { name: "caller_tool", input_schema: { type: "object" } }; + const withCallerTools = request(); + withCallerTools.payload = { ...withCallerTools.payload, tools: [callerTool] }; + assert.strictEqual(applyClaudeWebBrowserTemplate(withCallerTools), withCallerTools); + }); + + it("fails closed when a buffered browser response exceeds the hard size limit", async () => { + const oversized = new Uint8Array(16 * 1024 * 1024 + 1); + await assert.rejects( + () => sendClaudeWebBrowser(request(), createBrowserHarness(uiPayload(), {}, oversized).deps), + /response.*large|size limit/i + ); + }); + + it("uses a bounded page fetch instead of attaching a Playwright response waiter", async () => { + const harness = createBrowserHarness(); + let fetchInput: Record<string, unknown> | undefined; + const deps = { + ...harness.deps, + async fetchResponse(_page: unknown, input: Record<string, unknown>) { + fetchInput = input; + return { + status: 200, + headers: { "content-type": "text/event-stream" }, + body: new TextEncoder().encode('data: {"type":"message_stop"}\n\n'), + }; + }, + } as ClaudeWebBrowserDeps; + + await sendClaudeWebBrowser(request(), deps); + + assert.equal(harness.responseWaits(), 0); + assert.equal(fetchInput?.maxBytes, 16 * 1024 * 1024); + }); + + it("cancels a page fetch as soon as the incremental byte limit is exceeded", async () => { + const browserTransport = + (await import("../../open-sse/executors/claude-web/browserTransport.ts")) as Record< + string, + unknown + >; + const boundedFetch = browserTransport.fetchClaudeWebPageResponse; + assert.equal(typeof boundedFetch, "function"); + if (typeof boundedFetch !== "function") return; + + let reads = 0; + let cancelled = false; + const page = { + async evaluate( + callback: (input: Record<string, unknown>) => Promise<unknown>, + input: Record<string, unknown> + ) { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + new ReadableStream<Uint8Array>({ + pull(controller) { + reads += 1; + controller.enqueue(new Uint8Array(4)); + }, + cancel() { + cancelled = true; + }, + }), + { status: 200 } + ); + try { + return await callback(input); + } finally { + globalThis.fetch = originalFetch; + } + }, + }; + + await assert.rejects( + () => + (boundedFetch as (page: unknown, input: Record<string, unknown>) => Promise<unknown>)( + page, + { + url: request().url, + headers: {}, + body: "{}", + maxBytes: 8, + } + ), + /size limit/i + ); + assert.ok(reads >= 3 && reads <= 4, `unexpected prefetch count: ${reads}`); + assert.equal(cancelled, true); + }); + + it("aborts a pending browser response read and closes the page", async () => { + const harness = createBrowserHarness(); + let releaseRead: + | ((value: { status: number; headers: Record<string, string>; body: Uint8Array }) => void) + | undefined; + harness.deps.fetchResponse = () => + new Promise((resolve) => { + releaseRead = resolve; + }); + const controller = new AbortController(); + const pending = sendClaudeWebBrowser( + request("completion", { signal: controller.signal }), + harness.deps + ); + setTimeout(() => controller.abort(), 0); + + const outcome = await Promise.race([ + pending.then( + () => "resolved", + () => "rejected" + ), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)), + ]); + if (outcome === "timeout") { + releaseRead?.({ status: 200, headers: {}, body: new Uint8Array() }); + await pending.catch(() => {}); + } + assert.equal(outcome, "rejected"); + assert.equal(harness.closed(), 1); + }); +}); diff --git a/tests/unit/claude-web-executor-split.test.ts b/tests/unit/claude-web-executor-split.test.ts index be510e80d4..705160eeb4 100644 --- a/tests/unit/claude-web-executor-split.test.ts +++ b/tests/unit/claude-web-executor-split.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; // Split-guard for the claude-web executor payload extraction. -// The pure payload types + transforms + default tools/style live in the leaf +// The pure payload types + transforms + caller-tool/style helpers live in the leaf // claude-web/payload.ts (no host state, no fetch/auth). Host imports back the // symbols it uses (ClaudeWebRequestPayload, transformToClaude, transformFromClaude). const HERE = dirname(fileURLToPath(import.meta.url)); @@ -15,7 +15,7 @@ const LEAF = join(EXE, "claude-web/payload.ts"); test("leaf hosts the payload builders/transforms and does not import the host", () => { const src = readFileSync(LEAF, "utf8"); - for (const sym of ["transformToClaude", "transformFromClaude", "getDefaultTools"]) { + for (const sym of ["transformToClaude", "transformFromClaude", "transformOpenAiTools"]) { assert.match(src, new RegExp(`export function ${sym}\\b`)); } assert.match(src, /export interface ClaudeWebRequestPayload\b/); diff --git a/tests/unit/claude-web-live-alignment.test.ts b/tests/unit/claude-web-live-alignment.test.ts new file mode 100644 index 0000000000..5f943e5551 --- /dev/null +++ b/tests/unit/claude-web-live-alignment.test.ts @@ -0,0 +1,258 @@ +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { CLAUDE_WEB_FINGERPRINT } from "../../open-sse/config/claudeWebFingerprint.ts"; +import { ClaudeWebExecutor } from "../../open-sse/executors/claude-web.ts"; +import { transformToClaude } from "../../open-sse/executors/claude-web/payload.ts"; +import { __setTlsFetchOverrideForTesting } from "../../open-sse/services/claudeTlsClient.ts"; + +const originalBrowserFlag = process.env.WEB_COOKIE_USE_BROWSER; +const originalPoolFlag = process.env.OMNIROUTE_BROWSER_POOL; + +function textStream(text: string): ReadableStream<Uint8Array> { + const encoder = new TextEncoder(); + return new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); +} + +function claudeSseStream(): ReadableStream<Uint8Array> { + const events = [ + { type: "message_start" }, + { type: "message_delta", delta: { stop_reason: "end_turn" } }, + { type: "message_stop" }, + ]; + return textStream(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")); +} + +function clearBrowserFlags(): void { + delete process.env.WEB_COOKIE_USE_BROWSER; + delete process.env.OMNIROUTE_BROWSER_POOL; +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +afterEach(() => { + __setTlsFetchOverrideForTesting(null); + restoreEnv("WEB_COOKIE_USE_BROWSER", originalBrowserFlag); + restoreEnv("OMNIROUTE_BROWSER_POOL", originalPoolFlag); +}); + +describe("Claude Web live request alignment", () => { + it("maps an explicit reasoning effort to Claude Web extended thinking", () => { + const payload = transformToClaude( + { + messages: [{ role: "user", content: "Think carefully" }], + reasoning_effort: "high", + }, + "claude-opus-4-8" + ); + + assert.equal(payload.effort, "high"); + assert.equal(payload.thinking_mode, "extended"); + }); + + it("uses the shared browser fingerprint and the new-chat referer", async () => { + clearBrowserFlags(); + let capturedHeaders: Record<string, string> | undefined; + + __setTlsFetchOverrideForTesting(async (_url, options) => { + capturedHeaders = options.headers; + return { + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: claudeSseStream(), + }; + }); + + const result = await new ClaudeWebExecutor().execute({ + model: "claude-opus-4-8", + body: { messages: [{ role: "user", content: "Hello" }] }, + stream: true, + credentials: { + cookie: "sessionKey=fake-session; cf_clearance=fake-clearance", + orgId: "org-test", + conversationId: "conv-test", + }, + signal: null, + }); + + assert.equal(result.response.status, 200); + assert.equal(capturedHeaders?.["User-Agent"], CLAUDE_WEB_FINGERPRINT.userAgent); + assert.equal(capturedHeaders?.["Sec-Ch-Ua"], CLAUDE_WEB_FINGERPRINT.secChUa); + assert.equal(capturedHeaders?.["Sec-Ch-Ua-Platform"], CLAUDE_WEB_FINGERPRINT.secChUaPlatform); + assert.equal(capturedHeaders?.Referer, "https://claude.ai/new"); + }); + + it("fails closed when the authenticated organization cannot be resolved", async () => { + clearBrowserFlags(); + let completionCalls = 0; + + __setTlsFetchOverrideForTesting(async (url) => { + if (url.endsWith("/organizations")) { + return { + status: 503, + headers: new Headers({ "Content-Type": "application/json" }), + text: '{"error":"unavailable"}', + body: null, + }; + } + + completionCalls += 1; + return { + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: claudeSseStream(), + }; + }); + + const result = await new ClaudeWebExecutor().execute({ + model: "claude-sonnet-5", + body: { messages: [{ role: "user", content: "Hello" }] }, + stream: true, + credentials: { cookie: "sessionKey=fake-session; cf_clearance=fake-clearance" }, + signal: null, + }); + + assert.equal(result.response.status, 502); + assert.equal(completionCalls, 0); + assert.match(await result.response.text(), /organization/i); + }); + + it("uses the first organization returned by the current Claude Web session", async () => { + clearBrowserFlags(); + let completionCalls = 0; + let completionUrl = ""; + + __setTlsFetchOverrideForTesting(async (url) => { + if (url.endsWith("/organizations")) { + return { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + text: JSON.stringify([{ uuid: "org-first" }, { uuid: "org-second" }]), + body: null, + }; + } + + completionCalls += 1; + completionUrl = url; + return { + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: claudeSseStream(), + }; + }); + + const result = await new ClaudeWebExecutor().execute({ + model: "claude-sonnet-5", + body: { messages: [{ role: "user", content: "Hello" }] }, + stream: true, + credentials: { cookie: "sessionKey=fake-session; cf_clearance=fake-clearance" }, + signal: null, + }); + + assert.equal(result.response.status, 200); + assert.equal(completionCalls, 1); + assert.match(completionUrl, /\/api\/organizations\/org-first\/chat_conversations\//); + }); + + it("reports an invalid organization-session authorization as 401", async () => { + clearBrowserFlags(); + let completionCalls = 0; + + __setTlsFetchOverrideForTesting(async (url) => { + if (url.endsWith("/organizations")) { + return { + status: 403, + headers: new Headers({ "Content-Type": "application/json" }), + text: JSON.stringify({ + error: { + type: "permission_error", + message: "Invalid authorization", + details: { error_code: "invalid_auth" }, + }, + }), + body: null, + }; + } + + completionCalls += 1; + return { + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: claudeSseStream(), + }; + }); + + const result = await new ClaudeWebExecutor().execute({ + model: "claude-sonnet-5", + body: { messages: [{ role: "user", content: "Hello" }] }, + stream: true, + credentials: { cookie: "sessionKey=expired-session" }, + signal: null, + }); + + assert.equal(result.response.status, 401); + assert.equal(completionCalls, 0); + assert.match(await result.response.text(), /session expired|invalid/i); + }); + + it("sanitizes structured upstream error details before returning them", async () => { + clearBrowserFlags(); + const upstreamError = { + error: { + message: "upstream failed\n at C:\\Users\\admin\\private\\source.ts:10:2", + stack: "Error: upstream failed\n at C:\\Users\\admin\\private\\source.ts:10:2", + path: "C:\\Users\\admin\\private\\source.ts", + organization_id: "private-organization-id", + prompt: "private prompt", + }, + }; + + __setTlsFetchOverrideForTesting(async () => ({ + status: 500, + headers: new Headers({ "Content-Type": "application/json" }), + text: null, + body: textStream(JSON.stringify(upstreamError)), + })); + + const result = await new ClaudeWebExecutor().execute({ + model: "claude-sonnet-5", + body: { messages: [{ role: "user", content: "Hello" }] }, + stream: true, + credentials: { + cookie: "sessionKey=fake-session; cf_clearance=fake-clearance", + orgId: "org-test", + conversationId: "conv-test", + }, + signal: null, + }); + + assert.equal(result.response.status, 500); + const rawBody = await result.response.text(); + assert.doesNotMatch(rawBody, /C:\\\\Users/); + assert.doesNotMatch(rawBody, /"stack"/); + assert.doesNotMatch(rawBody, /"path"/); + assert.doesNotMatch(rawBody, /private-organization-id|private prompt/); + + const responseBody = JSON.parse(rawBody) as { + error: { message: string }; + upstream_details?: unknown; + }; + assert.equal(responseBody.error.message, "Claude Web API error (500)"); + assert.equal(responseBody.upstream_details, undefined); + }); +}); diff --git a/tests/unit/claude-web-payload-runtime.test.ts b/tests/unit/claude-web-payload-runtime.test.ts new file mode 100644 index 0000000000..3f7a7eda1b --- /dev/null +++ b/tests/unit/claude-web-payload-runtime.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + transformOpenAiTools, + transformToClaude, +} from "../../open-sse/executors/claude-web/payload.ts"; + +const PARENT_UUID = "00000000-0000-4000-8000-000000000001"; +const HUMAN_UUID = "00000000-0000-4000-8000-000000000002"; +const ASSISTANT_UUID = "00000000-0000-4000-8000-000000000003"; + +describe("Claude Web runtime payloads", () => { + it("builds a new conversation with creation parameters and both turn UUIDs", () => { + const payload = transformToClaude( + { messages: [{ role: "user", content: "start" }] }, + "claude-sonnet-5", + { + operation: "completion", + prompt: "start", + timezone: "Asia/Seoul", + locale: "ko-KR", + humanMessageUuid: HUMAN_UUID, + assistantMessageUuid: ASSISTANT_UUID, + isNewConversation: true, + } + ); + + assert.equal(payload.prompt, "start"); + assert.equal(payload.timezone, "Asia/Seoul"); + assert.equal(payload.locale, "ko-KR"); + assert.deepEqual(payload.turn_message_uuids, { + human_message_uuid: HUMAN_UUID, + assistant_message_uuid: ASSISTANT_UUID, + }); + assert.equal(payload.create_conversation_params?.model, "claude-sonnet-5"); + assert.equal("parent_message_uuid" in payload, false); + }); + + it("builds a follow-up with a parent and no conversation creation parameters", () => { + const payload = transformToClaude( + { messages: [{ role: "user", content: "next" }] }, + "claude-opus-4-8", + { + operation: "completion", + prompt: "next", + timezone: "Asia/Seoul", + locale: "ko-KR", + parentMessageUuid: PARENT_UUID, + humanMessageUuid: HUMAN_UUID, + assistantMessageUuid: ASSISTANT_UUID, + isNewConversation: false, + toolStates: [], + } + ); + + assert.equal(payload.parent_message_uuid, PARENT_UUID); + assert.equal("create_conversation_params" in payload, false); + assert.equal(payload.timezone, "Asia/Seoul"); + assert.deepEqual(payload.tool_states, []); + }); + + it("builds a retry with an empty prompt, parent, and assistant UUID only", () => { + const payload = transformToClaude( + { messages: [{ role: "user", content: "ignored for retry" }] }, + "claude-opus-4-8", + { + operation: "retry_completion", + prompt: "", + timezone: "UTC", + locale: "en-US", + parentMessageUuid: PARENT_UUID, + assistantMessageUuid: ASSISTANT_UUID, + isNewConversation: false, + } + ); + + assert.equal(payload.prompt, ""); + assert.equal(payload.parent_message_uuid, PARENT_UUID); + assert.deepEqual(payload.turn_message_uuids, { + assistant_message_uuid: ASSISTANT_UUID, + }); + assert.equal("create_conversation_params" in payload, false); + }); + + it("converts only valid OpenAI function tools", () => { + assert.deepEqual( + transformOpenAiTools([ + { + type: "function", + function: { + name: "get_weather", + description: "Read the weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + { type: "function", function: { description: "missing name" } }, + { type: "web_search_preview" }, + null, + ]), + [ + { + name: "get_weather", + description: "Read the weather", + input_schema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + ] + ); + }); + + it("does not invent Claude UI tools when the caller supplied none", () => { + const payload = transformToClaude( + { messages: [{ role: "user", content: "hello" }] }, + "claude-sonnet-5" + ); + + assert.deepEqual(payload.tools, []); + assert.equal( + payload.tools.some((tool) => tool.name === "show_widget" || tool.name === "web_search"), + false + ); + }); + + it("preserves explicit reasoning effort for prepared turns", () => { + const payload = transformToClaude( + { + messages: [{ role: "user", content: "think" }], + reasoning_effort: "high", + }, + "claude-opus-4-8", + { + operation: "completion", + prompt: "think", + timezone: "UTC", + locale: "en-US", + humanMessageUuid: HUMAN_UUID, + assistantMessageUuid: ASSISTANT_UUID, + isNewConversation: true, + } + ); + + assert.equal(payload.effort, "high"); + assert.equal(payload.thinking_mode, "extended"); + }); +}); diff --git a/tests/unit/claude-web-session.test.ts b/tests/unit/claude-web-session.test.ts new file mode 100644 index 0000000000..520a7f58b2 --- /dev/null +++ b/tests/unit/claude-web-session.test.ts @@ -0,0 +1,291 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import type { ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { + __resetClaudeWebSessionForTesting, + __setClaudeWebSessionNowForTesting, + commitClaudeWebTurn, + invalidateClaudeWebTurn, + prepareClaudeWebTurn, + type PrepareClaudeWebTurnInput, +} from "../../open-sse/executors/claude-web/session.ts"; + +const CONVERSATION_UUID = "00000000-0000-4000-8000-000000000010"; +const PARENT_UUID = "00000000-0000-4000-8000-000000000011"; + +function input( + body: Record<string, unknown>, + overrides: Partial<PrepareClaudeWebTurnInput> = {} +): PrepareClaudeWebTurnInput { + return { + body, + model: "claude-sonnet-5", + credentials: { connectionId: "connection-a" }, + organizationId: "organization-a", + normalizedCookie: "sessionKey=cookie-a", + ...overrides, + }; +} + +function firstTurnBody(question = "first question"): Record<string, unknown> { + return { messages: [{ role: "user", content: question }] }; +} + +function followUpBody( + firstQuestion = "first question", + firstAnswer = "first answer", + nextQuestion = "second question" +): Record<string, unknown> { + return { + messages: [ + { role: "user", content: firstQuestion }, + { role: "assistant", content: firstAnswer }, + { role: "user", content: nextQuestion }, + ], + }; +} + +beforeEach(() => { + __resetClaudeWebSessionForTesting(); + __setClaudeWebSessionNowForTesting(1_000_000); +}); + +afterEach(() => { + __resetClaudeWebSessionForTesting(); + __setClaudeWebSessionNowForTesting(null); +}); + +describe("Claude Web conversation sessions", () => { + it("reuses a committed conversation for the matching transcript prefix", () => { + const first = prepareClaudeWebTurn(input(firstTurnBody())); + assert.equal(first.endpointSuffix, "completion"); + assert.equal(first.pageUrl, "https://claude.ai/new"); + assert.equal("parent_message_uuid" in first.payload, false); + commitClaudeWebTurn(first, "first answer"); + + const followUp = prepareClaudeWebTurn(input(followUpBody())); + + assert.equal(followUp.conversationId, first.conversationId); + assert.equal(followUp.parentMessageUuid, first.assistantMessageUuid); + assert.equal(followUp.pageUrl, `https://claude.ai/chat/${first.conversationId}`); + assert.equal(followUp.payload.prompt, "second question"); + assert.equal("create_conversation_params" in followUp.payload, false); + }); + + it("recovers every normalized message when multi-turn history misses the cache", () => { + const recovered = prepareClaudeWebTurn( + input({ + messages: [ + { role: "system", content: "system rules" }, + { role: "user", content: "old question" }, + { role: "assistant", content: "old answer" }, + { role: "tool", content: [{ type: "text", text: "tool result" }] }, + { role: "user", content: "new question" }, + ], + }) + ); + + assert.notEqual(recovered.payload.prompt, "new question"); + for (const expected of [ + "system rules", + "old question", + "old answer", + "tool result", + "new question", + ]) { + assert.match(recovered.payload.prompt, new RegExp(expected)); + } + assert.ok(recovered.payload.create_conversation_params); + }); + + it("isolates cache entries by connection, cookie fingerprint, organization, and model", () => { + const first = prepareClaudeWebTurn(input(firstTurnBody())); + commitClaudeWebTurn(first, "first answer"); + + const isolatedInputs = [ + input(followUpBody(), { credentials: { connectionId: "connection-b" } }), + input(followUpBody(), { organizationId: "organization-b" }), + input(followUpBody(), { model: "claude-opus-4-8" }), + ]; + for (const isolated of isolatedInputs) { + assert.notEqual(prepareClaudeWebTurn(isolated).conversationId, first.conversationId); + } + + const cookieScoped = prepareClaudeWebTurn( + input(firstTurnBody("cookie question"), { + credentials: {}, + normalizedCookie: "sessionKey=cookie-one", + }) + ); + commitClaudeWebTurn(cookieScoped, "cookie answer"); + const otherCookie = prepareClaudeWebTurn( + input(followUpBody("cookie question", "cookie answer", "cookie follow-up"), { + credentials: {}, + normalizedCookie: "sessionKey=cookie-two", + }) + ); + assert.notEqual(otherCookie.conversationId, cookieScoped.conversationId); + }); + + it("validates the strict claude_web extension", () => { + const invalidExtensions = [ + { operation: "duplicate" }, + { conversation_id: "not-a-uuid" }, + { parent_message_uuid: "not-a-uuid" }, + { timezone: "Mars/Olympus_Mons" }, + { locale: "not_a_locale" }, + { tool_states: Array.from({ length: 129 }, () => null) }, + { unknown_field: true }, + ]; + + for (const claudeWeb of invalidExtensions) { + assert.throws(() => + prepareClaudeWebTurn(input({ ...firstTurnBody(), claude_web: claudeWeb })) + ); + } + }); + + it("resolves locale and timezone from extension, provider data, then runtime", () => { + const credentials: ProviderCredentials = { + connectionId: "connection-a", + providerSpecificData: { timezone: "America/New_York", locale: "fr-FR" }, + }; + const explicit = prepareClaudeWebTurn( + input( + { + ...firstTurnBody(), + claude_web: { timezone: "Asia/Seoul", locale: "ko-KR", tool_states: [] }, + }, + { credentials } + ) + ); + assert.equal(explicit.payload.timezone, "Asia/Seoul"); + assert.equal(explicit.payload.locale, "ko-KR"); + assert.deepEqual(explicit.payload.tool_states, []); + + const provider = prepareClaudeWebTurn(input(firstTurnBody("provider"), { credentials })); + assert.equal(provider.payload.timezone, "America/New_York"); + assert.equal(provider.payload.locale, "fr-FR"); + + const runtime = prepareClaudeWebTurn( + input(firstTurnBody("runtime"), { credentials: { connectionId: "connection-a" } }) + ); + const runtimeOptions = Intl.DateTimeFormat().resolvedOptions(); + assert.equal(runtime.payload.timezone, runtimeOptions.timeZone || "UTC"); + assert.equal(runtime.payload.locale, runtimeOptions.locale || "en-US"); + }); + + it("builds explicit retry state and fails closed when retry state is absent", () => { + const retry = prepareClaudeWebTurn( + input({ + ...followUpBody(), + claude_web: { + operation: "retry", + conversation_id: CONVERSATION_UUID, + parent_message_uuid: PARENT_UUID, + }, + }) + ); + + assert.equal(retry.endpointSuffix, "retry_completion"); + assert.equal(retry.conversationId, CONVERSATION_UUID); + assert.equal(retry.parentMessageUuid, PARENT_UUID); + assert.equal(retry.payload.prompt, ""); + assert.deepEqual(retry.payload.turn_message_uuids, { + assistant_message_uuid: retry.assistantMessageUuid, + }); + assert.equal("create_conversation_params" in retry.payload, false); + + assert.throws( + () => prepareClaudeWebTurn(input({ ...firstTurnBody(), claude_web: { operation: "retry" } })), + /conversation.*parent|parent.*conversation/i + ); + }); + + it("accepts the legacy credentials conversationId with an explicit parent", () => { + const credentials = { + connectionId: "connection-a", + conversationId: "legacy-conversation", + } as ProviderCredentials; + const turn = prepareClaudeWebTurn( + input( + { + ...firstTurnBody(), + claude_web: { parent_message_uuid: PARENT_UUID }, + }, + { credentials } + ) + ); + + assert.equal(turn.conversationId, "legacy-conversation"); + assert.equal(turn.parentMessageUuid, PARENT_UUID); + assert.equal("create_conversation_params" in turn.payload, false); + }); + + it("invalidates reusable continuation state", () => { + const first = prepareClaudeWebTurn(input(firstTurnBody())); + commitClaudeWebTurn(first, "first answer"); + const reusable = prepareClaudeWebTurn(input(followUpBody())); + assert.equal(reusable.conversationId, first.conversationId); + + invalidateClaudeWebTurn(reusable, "conversation"); + const afterInvalidation = prepareClaudeWebTurn(input(followUpBody())); + assert.notEqual(afterInvalidation.conversationId, first.conversationId); + }); + + it("expires entries after 30 minutes", () => { + const first = prepareClaudeWebTurn(input(firstTurnBody())); + commitClaudeWebTurn(first, "first answer"); + + __setClaudeWebSessionNowForTesting(1_000_000 + 30 * 60 * 1000 + 1); + const expired = prepareClaudeWebTurn(input(followUpBody())); + assert.notEqual(expired.conversationId, first.conversationId); + }); + + it("evicts the oldest entry when the 5,000-entry cap is exceeded", () => { + const oldest = prepareClaudeWebTurn(input(firstTurnBody("question-0"))); + commitClaudeWebTurn(oldest, "answer-0"); + + for (let index = 1; index <= 5_000; index += 1) { + const turn = prepareClaudeWebTurn(input(firstTurnBody(`question-${index}`))); + commitClaudeWebTurn(turn, `answer-${index}`); + } + + const evicted = prepareClaudeWebTurn( + input(followUpBody("question-0", "answer-0", "after eviction")) + ); + assert.notEqual(evicted.conversationId, oldest.conversationId); + }); + + it("prepares concurrent branches without mutating their shared parent", () => { + const first = prepareClaudeWebTurn(input(firstTurnBody())); + commitClaudeWebTurn(first, "first answer"); + + const left = prepareClaudeWebTurn( + input(followUpBody("first question", "first answer", "left branch")) + ); + const right = prepareClaudeWebTurn( + input(followUpBody("first question", "first answer", "right branch")) + ); + + assert.equal(left.parentMessageUuid, first.assistantMessageUuid); + assert.equal(right.parentMessageUuid, first.assistantMessageUuid); + assert.notEqual(left.assistantMessageUuid, right.assistantMessageUuid); + + commitClaudeWebTurn(left, "left answer"); + invalidateClaudeWebTurn(right); + const leftFollowUp = prepareClaudeWebTurn( + input({ + messages: [ + { role: "user", content: "first question" }, + { role: "assistant", content: "first answer" }, + { role: "user", content: "left branch" }, + { role: "assistant", content: "left answer" }, + { role: "user", content: "continue left" }, + ], + }) + ); + assert.equal(leftFollowUp.conversationId, first.conversationId); + }); +}); diff --git a/tests/unit/claude-web-sonnet5-registry-6209.test.ts b/tests/unit/claude-web-sonnet5-registry-6209.test.ts index d3d3ba0058..b449cd232e 100644 --- a/tests/unit/claude-web-sonnet5-registry-6209.test.ts +++ b/tests/unit/claude-web-sonnet5-registry-6209.test.ts @@ -2,14 +2,28 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { getModelsByProviderId } from "../../open-sse/config/providerModels.ts"; -// #6209 (#6200): the Claude Web provider (claude.ai scrape) now offers Claude 5 Sonnet. -// Regression guard: the claude-web registry must expose `claude-sonnet-5` alongside the -// pre-existing 4.6 Sonnet / 4.5 Haiku web entries. Fails without the registry line. -test("claude-web registry exposes claude-sonnet-5 (Claude 5 Sonnet web)", () => { - const models = getModelsByProviderId("claude-web"); - const ids = new Set(models.map((m) => m.id)); - assert.ok(ids.has("claude-sonnet-5"), "claude-web must expose claude-sonnet-5"); - // the prior web lineup must survive - assert.ok(ids.has("claude-sonnet-4-6"), "claude-web must keep claude-sonnet-4-6"); - assert.ok(ids.has("claude-haiku-4-5"), "claude-web must keep claude-haiku-4-5"); +test("claude-web registry matches the current selectable model set", () => { + const ids = getModelsByProviderId("claude-web") + .map((model) => model.id) + .sort(); + assert.deepEqual( + ids, + [ + "claude-fable-5", + "claude-haiku-4-5-20251001", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-sonnet-5", + ].sort() + ); +}); + +test("claude-web registry excludes the requested legacy Opus models", () => { + const ids = new Set(getModelsByProviderId("claude-web").map((model) => model.id)); + + assert.equal(ids.has("claude-3-opus-20240229"), false); + assert.equal(ids.has("claude-opus-4-1-20250805-claude-ai"), false); + assert.equal(ids.has("claude-opus-4-5-20251101"), false); }); diff --git a/tests/unit/claude-web-stream.test.ts b/tests/unit/claude-web-stream.test.ts new file mode 100644 index 0000000000..477643764b --- /dev/null +++ b/tests/unit/claude-web-stream.test.ts @@ -0,0 +1,325 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { createClaudeWebResponse } from "../../open-sse/executors/claude-web/stream.ts"; + +type ParsedSse = { + json: Array<Record<string, unknown>>; + doneCount: number; +}; + +function byteStream(text: string, chunkSizes: number[] = []): ReadableStream<Uint8Array> { + const bytes = new TextEncoder().encode(text); + return new ReadableStream<Uint8Array>({ + start(controller) { + let offset = 0; + for (const size of chunkSizes) { + if (offset >= bytes.length) break; + controller.enqueue(bytes.slice(offset, Math.min(offset + size, bytes.length))); + offset += size; + } + if (offset < bytes.length) controller.enqueue(bytes.slice(offset)); + controller.close(); + }, + }); +} + +function frames(events: Array<Record<string, unknown>>, newline = "\n"): string { + return events.map((event) => `data: ${JSON.stringify(event)}${newline}${newline}`).join(""); +} + +function validEvents(): Array<Record<string, unknown>> { + return [ + { type: "message_start", message: { model: "claude-sonnet-5" } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking" } }, + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "deep " }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_summary_delta", summary: "summary" }, + }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text" } }, + { + type: "content_block_delta", + index: 1, + delta: { type: "text_delta", text: "hello" }, + }, + { type: "content_block_stop", index: 1 }, + { type: "message_limit", remaining: 42 }, + { type: "model_update", model: "claude-sonnet-5" }, + { type: "message_delta", delta: { stop_reason: "end_turn" } }, + { type: "message_stop" }, + ]; +} + +function parseSse(output: string): ParsedSse { + const json: Array<Record<string, unknown>> = []; + let doneCount = 0; + for (const frame of output.split(/\r?\n\r?\n/)) { + const data = frame + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!data) continue; + if (data === "[DONE]") { + doneCount += 1; + } else { + json.push(JSON.parse(data) as Record<string, unknown>); + } + } + return { json, doneCount }; +} + +describe("Claude Web strict stream protocol", () => { + it("handles split CRLF frames, reasoning, text, metadata, and one terminal marker", async () => { + const events = validEvents(); + const prefix = + 'data: {"type":"ping",\r\ndata: "latency_ms":12,"prompt":"never expose me"}\r\n\r\n'; + const source = prefix + frames(events, "\r\n") + "data: [DONE]\r\n\r\n"; + const completions: Array<{ assistantText: string; stopReason: string }> = []; + let failures = 0; + const response = await createClaudeWebResponse(byteStream(source, [1, 2, 5, 3, 11, 7]), { + model: "claude-sonnet-5", + stream: true, + responseMetadata: { conversation_id: "conversation-test" }, + onComplete: (result) => completions.push(result), + onFailure: () => { + failures += 1; + }, + }); + + assert.equal(response.status, 200); + const output = await response.text(); + const parsed = parseSse(output); + const deltas = parsed.json.flatMap((chunk) => { + const choices = chunk.choices as Array<{ delta?: Record<string, unknown> }> | undefined; + return choices?.map((choice) => choice.delta ?? {}) ?? []; + }); + assert.equal(deltas.map((delta) => delta.reasoning_content ?? "").join(""), "deep summary"); + assert.equal(deltas.map((delta) => delta.content ?? "").join(""), "hello"); + + const metadataEvents = parsed.json + .map((chunk) => chunk.claude_web as Record<string, unknown> | undefined) + .map((metadata) => metadata?.event as Record<string, unknown> | undefined) + .map((event) => event?.type) + .filter(Boolean); + assert.ok(metadataEvents.includes("ping")); + assert.ok(metadataEvents.includes("message_limit")); + assert.ok(metadataEvents.includes("model_update")); + assert.doesNotMatch(output, /never expose me|tool_states|transcript_hash/); + assert.equal((output.match(/"finish_reason":"stop"/g) ?? []).length, 1); + assert.equal(parsed.doneCount, 1); + assert.deepEqual(completions, [{ assistantText: "hello", stopReason: "end_turn" }]); + assert.equal(failures, 0); + }); + + it("projects known metadata through per-event allowlists", async () => { + const events = [ + { type: "message_start" }, + { + type: "tool_approval", + status: "required", + prompt: "private prompt", + conversation_id: "private-conversation", + tool_states: [{ name: "private_tool" }], + transcript_hash: "private-hash", + }, + { type: "message_delta", delta: { stop_reason: "end_turn" } }, + { type: "message_stop" }, + ]; + const response = await createClaudeWebResponse(byteStream(frames(events)), { + model: "claude-sonnet-5", + stream: false, + responseMetadata: {}, + onComplete() {}, + onFailure() {}, + }); + const raw = await response.text(); + assert.match(raw, /tool_approval/); + assert.match(raw, /required/); + assert.doesNotMatch(raw, /private prompt|private-conversation|private_tool|private-hash/); + }); + + it("finishes and cancels upstream immediately after message_stop", async () => { + let controller: ReadableStreamDefaultController<Uint8Array> | undefined; + let cancelled = false; + const source = new ReadableStream<Uint8Array>({ + start(value) { + controller = value; + value.enqueue(new TextEncoder().encode(frames(validEvents()))); + }, + cancel() { + cancelled = true; + }, + }); + const response = await createClaudeWebResponse(source, { + model: "claude-sonnet-5", + stream: true, + responseMetadata: {}, + onComplete() {}, + onFailure() {}, + }); + const pending = response.text(); + const outcome = await Promise.race([ + pending.then(() => "completed" as const), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)), + ]); + if (outcome === "timeout") { + controller?.close(); + await pending; + } + assert.equal(outcome, "completed"); + assert.equal(cancelled, true); + }); + + it("cancels upstream when the downstream consumer disconnects", async () => { + let sourceController: ReadableStreamDefaultController<Uint8Array> | undefined; + let cancelled = false; + const partial = frames([ + { type: "message_start" }, + { type: "content_block_start", index: 0, content_block: { type: "text" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "partial" } }, + ]); + const source = new ReadableStream<Uint8Array>({ + start(value) { + sourceController = value; + value.enqueue(new TextEncoder().encode(partial)); + }, + cancel() { + cancelled = true; + }, + }); + const response = await createClaudeWebResponse(source, { + model: "claude-sonnet-5", + stream: true, + responseMetadata: {}, + onComplete() {}, + onFailure() {}, + }); + const reader = response.body!.getReader(); + await reader.read(); + const cancelPromise = reader.cancel("client disconnected"); + const outcome = await Promise.race([ + cancelPromise.then(() => "cancelled" as const), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)), + ]); + if (outcome === "timeout") { + sourceController?.close(); + await cancelPromise.catch(() => {}); + } + assert.equal(outcome, "cancelled"); + assert.equal(cancelled, true); + }); + + it("cancels an oversized unterminated SSE frame before reading the whole source", async () => { + const chunk = new TextEncoder().encode("x".repeat(64 * 1024)); + const availableChunks = 40; + let reads = 0; + let cancelled = false; + const source = new ReadableStream<Uint8Array>({ + pull(controller) { + if (reads >= availableChunks) { + controller.close(); + return; + } + reads += 1; + controller.enqueue(chunk); + }, + cancel() { + cancelled = true; + }, + }); + const response = await createClaudeWebResponse(source, { + model: "claude-sonnet-5", + stream: false, + responseMetadata: {}, + onComplete() {}, + onFailure() {}, + }); + + assert.equal(response.status, 502); + assert.equal(cancelled, true); + assert.ok(reads < availableChunks); + }); + + it("builds buffered output from the same semantic events", async () => { + const completions: Array<{ assistantText: string; stopReason: string }> = []; + let failures = 0; + const response = await createClaudeWebResponse(byteStream(frames(validEvents())), { + model: "claude-sonnet-5", + stream: false, + responseMetadata: { assistant_message_uuid: "assistant-test" }, + onComplete: (result) => completions.push(result), + onFailure: () => { + failures += 1; + }, + }); + + assert.equal(response.status, 200); + assert.match(response.headers.get("Content-Type") ?? "", /application\/json/); + const body = (await response.json()) as { + choices: Array<{ + message: { content: string; reasoning_content?: string }; + finish_reason: string; + }>; + claude_web: { assistant_message_uuid: string; events: unknown[] }; + }; + assert.equal(body.choices[0].message.content, "hello"); + assert.equal(body.choices[0].message.reasoning_content, "deep summary"); + assert.equal(body.choices[0].finish_reason, "stop"); + assert.equal(body.claude_web.assistant_message_uuid, "assistant-test"); + assert.equal(body.claude_web.events.length, 2); + assert.deepEqual(completions, [{ assistantText: "hello", stopReason: "end_turn" }]); + assert.equal(failures, 0); + }); + + it("fails closed for malformed, unknown, unordered, upstream error, and premature EOF", async () => { + const badStreams = [ + 'data: {"type":\n\n', + frames([{ type: "message_start" }, { type: "future_event" }]), + frames([ + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "too early" }, + }, + ]), + frames([ + { type: "message_start" }, + { + type: "error", + error: { message: "secret at C:\\Users\\private\\source.ts:10" }, + }, + ]), + frames([{ type: "message_start" }]), + frames([{ type: "message_start" }]) + "data: [DONE]\n\n", + ]; + + for (const badStream of badStreams) { + const completions: unknown[] = []; + let failures = 0; + const response = await createClaudeWebResponse(byteStream(badStream), { + model: "claude-sonnet-5", + stream: true, + responseMetadata: {}, + onComplete: (result) => completions.push(result), + onFailure: () => { + failures += 1; + }, + }); + const output = await response.text(); + + assert.match(output, /Claude Web stream protocol error/); + assert.doesNotMatch(output, /C:\\\\Users|private|source\.ts/); + assert.equal((output.match(/"finish_reason"/g) ?? []).length, 0); + assert.deepEqual(completions, []); + assert.equal(failures, 1); + } + }); +}); diff --git a/tests/unit/claude-web-transport.test.ts b/tests/unit/claude-web-transport.test.ts new file mode 100644 index 0000000000..4e4efd16d6 --- /dev/null +++ b/tests/unit/claude-web-transport.test.ts @@ -0,0 +1,410 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import { ClaudeWebExecutor } from "../../open-sse/executors/claude-web.ts"; +import { + __resetClaudeWebBrowserTemplatesForTesting, + type ClaudeWebTransportRequest, + type ClaudeWebTransportResult, +} from "../../open-sse/executors/claude-web/browserTransport.ts"; +import { transformToClaude } from "../../open-sse/executors/claude-web/payload.ts"; +import { __resetClaudeWebSessionForTesting } from "../../open-sse/executors/claude-web/session.ts"; +import { + isClaudeWebChallenge, + sendClaudeWebDirect, +} from "../../open-sse/executors/claude-web/transport.ts"; + +const originalBrowserFlag = process.env.WEB_COOKIE_USE_BROWSER; +const originalPoolFlag = process.env.OMNIROUTE_BROWSER_POOL; + +function textStream(text: string): ReadableStream<Uint8Array> { + const bytes = new TextEncoder().encode(text); + return new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +function validClaudeSse(answer = "answer"): ReadableStream<Uint8Array> { + const events = [ + { type: "message_start", message: { model: "claude-sonnet-5" } }, + { type: "content_block_start", index: 0, content_block: { type: "text" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: answer } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" } }, + { type: "message_stop" }, + ]; + return textStream(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")); +} + +function transportRequest(): ClaudeWebTransportRequest { + const payload = transformToClaude( + { messages: [{ role: "user", content: "hello" }] }, + "claude-sonnet-5" + ); + const organizationId = "organization-test"; + const conversationId = "00000000-0000-4000-8000-000000000010"; + return { + scopeKey: "scope-digest", + organizationId, + conversationId, + endpointSuffix: "completion", + pageUrl: `https://claude.ai/chat/${conversationId}`, + url: `https://claude.ai/api/organizations/${organizationId}/chat_conversations/${conversationId}/completion`, + cookieString: "sessionKey=session-secret; cf_clearance=existing-browser-bound-value", + headers: { + Accept: "text/event-stream", + "Content-Type": "application/json", + }, + payload, + locale: payload.locale, + timezone: payload.timezone, + signal: null, + }; +} + +function result( + status: number, + body: ReadableStream<Uint8Array> | null, + headers: Headers = new Headers() +): ClaudeWebTransportResult { + return { status, headers, body }; +} + +function credentials(connectionId = "connection-a") { + return { + cookie: "sessionKey=session-secret; cf_clearance=existing-browser-bound-value", + orgId: "organization-test", + connectionId, + }; +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +beforeEach(() => { + delete process.env.WEB_COOKIE_USE_BROWSER; + delete process.env.OMNIROUTE_BROWSER_POOL; + __resetClaudeWebSessionForTesting(); + __resetClaudeWebBrowserTemplatesForTesting(); +}); + +afterEach(() => { + restoreEnv("WEB_COOKIE_USE_BROWSER", originalBrowserFlag); + restoreEnv("OMNIROUTE_BROWSER_POOL", originalPoolFlag); + __resetClaudeWebSessionForTesting(); + __resetClaudeWebBrowserTemplatesForTesting(); +}); + +describe("Claude Web direct transport", () => { + it("sends only the prepared endpoint, headers, cookie, and payload", async () => { + const request = transportRequest(); + let capturedUrl = ""; + let capturedOptions: Record<string, unknown> = {}; + + const response = await sendClaudeWebDirect(request, { + async tlsFetch(url, options) { + capturedUrl = url; + capturedOptions = options as unknown as Record<string, unknown>; + return { + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: validClaudeSse(), + }; + }, + }); + + assert.equal(capturedUrl, request.url); + assert.equal(capturedOptions.method, "POST"); + assert.equal(capturedOptions.stream, true); + assert.deepEqual(JSON.parse(String(capturedOptions.body)), request.payload); + assert.equal((capturedOptions.headers as Record<string, string>).Cookie, request.cookieString); + assert.equal(response.status, 200); + assert.ok(response.body); + }); + + it("classifies only known 403 challenge evidence", () => { + assert.equal( + isClaudeWebChallenge({ + ...result(403, null, new Headers({ "cf-mitigated": "challenge" })), + bodyText: "opaque", + }), + true + ); + assert.equal( + isClaudeWebChallenge({ + ...result(403, null), + bodyText: "<title>Just a moment...", + }), + true + ); + assert.equal( + isClaudeWebChallenge({ ...result(403, null), bodyText: '{"error":"forbidden"}' }), + false + ); + assert.equal( + isClaudeWebChallenge({ + ...result(429, null, new Headers({ "cf-mitigated": "challenge" })), + bodyText: "Just a moment...", + }), + false + ); + }); +}); + +describe("Claude Web executor transport orchestration", () => { + it("returns buffered output and generated state from an exact direct request", async () => { + const requests: ClaudeWebTransportRequest[] = []; + const executor = new ClaudeWebExecutor({ + async sendDirect(request) { + requests.push(request); + return result( + 200, + validClaudeSse("direct answer"), + new Headers({ "Content-Type": "text/event-stream" }) + ); + }, + async sendBrowser() { + throw new Error("browser should not be called"); + }, + }); + + const execution = await executor.execute({ + model: "claude-sonnet-5", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: false, + credentials: credentials(), + signal: null, + }); + + assert.equal(requests.length, 1); + assert.match( + requests[0].url, + /^https:\/\/claude\.ai\/api\/organizations\/organization-test\/chat_conversations\/[a-f0-9-]+\/completion$/ + ); + assert.ok(requests[0].payload.create_conversation_params); + const responseBody = (await execution.response.json()) as { + choices: Array<{ message: { content: string } }>; + claude_web: { conversation_id: string; assistant_message_uuid: string }; + }; + assert.equal(responseBody.choices[0].message.content, "direct answer"); + assert.equal(responseBody.claude_web.conversation_id, requests[0].conversationId); + assert.match( + execution.response.headers.get("X-OmniRoute-Claude-Web-Assistant-Message-Uuid") ?? "", + /^[a-f0-9-]+$/ + ); + }); + + it("returns only a redacted request projection to the shared request logger", async () => { + let sentRequest: ClaudeWebTransportRequest | undefined; + const executor = new ClaudeWebExecutor({ + async sendDirect(request) { + sentRequest = request; + return result(200, validClaudeSse()); + }, + async sendBrowser() { + throw new Error("browser should not be called"); + }, + }); + const execution = await executor.execute({ + model: "claude-sonnet-5", + body: { + messages: [{ role: "user", content: "PRIVATE_PROMPT" }], + tools: [ + { + type: "function", + function: { name: "private_tool", parameters: { type: "object" } }, + }, + ], + }, + stream: false, + credentials: { + ...credentials(), + deviceId: "private-device-id", + }, + signal: null, + }); + + assert.equal(sentRequest?.payload.prompt, "PRIVATE_PROMPT"); + const logged = JSON.stringify({ + url: execution.url, + headers: execution.headers, + body: execution.transformedBody, + }); + assert.doesNotMatch( + logged, + /PRIVATE_PROMPT|private_tool|private-device-id|organization-test|[0-9a-f]{8}-[0-9a-f-]{27}/i + ); + assert.match(String(execution.url), /.*/); + }); + + it("does not expose transport exception details in logs or error responses", async () => { + const messages: string[] = []; + const executor = new ClaudeWebExecutor({ + async sendDirect() { + throw new Error( + "request failed for organization-test and sessionKey=session-secret at https://claude.ai/private" + ); + }, + async sendBrowser() { + throw new Error("browser should not be called"); + }, + }); + const execution = await executor.execute({ + model: "claude-sonnet-5", + body: { messages: [{ role: "user", content: "PRIVATE_PROMPT" }] }, + stream: false, + credentials: credentials(), + signal: null, + log: { + error(_tag, message) { + messages.push(message); + }, + }, + }); + const exposed = `${await execution.response.text()} ${messages.join(" ")}`; + assert.equal(execution.response.status, 502); + assert.doesNotMatch( + exposed, + /organization-test|session-secret|PRIVATE_PROMPT|claude\.ai\/private/ + ); + }); + + it("falls back from a direct challenge to the same scoped browser request when enabled", async () => { + process.env.OMNIROUTE_BROWSER_POOL = "on"; + let directRequest: ClaudeWebTransportRequest | undefined; + let browserRequest: ClaudeWebTransportRequest | undefined; + const executor = new ClaudeWebExecutor({ + async sendDirect(request) { + directRequest = request; + return { + ...result(403, null, new Headers({ "cf-mitigated": "challenge" })), + bodyText: "Just a moment...", + }; + }, + async sendBrowser(request) { + browserRequest = request; + return result(200, validClaudeSse("browser answer")); + }, + }); + + const execution = await executor.execute({ + model: "claude-opus-4-8", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: false, + credentials: credentials(), + signal: null, + }); + + assert.ok(directRequest); + assert.strictEqual(browserRequest, directRequest); + assert.equal(execution.response.status, 200); + assert.equal( + ((await execution.response.json()) as { choices: Array<{ message: { content: string } }> }) + .choices[0].message.content, + "browser answer" + ); + }); + + it("returns a sanitized challenge without invoking browser fallback when disabled", async () => { + let browserCalls = 0; + const executor = new ClaudeWebExecutor({ + async sendDirect() { + return { + ...result(403, null, new Headers({ "cf-mitigated": "challenge" })), + bodyText: "Just a moment... at C:\\Users\\private\\claude-cookie.txt", + }; + }, + async sendBrowser() { + browserCalls += 1; + return result(200, validClaudeSse()); + }, + }); + + const execution = await executor.execute({ + model: "claude-sonnet-5", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: credentials(), + signal: null, + }); + const body = await execution.response.text(); + + assert.equal(execution.response.status, 403); + assert.equal(browserCalls, 0); + assert.match(body, /cloudflare_challenge/); + assert.doesNotMatch(body, /C:\\\\Users|private|cookie\.txt/); + }); + + it("invalidates reusable continuation state after a 401", async () => { + const conversationIds: string[] = []; + let call = 0; + const executor = new ClaudeWebExecutor({ + async sendDirect(request) { + call += 1; + conversationIds.push(request.conversationId); + if (call === 2) return { ...result(401, null), bodyText: "expired" }; + return result(200, validClaudeSse(call === 1 ? "first answer" : "replacement answer")); + }, + async sendBrowser() { + throw new Error("browser should not be called"); + }, + }); + + const firstBody = { messages: [{ role: "user", content: "first question" }] }; + const followUpBody = { + messages: [ + { role: "user", content: "first question" }, + { role: "assistant", content: "first answer" }, + { role: "user", content: "second question" }, + ], + }; + await executor.execute({ + model: "claude-sonnet-5", + body: firstBody, + stream: false, + credentials: credentials(), + signal: null, + }); + const unauthorized = await executor.execute({ + model: "claude-sonnet-5", + body: followUpBody, + stream: false, + credentials: credentials(), + signal: null, + }); + await executor.execute({ + model: "claude-sonnet-5", + body: followUpBody, + stream: false, + credentials: credentials(), + signal: null, + }); + + assert.equal(unauthorized.response.status, 401); + assert.equal(conversationIds[1], conversationIds[0]); + assert.notEqual(conversationIds[2], conversationIds[0]); + }); + + it("has no execution-time dependency on the standalone Turnstile solver", () => { + const executorSource = readFileSync( + new URL("../../open-sse/executors/claude-web.ts", import.meta.url), + "utf8" + ); + const indexSource = readFileSync( + new URL("../../open-sse/executors/index.ts", import.meta.url), + "utf8" + ); + + assert.doesNotMatch(executorSource, /claudeTurnstileSolver|getCfClearanceToken|tryBackedChat/); + assert.doesNotMatch(indexSource, /ClaudeWebWithAutoRefresh/); + assert.match(indexSource, /"claude-web": new ClaudeWebExecutor\(\)/); + assert.match(indexSource, /"cw-web": new ClaudeWebExecutor\(\)/); + }); +}); diff --git a/tests/unit/issue-6662-repro.test.ts b/tests/unit/issue-6662-repro.test.ts index 6f058d7789..4c3234dc2a 100644 --- a/tests/unit/issue-6662-repro.test.ts +++ b/tests/unit/issue-6662-repro.test.ts @@ -22,9 +22,8 @@ import assert from "node:assert/strict"; const mod = await import("../../open-sse/executors/v0-vercel-web.ts"); const { ClaudeWebExecutor } = await import("../../open-sse/executors/claude-web.ts"); -const { __setTlsFetchOverrideForTesting } = await import( - "../../open-sse/services/claudeTlsClient.ts" -); +const { __setTlsFetchOverrideForTesting } = + await import("../../open-sse/services/claudeTlsClient.ts"); function sseUpstream(events: string[]): Response { const encoder = new TextEncoder(); @@ -128,8 +127,8 @@ describe("#6662 repro — claude-web drops thinking_delta reasoning_content", () body: { messages: [{ role: "user", content: "Solve 17*23" }] }, stream: true, credentials: { - // cf_clearance present up front so normalizeClaudeSessionCookieWithAutoRefresh - // takes the fast path and never attempts a real Turnstile solve in-test. + // The direct transport forwards the supplied cookie as-is. This fixture + // stays entirely local through the injected TLS response. apiKey: "sessionKey=fake-session; cf_clearance=fake-clearance", orgId: "org-test", conversationId: "conv-test",