From e3e962dbda3c7b2e5f35aa6b70211e576938e3d0 Mon Sep 17 00:00:00 2001 From: Mourad Maatoug Date: Fri, 15 May 2026 14:04:34 +0200 Subject: [PATCH] feat(cc-bridge): config-driven system block transforms (closes #2260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a config-driven DSL pipeline for normalizing the system blocks sent to Anthropic via the Claude Code bridge. Removes third-party agent CLI fingerprints (OpenCode, Cline, Cursor, Continue) and prepends the SDK identity + signed billing header so the request looks classifier-correct regardless of which client originally sent it. Why a DSL: future fingerprint defenses ship as config rows, not new code. Mode presets (basic/aggressive/custom), reorder, add, delete, reset — all hot-reloaded through the existing runtimeSettings registry. Files - open-sse/services/ccBridgeTransforms.ts (~440 LOC; 8 op-kinds, executor, buildBillingHeaderValue ported from ex-machina cch.ts, singleton config matching cliFingerprints pattern, T4-200 fixture-matching defaults) - tests/unit/cc-bridge-transforms.test.ts (30 tests; per-op, idempotency, ordering, verbatim-OpenCode regression) - open-sse/services/claudeCodeCompatible.ts (step 5b in buildAndSign pipeline + re-exports for downstream) - open-sse/executors/base.ts (maintainer comment: native OAuth path is intentionally not routed through the DSL; it has its own billing prepend at lines 744-773) - src/lib/config/runtimeSettings.ts (RuntimeReloadSection + snapshot + default + normalize + apply + change-detection) - src/shared/validation/settingsSchemas.ts (zod discriminatedUnion over the 8 op-kinds, max 50 ops per pipeline) - src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx (Settings UI Card: enabled toggle, ordered pipeline list with reorder buttons, add-op dropdown, reset-to-defaults) - src/i18n/messages/en.json (10 new strings under settings.ccBridgeTransforms.*) Defaults match the empirical T4=200 layout: [billing, identity, sanitized, extras]. Verified against prod call logs (1c1946a2 = plugin-200, de8ab5bd = raw-429) and four-variant live A/B test (T1=429 baseline, T2=429 phrase-only, T3=429 sanitizer-only, T4=200 with billing+identity). Test result: 30/30 ccBridgeTransforms + 6/6 baseline claude-code-compatible-helpers green. Scope: CC bridge surface only (open-sse/services/claudeCodeCompatible.ts). Native claude OAuth path is NOT routed through the DSL — it has its own billing prepend mechanism that already works. Refs: https://github.com/diegosouzapw/OmniRoute/issues/2260 --- open-sse/executors/base.ts | 9 + open-sse/services/ccBridgeTransforms.ts | 577 ++++++++++++++++++ open-sse/services/claudeCodeCompatible.ts | 19 + .../settings/components/RoutingTab.tsx | 279 +++++++++ src/i18n/messages/en.json | 9 + src/lib/config/runtimeSettings.ts | 32 +- src/shared/validation/settingsSchemas.ts | 57 ++ tests/unit/cc-bridge-transforms.test.ts | 432 +++++++++++++ 8 files changed, 1413 insertions(+), 1 deletion(-) create mode 100644 open-sse/services/ccBridgeTransforms.ts create mode 100644 tests/unit/cc-bridge-transforms.test.ts diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index e9750e2dc5..c25de9aa1c 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -619,6 +619,15 @@ export class BaseExecutor { remapToolNamesInRequest(tb); obfuscateInBody(tb); + // NOTE (issue #2260): This is the native `claude` provider OAuth path. + // It is intentionally NOT routed through applyCcBridgeTransformPipeline. + // The native OAuth path already prepends its own billing line + sentinel + // (see lines ~744-773 below, dayStamp-based, cc_entrypoint=cli, cch=00000 + // placeholder, signed at body level). The CC bridge transforms DSL is + // wired into buildAndSignClaudeCodeRequest (claudeCodeCompatible.ts step 5b) + // which is the anthropic-compatible-cc-* relay path — a different, + // separately classified surface. Do not double-prepend here. + // Real CLI never sets cache_control on tools. if (Array.isArray(tb.tools)) { for (const t of tb.tools as Array>) { diff --git a/open-sse/services/ccBridgeTransforms.ts b/open-sse/services/ccBridgeTransforms.ts new file mode 100644 index 0000000000..62a149da23 --- /dev/null +++ b/open-sse/services/ccBridgeTransforms.ts @@ -0,0 +1,577 @@ +/** + * CC Bridge Transforms — config-driven request body normalization for the + * Claude Code Compatible (`anthropic-compatible-cc-*`) bridge. + * + * Goal: ensure the final request body OmniRoute sends to Anthropic's + * `/v1/messages?beta=true` endpoint has classifier-correct structure + * regardless of which client (OpenCode, Cline, Cursor, Continue, raw API + * consumer) supplied the prompt. + * + * Approach: an ordered pipeline of declarative `TransformOp` entries that + * mutate the request body in place. Each op is idempotent; the executor is + * pure (no I/O); new defenses can be added through Settings UI by appending + * a new op — no new TypeScript needed. + * + * Reference implementation: ex-machina/opencode-anthropic-auth `transform.ts` + * and `cch.ts`. Ported with the same defaults (paragraph anchors, identity + * prefixes, text replacements, billing header algorithm) but generalised + * behind a discriminated-union DSL so future fingerprints are configurable. + * + * Related: OmniRoute issue #2260. + */ +import { createHash } from "node:crypto"; + +// ──────────────────────────────────────────────────────────────────────────── +// DSL types +// ──────────────────────────────────────────────────────────────────────────── + +export type TransformOp = + | DropParagraphIfContainsOp + | DropParagraphIfStartsWithOp + | ReplaceTextOp + | ReplaceRegexOp + | DropBlockIfContainsOp + | PrependSystemBlockOp + | AppendSystemBlockOp + | InjectBillingHeaderOp; + +export interface DropParagraphIfContainsOp { + kind: "drop_paragraph_if_contains"; + needles: string[]; + caseSensitive?: boolean; +} + +export interface DropParagraphIfStartsWithOp { + kind: "drop_paragraph_if_starts_with"; + prefixes: string[]; + caseSensitive?: boolean; +} + +export interface ReplaceTextOp { + kind: "replace_text"; + match: string; + replacement: string; + allOccurrences?: boolean; +} + +export interface ReplaceRegexOp { + kind: "replace_regex"; + pattern: string; + flags?: string; + replacement: string; +} + +export interface DropBlockIfContainsOp { + kind: "drop_block_if_contains"; + needles: string[]; +} + +export interface PrependSystemBlockOp { + kind: "prepend_system_block"; + text: string; + /** Skip if any earlier block already starts with this prefix. */ + idempotencyKey?: string; +} + +export interface AppendSystemBlockOp { + kind: "append_system_block"; + text: string; + idempotencyKey?: string; +} + +export interface InjectBillingHeaderOp { + kind: "inject_billing_header"; + /** Anthropic billing entrypoint label (e.g. `sdk-cli`, `cli`). */ + entrypoint: string; + /** + * Version suffix algorithm: + * - ex-machina: sha256(SALT + chars-at-positions + version).slice(0,3) + * - omniroute-daystamp: sha256(YYYY-MM-DD + version).slice(0,3) + */ + versionFormat: "ex-machina" | "omniroute-daystamp"; + /** + * CCH attestation algorithm: + * - sha256-first-user: sha256(firstUserMessageText).slice(0,5) (ex-machina) + * - xxhash64-body: defer to body-level signRequestBody; emit "00000" here + * - static-zero: emit "00000" (relay endpoints don't validate) + */ + cchAlgo: "sha256-first-user" | "xxhash64-body" | "static-zero"; + /** Override the embedded `cc_version=` value. Defaults to `2.1.137`. */ + version?: string; +} + +export interface CcBridgeTransformsConfig { + enabled: boolean; + pipeline: TransformOp[]; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Ported constants (ex-machina/constants.ts) +// ──────────────────────────────────────────────────────────────────────────── + +/** Stable salt used by ex-machina/cch.ts for the version-suffix hash. */ +export const CCH_SALT = "59cf53e54c78"; +/** Character positions sampled from the first user message text. */ +export const CCH_POSITIONS = [4, 7, 20] as const; +/** Default `cc_version=` value embedded in the billing header. */ +export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.137"; +/** Identity sentinel prepended for Claude Agent SDK callers. */ +export const CLAUDE_AGENT_SDK_IDENTITY = + "You are a Claude agent, built on Anthropic's Claude Agent SDK."; +/** Paragraph anchors from ex-machina (URLs identifying third-party agents). */ +export const DEFAULT_PARAGRAPH_REMOVAL_ANCHORS = [ + "github.com/anomalyco/opencode", + "opencode.ai/docs", + "github.com/cline/cline", + "github.com/getcursor/cursor", + "continue.dev", +]; +/** Identity paragraph prefixes that signal a third-party agent. */ +export const DEFAULT_IDENTITY_PREFIXES = ["You are OpenCode"]; +/** Text replacements (last entry is the v1.7.5 phrase-shape filter fix). */ +export const DEFAULT_TEXT_REPLACEMENTS: Array<{ match: string; replacement: string }> = [ + { match: "if OpenCode honestly", replacement: "if the assistant honestly" }, + { + match: "Here is some useful information about the environment you are running in:", + replacement: "Environment context you are running in:", + }, +]; + +/** + * Default pipeline shipped with the PR — matches the T4-200 fixture layout + * proven against the live OmniRoute deployment (call log + * f0c2fedb-b27a-4f1d-9ee6-0c88646a6d42). + * + * Layout after pipeline (system blocks): + * [0] x-anthropic-billing-header: cc_version=…; cc_entrypoint=sdk-cli; cch=… + * [1] You are a Claude agent, built on Anthropic's Claude Agent SDK. + * [2..] sanitized caller-supplied system blocks + */ +export const DEFAULT_CC_BRIDGE_PIPELINE: TransformOp[] = [ + // Sanitize caller-supplied system blocks first so dropped paragraphs do not + // accidentally contain a stale billing header from a previous pass. + { + kind: "drop_paragraph_if_contains", + needles: [...DEFAULT_PARAGRAPH_REMOVAL_ANCHORS], + }, + { + kind: "drop_paragraph_if_starts_with", + prefixes: [...DEFAULT_IDENTITY_PREFIXES], + }, + ...DEFAULT_TEXT_REPLACEMENTS.map((r) => ({ + kind: "replace_text", + match: r.match, + replacement: r.replacement, + allOccurrences: true, + })), + // Then prepend the SDK identity (becomes block[1] after billing prepend). + { + kind: "prepend_system_block", + text: CLAUDE_AGENT_SDK_IDENTITY, + idempotencyKey: "claude-agent-sdk-identity", + }, + // Billing header always lands at block[0] — matches T4-200 fixture layout. + { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }, +]; + +export const DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG: CcBridgeTransformsConfig = { + enabled: true, + pipeline: DEFAULT_CC_BRIDGE_PIPELINE, +}; + +// ──────────────────────────────────────────────────────────────────────────── +// Billing header value +// ──────────────────────────────────────────────────────────────────────────── + +interface SystemBlock { + type: string; + text?: string; + [key: string]: unknown; +} + +interface MessageContentBlock { + type?: string; + text?: string; + [key: string]: unknown; +} + +interface Message { + role?: string; + content?: string | MessageContentBlock[]; + [key: string]: unknown; +} + +/** + * Pull the textual content of the first user message in the request. + * Returns "" when no user message has text content. + */ +export function extractFirstUserMessageText(messages: Message[]): string { + if (!Array.isArray(messages)) return ""; + + for (const msg of messages) { + if (msg?.role !== "user") continue; + if (typeof msg.content === "string") return msg.content; + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block?.type === "text" && typeof block.text === "string") { + return block.text; + } + } + } + } + return ""; +} + +function sha256Hex(input: string): string { + return createHash("sha256").update(input, "utf8").digest("hex"); +} + +function pickCharsAtPositions(text: string, positions: readonly number[]): string { + return positions.map((p) => (typeof text[p] === "string" ? text[p] : "\0")).join(""); +} + +/** + * Compute the `cc_version` suffix per the ex-machina algorithm. + * + * `sha256(CCH_SALT + chars-at-CCH_POSITIONS(firstUserMessage) + version).slice(0, 3)` + */ +export function computeExMachinaVersionSuffix(firstUserText: string, version: string): string { + const picks = pickCharsAtPositions(firstUserText, CCH_POSITIONS); + return sha256Hex(`${CCH_SALT}${picks}${version}`).slice(0, 3); +} + +/** + * Compute the `cc_version` suffix per the OmniRoute native-OAuth algorithm: + * sha256(YYYY-MM-DD + version).slice(0,3). Stable per UTC day. + */ +export function computeDaystampVersionSuffix(version: string, now: Date = new Date()): string { + const dayStamp = now.toISOString().slice(0, 10); + return sha256Hex(`${dayStamp}${version}`).slice(0, 3); +} + +/** + * Compute the `cch=` attestation value per ex-machina algorithm: + * sha256(firstUserMessage).slice(0,5). + */ +export function computeCchSha256FirstUser(firstUserText: string): string { + return sha256Hex(firstUserText).slice(0, 5); +} + +interface BuildBillingHeaderOptions { + entrypoint: string; + versionFormat: "ex-machina" | "omniroute-daystamp"; + cchAlgo: "sha256-first-user" | "xxhash64-body" | "static-zero"; + version?: string; + now?: Date; +} + +/** + * Build the `x-anthropic-billing-header: …` string injected as system block[0]. + * + * `xxhash64-body` and `static-zero` both emit `cch=00000` here because the + * actual body-level CCH attestation is computed later by + * `claudeCodeCCH.signRequestBody()` and replaces a 00000 placeholder in the + * serialized JSON. ex-machina's `sha256-first-user` value lives in the + * header itself. + */ +export function buildBillingHeaderValue( + messages: Message[], + options: BuildBillingHeaderOptions +): string { + const version = options.version || DEFAULT_CLAUDE_CODE_VERSION; + const firstUserText = extractFirstUserMessageText(messages); + + const suffix = + options.versionFormat === "omniroute-daystamp" + ? computeDaystampVersionSuffix(version, options.now) + : computeExMachinaVersionSuffix(firstUserText, version); + + let cch: string; + switch (options.cchAlgo) { + case "sha256-first-user": + cch = computeCchSha256FirstUser(firstUserText); + break; + case "xxhash64-body": + case "static-zero": + default: + cch = "00000"; + break; + } + + return `x-anthropic-billing-header: cc_version=${version}.${suffix}; cc_entrypoint=${options.entrypoint}; cch=${cch};`; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Body shape helpers +// ──────────────────────────────────────────────────────────────────────────── + +interface RequestBody { + system?: string | SystemBlock[]; + messages?: Message[]; + [key: string]: unknown; +} + +function normalizeSystemToBlocks(system: unknown): SystemBlock[] { + if (system === null || system === undefined) return []; + if (typeof system === "string") { + return system.length > 0 ? [{ type: "text", text: system }] : []; + } + if (Array.isArray(system)) { + return system + .filter((b): b is SystemBlock => !!b && typeof b === "object") + .map((b) => ({ ...b })); + } + if (typeof system === "object") { + const block = system as SystemBlock; + return block && typeof block.text === "string" ? [{ ...block }] : []; + } + return []; +} + +function isTextBlock(block: SystemBlock): block is SystemBlock & { text: string } { + return block.type === "text" && typeof block.text === "string"; +} + +function containsString(haystack: string, needle: string, caseSensitive: boolean): boolean { + if (caseSensitive) return haystack.includes(needle); + return haystack.toLowerCase().includes(needle.toLowerCase()); +} + +function startsWithString(haystack: string, prefix: string, caseSensitive: boolean): boolean { + if (caseSensitive) return haystack.startsWith(prefix); + return haystack.toLowerCase().startsWith(prefix.toLowerCase()); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Op executors +// ──────────────────────────────────────────────────────────────────────────── + +function applyDropParagraphIfContains( + blocks: SystemBlock[], + op: DropParagraphIfContainsOp +): SystemBlock[] { + const caseSensitive = op.caseSensitive !== false; + const needles = op.needles || []; + if (needles.length === 0) return blocks; + + return blocks.map((block) => { + if (!isTextBlock(block)) return block; + const paragraphs = block.text.split(/\n\n+/); + const filtered = paragraphs.filter( + (p) => !needles.some((n) => containsString(p, n, caseSensitive)) + ); + return { ...block, text: filtered.join("\n\n") }; + }); +} + +function applyDropParagraphIfStartsWith( + blocks: SystemBlock[], + op: DropParagraphIfStartsWithOp +): SystemBlock[] { + const caseSensitive = op.caseSensitive !== false; + const prefixes = op.prefixes || []; + if (prefixes.length === 0) return blocks; + + return blocks.map((block) => { + if (!isTextBlock(block)) return block; + const paragraphs = block.text.split(/\n\n+/); + const filtered = paragraphs.filter( + (p) => !prefixes.some((prefix) => startsWithString(p.trimStart(), prefix, caseSensitive)) + ); + return { ...block, text: filtered.join("\n\n") }; + }); +} + +function applyReplaceText(blocks: SystemBlock[], op: ReplaceTextOp): SystemBlock[] { + if (!op.match) return blocks; + return blocks.map((block) => { + if (!isTextBlock(block)) return block; + if (!block.text.includes(op.match)) return block; + let next = block.text; + if (op.allOccurrences) { + next = next.split(op.match).join(op.replacement); + } else { + next = next.replace(op.match, op.replacement); + } + return { ...block, text: next }; + }); +} + +function applyReplaceRegex(blocks: SystemBlock[], op: ReplaceRegexOp): SystemBlock[] { + if (!op.pattern) return blocks; + let regex: RegExp; + try { + regex = new RegExp(op.pattern, op.flags ?? "u"); + } catch { + return blocks; + } + return blocks.map((block) => { + if (!isTextBlock(block)) return block; + return { ...block, text: block.text.replace(regex, op.replacement) }; + }); +} + +function applyDropBlockIfContains(blocks: SystemBlock[], op: DropBlockIfContainsOp): SystemBlock[] { + const needles = op.needles || []; + if (needles.length === 0) return blocks; + return blocks.filter((block) => { + if (!isTextBlock(block)) return true; + return !needles.some((n) => block.text.includes(n)); + }); +} + +function applyPrependSystemBlock(blocks: SystemBlock[], op: PrependSystemBlockOp): SystemBlock[] { + if (!op.text) return blocks; + if (op.idempotencyKey || op.text) { + const firstText = blocks.find(isTextBlock)?.text ?? ""; + if (firstText === op.text || firstText.startsWith(op.text)) { + return blocks; + } + } + return [{ type: "text", text: op.text }, ...blocks]; +} + +function applyAppendSystemBlock(blocks: SystemBlock[], op: AppendSystemBlockOp): SystemBlock[] { + if (!op.text) return blocks; + const last = [...blocks].reverse().find(isTextBlock)?.text ?? ""; + if (last === op.text) return blocks; + return [...blocks, { type: "text", text: op.text }]; +} + +function applyInjectBillingHeader( + body: RequestBody, + blocks: SystemBlock[], + op: InjectBillingHeaderOp +): SystemBlock[] { + // No user message → no billing header (ex-machina parity, transform.ts:340). + const messages = Array.isArray(body.messages) ? body.messages : []; + const hasUser = messages.some((m) => m?.role === "user"); + if (!hasUser) return blocks; + + const headerValue = buildBillingHeaderValue(messages, { + entrypoint: op.entrypoint, + versionFormat: op.versionFormat, + cchAlgo: op.cchAlgo, + version: op.version, + }); + + // Idempotency: replace any existing billing header block (ex-machina + native + // OAuth path both rebuild on retry; see executors/base.ts issue #1712). + const headerPrefix = "x-anthropic-billing-header:"; + const filtered = blocks.filter((b) => !(isTextBlock(b) && b.text.startsWith(headerPrefix))); + return [{ type: "text", text: headerValue }, ...filtered]; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Pipeline executor +// ──────────────────────────────────────────────────────────────────────────── + +export interface ApplyPipelineResult { + body: RequestBody; + appliedOpKinds: string[]; +} + +/** + * Run the configured transform pipeline against a request body. + * + * The body is mutated in place (its `system` field is replaced); returned for + * chaining. `appliedOpKinds` lists the ops that ran (omitting no-ops when + * config is disabled). When `config.enabled === false`, the body is returned + * unchanged and `appliedOpKinds` is empty. + */ +export function applyCcBridgeTransformPipeline( + body: RequestBody, + config: CcBridgeTransformsConfig = getCcBridgeTransformsConfig() +): ApplyPipelineResult { + if (!body || typeof body !== "object") { + return { body, appliedOpKinds: [] }; + } + if (!config.enabled || !Array.isArray(config.pipeline) || config.pipeline.length === 0) { + return { body, appliedOpKinds: [] }; + } + + let blocks = normalizeSystemToBlocks(body.system); + const appliedOpKinds: string[] = []; + + for (const op of config.pipeline) { + switch (op.kind) { + case "drop_paragraph_if_contains": + blocks = applyDropParagraphIfContains(blocks, op); + break; + case "drop_paragraph_if_starts_with": + blocks = applyDropParagraphIfStartsWith(blocks, op); + break; + case "replace_text": + blocks = applyReplaceText(blocks, op); + break; + case "replace_regex": + blocks = applyReplaceRegex(blocks, op); + break; + case "drop_block_if_contains": + blocks = applyDropBlockIfContains(blocks, op); + break; + case "prepend_system_block": + blocks = applyPrependSystemBlock(blocks, op); + break; + case "append_system_block": + blocks = applyAppendSystemBlock(blocks, op); + break; + case "inject_billing_header": + blocks = applyInjectBillingHeader(body, blocks, op); + break; + default: { + // Unknown op kind — skip silently to keep forward compatibility. + continue; + } + } + appliedOpKinds.push(op.kind); + } + + // Drop empty text blocks left behind by paragraph removal (matches + // ex-machina sanitizeSystemText trim semantics). + blocks = blocks.filter((b) => !isTextBlock(b) || b.text.length > 0); + + body.system = blocks; + return { body, appliedOpKinds }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Runtime singleton (mirrors cliFingerprints `_cliCompatProviders` pattern). +// ──────────────────────────────────────────────────────────────────────────── + +let _runtimeConfig: CcBridgeTransformsConfig = DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG; + +/** + * Replace the active CC bridge transforms config. Called from + * `runtimeSettings.applyCcBridgeTransformsSection()` when the Settings UI + * saves a new pipeline. + */ +export function setCcBridgeTransformsConfig(config: CcBridgeTransformsConfig | null): void { + if (!config) { + _runtimeConfig = DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG; + return; + } + _runtimeConfig = { + enabled: config.enabled !== false, + pipeline: Array.isArray(config.pipeline) ? config.pipeline : DEFAULT_CC_BRIDGE_PIPELINE, + }; +} + +/** + * Read the currently active config (defaults to DEFAULT_CC_BRIDGE_PIPELINE). + */ +export function getCcBridgeTransformsConfig(): CcBridgeTransformsConfig { + return _runtimeConfig; +} + +/** + * Reset to defaults — exposed for tests and the Settings UI "Reset" button. + */ +export function resetCcBridgeTransformsConfig(): void { + _runtimeConfig = DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG; +} diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index c8439ef118..93a6cacb6c 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -12,6 +12,7 @@ import { enforceCacheControlLimit, } from "./claudeCodeConstraints.ts"; import { obfuscateInBody } from "./claudeCodeObfuscation.ts"; +import { applyCcBridgeTransformPipeline } from "./ccBridgeTransforms.ts"; /** * `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept @@ -332,6 +333,11 @@ export async function buildAndSignClaudeCodeRequest( // Step 5: Cache control enforceCacheControlLimit(body); + // Step 5b: Config-driven CC bridge transforms (issue #2260) + // Normalizes system blocks to classifier-correct structure regardless of source + // client (OpenCode, Cline, Cursor, Continue, raw API). Idempotent on re-run. + applyCcBridgeTransformPipeline(body as Parameters[0]); + // Step 6: Obfuscation (optional, per-provider setting) if (enableObfuscation) { obfuscateInBody(body); @@ -362,6 +368,19 @@ export { disableThinkingIfToolChoiceForced, enforceCacheControlLimit, } from "./claudeCodeConstraints.ts"; +export { + applyCcBridgeTransformPipeline, + buildBillingHeaderValue, + setCcBridgeTransformsConfig, + getCcBridgeTransformsConfig, + resetCcBridgeTransformsConfig, + DEFAULT_CC_BRIDGE_PIPELINE, + DEFAULT_PARAGRAPH_REMOVAL_ANCHORS, + DEFAULT_IDENTITY_PREFIXES, + DEFAULT_TEXT_REPLACEMENTS, + CLAUDE_AGENT_SDK_IDENTITY, +} from "./ccBridgeTransforms.ts"; +export type { TransformOp, CcBridgeTransformsConfig } from "./ccBridgeTransforms.ts"; export function resolveClaudeCodeCompatibleEffort( sourceBody?: Record | null, diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 82f5110939..9630be847d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -10,6 +10,109 @@ import { normalizeCliCompatProviderId, } from "@/shared/constants/cliCompatProviders"; +// Mirror of DEFAULT_CC_BRIDGE_PIPELINE from open-sse/services/ccBridgeTransforms.ts. +// Kept client-side so the UI can render + reset to defaults without a server roundtrip. +// Server is the source of truth — if pipeline is empty/missing on PATCH, server falls back to its own defaults. +const DEFAULT_CC_BRIDGE_PIPELINE_CLIENT = [ + { + kind: "drop_paragraph_if_contains", + needles: [ + "github.com/anomalyco/opencode", + "opencode.ai/docs", + "github.com/cline/cline", + "github.com/getcursor/cursor", + "continue.dev", + ], + }, + { + kind: "drop_paragraph_if_starts_with", + prefixes: ["You are OpenCode"], + }, + { + kind: "replace_text", + match: "if OpenCode honestly", + replacement: "if the assistant honestly", + }, + { + kind: "replace_text", + match: "Here is some useful information about the environment you are running in:", + replacement: "Environment context you are running in:", + }, + { + kind: "prepend_system_block", + text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", + idempotencyKey: "claude-agent-sdk-identity", + }, + { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }, +]; + +const TRANSFORM_OP_KINDS = [ + "drop_paragraph_if_contains", + "drop_paragraph_if_starts_with", + "replace_text", + "replace_regex", + "drop_block_if_contains", + "prepend_system_block", + "append_system_block", + "inject_billing_header", +] as const; + +function summarizeTransformOp(op: any): string { + switch (op?.kind) { + case "drop_paragraph_if_contains": + return `drop paragraphs containing: ${(op.needles || []).slice(0, 3).join(", ")}${(op.needles || []).length > 3 ? "…" : ""}`; + case "drop_paragraph_if_starts_with": + return `drop paragraphs starting with: ${(op.prefixes || []).slice(0, 3).join(", ")}${(op.prefixes || []).length > 3 ? "…" : ""}`; + case "replace_text": + return `replace "${(op.match || "").slice(0, 40)}${(op.match || "").length > 40 ? "…" : ""}" → "${(op.replacement || "").slice(0, 40)}${(op.replacement || "").length > 40 ? "…" : ""}"`; + case "replace_regex": + return `regex /${op.pattern}/${op.flags || ""} → "${(op.replacement || "").slice(0, 40)}"`; + case "drop_block_if_contains": + return `drop blocks containing: ${(op.needles || []).slice(0, 3).join(", ")}`; + case "prepend_system_block": + return `prepend block: "${(op.text || "").slice(0, 60)}${(op.text || "").length > 60 ? "…" : ""}"`; + case "append_system_block": + return `append block: "${(op.text || "").slice(0, 60)}${(op.text || "").length > 60 ? "…" : ""}"`; + case "inject_billing_header": + return `inject billing header (entrypoint=${op.entrypoint}, version=${op.versionFormat}, cch=${op.cchAlgo})`; + default: + return JSON.stringify(op); + } +} + +function makeBlankOp(kind: string): any { + switch (kind) { + case "drop_paragraph_if_contains": + return { kind, needles: [""] }; + case "drop_paragraph_if_starts_with": + return { kind, prefixes: [""] }; + case "replace_text": + return { kind, match: "", replacement: "" }; + case "replace_regex": + return { kind, pattern: "", flags: "g", replacement: "" }; + case "drop_block_if_contains": + return { kind, needles: [""] }; + case "prepend_system_block": + return { kind, text: "" }; + case "append_system_block": + return { kind, text: "" }; + case "inject_billing_header": + return { + kind, + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }; + default: + return { kind }; + } +} + export default function RoutingTab() { const [settings, setSettings] = useState({ alwaysPreserveClientCache: "auto", @@ -17,7 +120,9 @@ export default function RoutingTab() { cliCompatProviders: [], autoRoutingEnabled: true, autoRoutingDefaultVariant: "lkgp", + ccBridgeTransforms: { enabled: true, pipeline: DEFAULT_CC_BRIDGE_PIPELINE_CLIENT }, }); + const [addOpKind, setAddOpKind] = useState("drop_paragraph_if_contains"); const [loading, setLoading] = useState(true); const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false); const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" }); @@ -59,6 +164,43 @@ export default function RoutingTab() { ); const cliCompatProviderSet = useMemo(() => new Set(cliCompatProviders), [cliCompatProviders]); + const ccBridgeTransforms = useMemo(() => { + const raw = settings.ccBridgeTransforms; + if (raw && typeof raw === "object" && Array.isArray(raw.pipeline)) { + return { enabled: raw.enabled !== false, pipeline: raw.pipeline }; + } + return { enabled: true, pipeline: DEFAULT_CC_BRIDGE_PIPELINE_CLIENT }; + }, [settings.ccBridgeTransforms]); + + const updateCcBridgeTransforms = (next: { enabled: boolean; pipeline: any[] }) => { + updateSetting({ ccBridgeTransforms: next }); + }; + + const moveCcBridgeOp = (index: number, direction: -1 | 1) => { + const pipeline = [...ccBridgeTransforms.pipeline]; + const target = index + direction; + if (target < 0 || target >= pipeline.length) return; + [pipeline[index], pipeline[target]] = [pipeline[target], pipeline[index]]; + updateCcBridgeTransforms({ enabled: ccBridgeTransforms.enabled, pipeline }); + }; + + const deleteCcBridgeOp = (index: number) => { + const pipeline = ccBridgeTransforms.pipeline.filter((_, i) => i !== index); + updateCcBridgeTransforms({ enabled: ccBridgeTransforms.enabled, pipeline }); + }; + + const addCcBridgeOp = () => { + const pipeline = [...ccBridgeTransforms.pipeline, makeBlankOp(addOpKind)]; + updateCcBridgeTransforms({ enabled: ccBridgeTransforms.enabled, pipeline }); + }; + + const resetCcBridgePipeline = () => { + updateCcBridgeTransforms({ + enabled: true, + pipeline: DEFAULT_CC_BRIDGE_PIPELINE_CLIENT.map((op) => ({ ...op })), + }); + }; + const toggleCliCompatProvider = (providerId: string, enabled: boolean) => { const normalizedProviderId = normalizeCliCompatProviderId(providerId); const nextProviders = new Set(cliCompatProviders); @@ -331,6 +473,143 @@ export default function RoutingTab() { + +
+
+
+ +
+
+

{t("ccBridgeTransforms")}

+

{t("ccBridgeTransformsDesc")}

+

+ {t("ccBridgeTransformsEnabled", { count: ccBridgeTransforms.pipeline.length })} +

+
+
+
+ +
+
+ + {ccBridgeTransforms.pipeline.length === 0 ? ( +

{t("ccBridgeTransformsEmpty")}

+ ) : ( +
    + {ccBridgeTransforms.pipeline.map((op: any, index: number) => ( +
  1. + + {index + 1} + +
    +
    {op?.kind}
    +
    + {summarizeTransformOp(op)} +
    +
    +
    + + + +
    +
  2. + ))} +
+ )} + +
+ + + +
+ +

+ Pipeline applies in order at step 5b of the CC bridge request build (after cache-control, + before ZWJ obfuscation). All ops are idempotent on re-run. Op editing beyond + reorder/delete requires PATCH via /api/settings — full per-op form is coming in a + follow-up; for now, edit the JSON directly via the API to fine-tune individual ops. +

+
+
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 7955339c41..e3568cbebf 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3386,6 +3386,15 @@ "disableFingerprintTitle": "Disable fingerprint for {provider}", "forcedFingerprintTitle": "Always enabled for {provider} — required for OAuth account safety; cannot be turned off.", "forcedFingerprintBadge": "Required", + "ccBridgeTransforms": "CC Bridge System Transforms", + "ccBridgeTransformsDesc": "Config-driven pipeline that normalizes the system prompt sent to Anthropic via the CC bridge. Removes third-party client fingerprints (OpenCode, Cline, Cursor, Continue), prepends the SDK identity, and injects the billing header — so Anthropic's classifier sees a classifier-correct request regardless of which client sent it. Issue #2260.", + "ccBridgeTransformsEnabled": "{count} transform(s) active in pipeline", + "ccBridgeTransformsAddOp": "Add transform", + "ccBridgeTransformsReset": "Reset to defaults", + "ccBridgeTransformsEmpty": "Pipeline empty — requests pass through untouched.", + "ccBridgeTransformsMoveUp": "Move up", + "ccBridgeTransformsMoveDown": "Move down", + "ccBridgeTransformsDelete": "Delete", "routingStrategy": "Routing Strategy", "routingAdvancedGuideTitle": "Advanced routing guidance", "routingAdvancedGuideHint1": "Use Fill First for predictable priority, Round Robin for fairness, and P2C for latency resilience.", diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts index 9df6d5985f..d0f9fbf56f 100644 --- a/src/lib/config/runtimeSettings.ts +++ b/src/lib/config/runtimeSettings.ts @@ -12,7 +12,8 @@ export type RuntimeReloadSection = | "healthCheckLogs" | "thoughtSignature" | "modelsDevSync" - | "corsOrigins"; + | "corsOrigins" + | "ccBridgeTransforms"; export interface RuntimeReloadChange { section: RuntimeReloadSection; @@ -31,6 +32,7 @@ interface RuntimeSettingsSnapshot { modelsDevSyncEnabled: boolean; modelsDevSyncInterval: number | null; corsOrigins: string; + ccBridgeTransforms: unknown; } const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { @@ -45,6 +47,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { modelsDevSyncEnabled: false, modelsDevSyncInterval: null, corsOrigins: "", + ccBridgeTransforms: null, }; let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null; @@ -180,6 +183,7 @@ export function buildRuntimeSettingsSnapshot( modelsDevSyncEnabled: settings.modelsDevSyncEnabled === true, modelsDevSyncInterval: normalizeNumber(settings.modelsDevSyncInterval), corsOrigins: typeof settings.corsOrigins === "string" ? settings.corsOrigins : "", + ccBridgeTransforms: parseStoredJson(settings.ccBridgeTransforms, "ccBridgeTransforms"), }; } @@ -257,6 +261,24 @@ async function applyCorsOriginsSection(corsOrigins: string) { setRuntimeAllowedOrigins(corsOrigins); } +async function applyCcBridgeTransformsSection(ccBridgeTransforms: unknown) { + const { setCcBridgeTransformsConfig, resetCcBridgeTransformsConfig } = + await import("@omniroute/open-sse/services/ccBridgeTransforms.ts"); + + if ( + ccBridgeTransforms === null || + ccBridgeTransforms === undefined || + typeof ccBridgeTransforms !== "object" + ) { + resetCcBridgeTransformsConfig(); + return; + } + + setCcBridgeTransformsConfig( + ccBridgeTransforms as Parameters[0] + ); +} + async function applyModelsDevSyncSection( previousSnapshot: RuntimeSettingsSnapshot, currentSnapshot: RuntimeSettingsSnapshot, @@ -392,6 +414,14 @@ export async function applyRuntimeSettings( markChanged("corsOrigins"); } + if ( + force || + hasChanged(currentSnapshot.ccBridgeTransforms, previousSnapshot.ccBridgeTransforms) + ) { + await applyCcBridgeTransformsSection(currentSnapshot.ccBridgeTransforms); + markChanged("ccBridgeTransforms"); + } + lastAppliedSnapshot = currentSnapshot; return changes; } diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 7a5b1b3411..8ed8804eba 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -63,6 +63,63 @@ export const updateSettingsSchema = z.object({ wsAuth: z.boolean().optional(), // CLI Fingerprint compatibility (per-provider) cliCompatProviders: z.array(z.string().max(100)).optional(), + // CC bridge transforms (issue #2260): config-driven pipeline that normalizes + // system blocks at the Claude Code bridge so any client (OpenCode, Cline, + // Cursor, Continue, raw API) ends up with classifier-correct structure. + ccBridgeTransforms: z + .object({ + enabled: z.boolean(), + pipeline: z + .array( + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("drop_paragraph_if_contains"), + needles: z.array(z.string().max(500)).max(50), + caseSensitive: z.boolean().optional(), + }), + z.object({ + kind: z.literal("drop_paragraph_if_starts_with"), + prefixes: z.array(z.string().max(500)).max(50), + caseSensitive: z.boolean().optional(), + }), + z.object({ + kind: z.literal("replace_text"), + match: z.string().min(1).max(500), + replacement: z.string().max(500), + allOccurrences: z.boolean().optional(), + }), + z.object({ + kind: z.literal("replace_regex"), + pattern: z.string().min(1).max(500), + flags: z.string().max(10).optional(), + replacement: z.string().max(500), + }), + z.object({ + kind: z.literal("drop_block_if_contains"), + needles: z.array(z.string().max(500)).max(50), + }), + z.object({ + kind: z.literal("prepend_system_block"), + text: z.string().min(1).max(2000), + idempotencyKey: z.string().max(100).optional(), + }), + z.object({ + kind: z.literal("append_system_block"), + text: z.string().min(1).max(2000), + idempotencyKey: z.string().max(100).optional(), + }), + z.object({ + kind: z.literal("inject_billing_header"), + entrypoint: z.string().min(1).max(50), + versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]), + cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]), + version: z.string().max(50).optional(), + }), + ]) + ) + .max(50), + }) + .optional(), // Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4") stripModelPrefix: z.boolean().optional(), // Cache control preservation mode diff --git a/tests/unit/cc-bridge-transforms.test.ts b/tests/unit/cc-bridge-transforms.test.ts new file mode 100644 index 0000000000..11666d0bc5 --- /dev/null +++ b/tests/unit/cc-bridge-transforms.test.ts @@ -0,0 +1,432 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + DEFAULT_CC_BRIDGE_PIPELINE, + DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG, + DEFAULT_CLAUDE_CODE_VERSION, + CLAUDE_AGENT_SDK_IDENTITY, + DEFAULT_PARAGRAPH_REMOVAL_ANCHORS, + DEFAULT_IDENTITY_PREFIXES, + DEFAULT_TEXT_REPLACEMENTS, + applyCcBridgeTransformPipeline, + buildBillingHeaderValue, + computeCchSha256FirstUser, + computeExMachinaVersionSuffix, + computeDaystampVersionSuffix, + extractFirstUserMessageText, + setCcBridgeTransformsConfig, + getCcBridgeTransformsConfig, + resetCcBridgeTransformsConfig, +} = await import("../../open-sse/services/ccBridgeTransforms.ts"); + +type TransformOp = Parameters[1] extends infer C + ? C extends { pipeline: infer P } + ? P extends Array + ? T + : never + : never + : never; + +function bodyWithSystem(systemBlocks: Array<{ type: string; text: string }>, userText = "hi") { + return { + model: "claude-opus-4-7", + messages: [{ role: "user", content: [{ type: "text", text: userText }] }], + system: systemBlocks, + }; +} + +function runPipeline(body: any, ops: any[]) { + return applyCcBridgeTransformPipeline(body, { enabled: true, pipeline: ops }); +} + +test.beforeEach(() => { + resetCcBridgeTransformsConfig(); +}); + +// ── Defaults sanity ───────────────────────────────────────────────────────── + +test("DEFAULT_CC_BRIDGE_PIPELINE places billing header at [0] and identity at [1] in output", () => { + const result = runPipeline( + bodyWithSystem([{ type: "text", text: "body" }], "user prompt"), + DEFAULT_CC_BRIDGE_PIPELINE + ); + const blocks = result.body.system as any[]; + assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header:")); + assert.equal(blocks[1].text, CLAUDE_AGENT_SDK_IDENTITY); +}); + +test("DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG is enabled", () => { + assert.equal(DEFAULT_CC_BRIDGE_TRANSFORMS_CONFIG.enabled, true); +}); + +test("DEFAULT_PARAGRAPH_REMOVAL_ANCHORS includes ex-machina v1.7.5 anchors", () => { + assert.ok(DEFAULT_PARAGRAPH_REMOVAL_ANCHORS.includes("github.com/anomalyco/opencode")); + assert.ok(DEFAULT_PARAGRAPH_REMOVAL_ANCHORS.includes("opencode.ai/docs")); +}); + +test("DEFAULT_IDENTITY_PREFIXES strips OpenCode identity", () => { + assert.ok(DEFAULT_IDENTITY_PREFIXES.includes("You are OpenCode")); +}); + +test("DEFAULT_TEXT_REPLACEMENTS includes v1.7.5 phrase fix", () => { + const phrase = "Here is some useful information about the environment you are running in:"; + const rule = DEFAULT_TEXT_REPLACEMENTS.find((r) => r.match === phrase); + assert.ok(rule, "expected phrase replacement"); + assert.equal(rule!.replacement, "Environment context you are running in:"); +}); + +// ── Op: drop_paragraph_if_contains ───────────────────────────────────────── + +test("drop_paragraph_if_contains removes matching paragraphs only", () => { + const text = [ + "Intro paragraph here.", + "See github.com/anomalyco/opencode for details.", + "Final paragraph survives.", + ].join("\n\n"); + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "drop_paragraph_if_contains", needles: ["github.com/anomalyco/opencode"] }, + ]); + const out = (result.body.system as any[])[0].text as string; + assert.ok(out.includes("Intro paragraph")); + assert.ok(out.includes("Final paragraph")); + assert.ok(!out.includes("anomalyco")); +}); + +test("drop_paragraph_if_contains with empty needles is a no-op", () => { + const text = "Stays put."; + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "drop_paragraph_if_contains", needles: [] }, + ]); + assert.equal((result.body.system as any[])[0].text, text); +}); + +// ── Op: drop_paragraph_if_starts_with ────────────────────────────────────── + +test("drop_paragraph_if_starts_with drops identity-prefixed paragraphs", () => { + const text = ["You are OpenCode, a helper.", "Real content."].join("\n\n"); + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "drop_paragraph_if_starts_with", prefixes: ["You are OpenCode"] }, + ]); + const out = (result.body.system as any[])[0].text as string; + assert.ok(!out.includes("You are OpenCode")); + assert.ok(out.includes("Real content")); +}); + +// ── Op: replace_text ─────────────────────────────────────────────────────── + +test("replace_text replaces v1.7.5 phrase exactly once by default", () => { + const phrase = "Here is some useful information about the environment you are running in:"; + const text = `Prefix. ${phrase} body.`; + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { + kind: "replace_text", + match: phrase, + replacement: "Environment context you are running in:", + }, + ]); + const out = (result.body.system as any[])[0].text as string; + assert.ok(out.includes("Environment context you are running in:")); + assert.ok(!out.includes("Here is some useful information")); +}); + +test("replace_text allOccurrences=true replaces every hit", () => { + const text = "X X X"; + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "replace_text", match: "X", replacement: "Y", allOccurrences: true }, + ]); + assert.equal((result.body.system as any[])[0].text, "Y Y Y"); +}); + +// ── Op: replace_regex ────────────────────────────────────────────────────── + +test("replace_regex handles flags and patterns", () => { + const text = "foo bar foo BAR"; + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "replace_regex", pattern: "foo", flags: "gi", replacement: "baz" }, + ]); + assert.equal((result.body.system as any[])[0].text, "baz bar baz BAR"); +}); + +test("replace_regex invalid pattern is a no-op", () => { + const text = "stays."; + const result = runPipeline(bodyWithSystem([{ type: "text", text }]), [ + { kind: "replace_regex", pattern: "[invalid(", replacement: "X" }, + ]); + assert.equal((result.body.system as any[])[0].text, text); +}); + +// ── Op: drop_block_if_contains ───────────────────────────────────────────── + +test("drop_block_if_contains drops entire matching blocks", () => { + const body = bodyWithSystem([ + { type: "text", text: "block A keep" }, + { type: "text", text: "block B drop github.com/anomalyco/opencode" }, + { type: "text", text: "block C keep" }, + ]); + const result = runPipeline(body, [ + { kind: "drop_block_if_contains", needles: ["github.com/anomalyco/opencode"] }, + ]); + const blocks = result.body.system as any[]; + assert.equal(blocks.length, 2); + assert.equal(blocks[0].text, "block A keep"); + assert.equal(blocks[1].text, "block C keep"); +}); + +// ── Op: prepend_system_block ─────────────────────────────────────────────── + +test("prepend_system_block adds identity at position [0]", () => { + const result = runPipeline(bodyWithSystem([{ type: "text", text: "body" }]), [ + { kind: "prepend_system_block", text: CLAUDE_AGENT_SDK_IDENTITY }, + ]); + const blocks = result.body.system as any[]; + assert.equal(blocks[0].text, CLAUDE_AGENT_SDK_IDENTITY); + assert.equal(blocks[1].text, "body"); +}); + +test("prepend_system_block is idempotent when first block already matches", () => { + const body = bodyWithSystem([ + { type: "text", text: CLAUDE_AGENT_SDK_IDENTITY }, + { type: "text", text: "body" }, + ]); + const result = runPipeline(body, [ + { kind: "prepend_system_block", text: CLAUDE_AGENT_SDK_IDENTITY }, + ]); + const blocks = result.body.system as any[]; + assert.equal(blocks.length, 2); + assert.equal(blocks[0].text, CLAUDE_AGENT_SDK_IDENTITY); +}); + +// ── Op: append_system_block ──────────────────────────────────────────────── + +test("append_system_block adds to end and stays idempotent", () => { + const body = bodyWithSystem([{ type: "text", text: "body" }]); + const op = { kind: "append_system_block", text: "trailer" } as const; + let result = runPipeline(body, [op]); + result = runPipeline(result.body, [op]); + const blocks = result.body.system as any[]; + assert.equal(blocks.length, 2); + assert.equal(blocks[1].text, "trailer"); +}); + +// ── Op: inject_billing_header ────────────────────────────────────────────── + +test("inject_billing_header builds ex-machina sdk-cli header at [0]", () => { + const body = bodyWithSystem([{ type: "text", text: "body" }], "user prompt here"); + const result = runPipeline(body, [ + { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }, + ]); + const blocks = result.body.system as any[]; + assert.equal(blocks[0].type, "text"); + assert.match( + blocks[0].text, + /^x-anthropic-billing-header: cc_version=\d+\.\d+\.\d+\.[0-9a-f]{3}; cc_entrypoint=sdk-cli; cch=[0-9a-f]{5};$/ + ); +}); + +test("inject_billing_header is idempotent — replaces existing header in place", () => { + const body = bodyWithSystem([{ type: "text", text: "body" }], "p1"); + const op = { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + } as const; + const first = runPipeline(body, [op]); + const second = runPipeline(first.body, [op]); + const blocks = second.body.system as any[]; + const headerCount = blocks.filter( + (b: any) => typeof b.text === "string" && b.text.startsWith("x-anthropic-billing-header:") + ).length; + assert.equal(headerCount, 1); +}); + +test("inject_billing_header skips when no user message present", () => { + const result = applyCcBridgeTransformPipeline( + { messages: [], system: [{ type: "text", text: "body" }] } as any, + { + enabled: true, + pipeline: [ + { + kind: "inject_billing_header", + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }, + ], + } + ); + const blocks = result.body.system as any[]; + assert.equal(blocks[0].text, "body"); +}); + +test("inject_billing_header xxhash64-body uses 00000 placeholder", () => { + const result = runPipeline(bodyWithSystem([{ type: "text", text: "body" }], "user"), [ + { + kind: "inject_billing_header", + entrypoint: "cli", + versionFormat: "omniroute-daystamp", + cchAlgo: "xxhash64-body", + }, + ]); + const blocks = result.body.system as any[]; + assert.match(blocks[0].text, /cch=00000;$/); + assert.match(blocks[0].text, /cc_entrypoint=cli;/); +}); + +// ── Algorithm primitives ─────────────────────────────────────────────────── + +test("extractFirstUserMessageText handles string and block content", () => { + assert.equal(extractFirstUserMessageText([{ role: "user", content: "hello" }] as any), "hello"); + assert.equal( + extractFirstUserMessageText([ + { role: "system", content: "ignore" } as any, + { role: "user", content: [{ type: "text", text: "world" }] as any }, + ]), + "world" + ); + assert.equal(extractFirstUserMessageText([] as any), ""); +}); + +test("computeCchSha256FirstUser yields 5-hex digest", () => { + const hex = computeCchSha256FirstUser("hello"); + assert.match(hex, /^[0-9a-f]{5}$/); +}); + +test("computeExMachinaVersionSuffix yields 3-hex digest", () => { + const hex = computeExMachinaVersionSuffix( + "the quick brown fox jumps", + DEFAULT_CLAUDE_CODE_VERSION + ); + assert.match(hex, /^[0-9a-f]{3}$/); +}); + +test("computeDaystampVersionSuffix yields 3-hex digest", () => { + const hex = computeDaystampVersionSuffix( + DEFAULT_CLAUDE_CODE_VERSION, + new Date("2026-05-15T00:00:00Z") + ); + assert.match(hex, /^[0-9a-f]{3}$/); +}); + +test("buildBillingHeaderValue produces the expected ex-machina format", () => { + const value = buildBillingHeaderValue([{ role: "user", content: "hello" }] as any, { + entrypoint: "sdk-cli", + versionFormat: "ex-machina", + cchAlgo: "sha256-first-user", + }); + assert.match( + value, + /^x-anthropic-billing-header: cc_version=2\.1\.137\.[0-9a-f]{3}; cc_entrypoint=sdk-cli; cch=[0-9a-f]{5};$/ + ); +}); + +// ── Disabled config short-circuits ───────────────────────────────────────── + +test("applyCcBridgeTransformPipeline does nothing when config.enabled=false", () => { + const body = bodyWithSystem([{ type: "text", text: "body" }]); + const result = applyCcBridgeTransformPipeline(body, { + enabled: false, + pipeline: DEFAULT_CC_BRIDGE_PIPELINE, + }); + assert.equal(result.appliedOpKinds.length, 0); + assert.equal((result.body.system as any[])[0].text, "body"); +}); + +// ── Singleton config getters/setters ─────────────────────────────────────── + +test("setCcBridgeTransformsConfig swaps the runtime config; reset restores defaults", () => { + setCcBridgeTransformsConfig({ enabled: false, pipeline: [] }); + assert.equal(getCcBridgeTransformsConfig().enabled, false); + resetCcBridgeTransformsConfig(); + assert.equal(getCcBridgeTransformsConfig().enabled, true); +}); + +// ── Pipeline ordering ────────────────────────────────────────────────────── + +test("pipeline ordering is preserved — replace runs before drop", () => { + const body = bodyWithSystem([{ type: "text", text: "X" }]); + const result = runPipeline(body, [ + { kind: "replace_text", match: "X", replacement: "github.com/anomalyco/opencode" }, + { kind: "drop_paragraph_if_contains", needles: ["github.com/anomalyco/opencode"] }, + ]); + const blocks = result.body.system as any[]; + assert.equal(blocks.length, 0); +}); + +// ── End-to-end: T4-200 fixture layout ────────────────────────────────────── + +test("DEFAULT_CC_BRIDGE_PIPELINE produces T4-200 fixture shape on verbatim OpenCode prompt", () => { + // Verbatim phrase from issue #2260 — the v1.7.5 trigger. + const fingerprintPhrase = + "Here is some useful information about the environment you are running in:"; + const openCodePrompt = [ + "You are OpenCode, a coding assistant.", + "See github.com/anomalyco/opencode for details.", + fingerprintPhrase, + "Working directory: /home/dev", + "If OpenCode honestly cannot find the answer, say so.", + ].join("\n\n"); + + const body = { + model: "claude-opus-4-7", + messages: [{ role: "user", content: [{ type: "text", text: "Say OK." }] }], + system: [ + { type: "text", text: openCodePrompt }, + { type: "text", text: "Memory protocol block." }, + { type: "text", text: "Browser MCP block." }, + ], + }; + + const result = applyCcBridgeTransformPipeline(body as any, { + enabled: true, + pipeline: DEFAULT_CC_BRIDGE_PIPELINE, + }); + const blocks = result.body.system as any[]; + + // Layout: [billing][identity][sanitized][memory][browser] + assert.equal(blocks.length, 5, "expected 5 system blocks"); + assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header:")); + assert.equal(blocks[1].text, CLAUDE_AGENT_SDK_IDENTITY); + + const sanitized = blocks[2].text as string; + assert.ok(!sanitized.includes("You are OpenCode"), "identity paragraph should be dropped"); + assert.ok( + !sanitized.includes("github.com/anomalyco/opencode"), + "anchor paragraph should be dropped" + ); + assert.ok(!sanitized.includes(fingerprintPhrase), "v1.7.5 phrase should be replaced"); + assert.ok( + sanitized.includes("Environment context you are running in:"), + "replacement phrase should be present" + ); + assert.ok( + sanitized.includes("Working directory: /home/dev"), + "non-fingerprint content preserved" + ); + + assert.equal(blocks[3].text, "Memory protocol block."); + assert.equal(blocks[4].text, "Browser MCP block."); + + // Pipeline reports every op ran. + assert.equal(result.appliedOpKinds.length, DEFAULT_CC_BRIDGE_PIPELINE.length); +}); + +// ── String system field normalization ────────────────────────────────────── + +test("string system field is normalized to a single text block before transforms", () => { + const body = { + model: "claude-opus-4-7", + messages: [{ role: "user", content: "hi" }], + system: "raw string system", + }; + const result = runPipeline(body as any, [{ kind: "prepend_system_block", text: "PREFIX" }]); + const blocks = result.body.system as any[]; + assert.equal(blocks[0].text, "PREFIX"); + assert.equal(blocks[1].text, "raw string system"); +});