mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(claude-web): align session transport and fallback (#8230)
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
510
tests/unit/claude-web-browser-transport.test.ts
Normal file
510
tests/unit/claude-web-browser-transport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
258
tests/unit/claude-web-live-alignment.test.ts
Normal file
258
tests/unit/claude-web-live-alignment.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
tests/unit/claude-web-payload-runtime.test.ts
Normal file
153
tests/unit/claude-web-payload-runtime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
291
tests/unit/claude-web-session.test.ts
Normal file
291
tests/unit/claude-web-session.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
325
tests/unit/claude-web-stream.test.ts
Normal file
325
tests/unit/claude-web-stream.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
410
tests/unit/claude-web-transport.test.ts
Normal file
410
tests/unit/claude-web-transport.test.ts
Normal 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\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user