fix(claude-web): align session transport and fallback (#8230)

This commit is contained in:
backryun
2026-07-23 17:24:22 +09:00
committed by GitHub
parent 686375ba72
commit 18f1f667bf
25 changed files with 4504 additions and 1096 deletions

View File

@@ -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

View File

@@ -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: <uuid>
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.

View File

@@ -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,
},
],
};

View File

@@ -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<string, unknown>,
signal?: AbortSignal
): Promise<boolean> {
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();

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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);
}
/**

View File

@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
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;
}

View File

@@ -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);
}

View File

@@ -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,
};
}

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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.
*/

View File

@@ -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();
}

View File

@@ -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" });
});
});

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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/);

View File

@@ -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);
});
});

View File

@@ -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");
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});

View File

@@ -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);
}
});
});

View File

@@ -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...</title>",
}),
true
);
assert.equal(
isClaudeWebChallenge({ ...result(403, null), bodyText: '{"error":"forbidden"}' }),
false
);
assert.equal(
isClaudeWebChallenge({
...result(429, null, new Headers({ "cf-mitigated": "challenge" })),
bodyText: "<title>Just a moment...</title>",
}),
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), /<organization>.*<conversation>/);
});
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: "<title>Just a moment...</title>",
};
},
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: "<title>Just a moment...</title> 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\(\)/);
});
});

View File

@@ -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",