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

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