diff --git a/.cspell/words.txt b/.cspell/words.txt index d9b6cecc..c813851e 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -1,4 +1,5 @@ anthropic +anthropics anyhow claudemd clippy @@ -15,6 +16,7 @@ RAII ratatui replacen reqwest +rfind rustls serde strum @@ -23,3 +25,4 @@ thiserror tokio tracing venv +xxhash diff --git a/CLAUDE.md b/CLAUDE.md index a5c6d1ba..fe03ec2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,42 +7,43 @@ oxide-code is a terminal-based AI coding assistant written in Rust, inspired by ### CLI ```bash -ox # Start an interactive session +ox # Start an interactive session ``` ### Project Layout ```text . -├── crates/oxide-code/ # Main binary crate -├── docs/ # Roadmap and research notes -└── target/ # Build output +├── crates/oxide-code/ # Main binary crate +├── docs/ # Roadmap and research notes +└── target/ # Build output ``` ### Crate Structure (`crates/oxide-code/src/`) ```text . -├── client.rs # Client module root +├── client.rs # Client module root ├── client/ -│ └── anthropic.rs # Anthropic Messages API streaming client -├── config.rs # Configuration loading (env vars, model, base URL) +│ ├── anthropic.rs # Anthropic Messages API streaming client +│ └── billing.rs # Billing attribution header (fingerprint, cch attestation) +├── config.rs # Configuration loading (env vars, model, base URL) ├── config/ -│ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, file locking -├── main.rs # CLI entry point, agent loop, async REPL -├── message.rs # Conversation message types -├── prompt.rs # System prompt builder (section assembly, static content) +│ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, file locking +├── main.rs # CLI entry point, agent loop, async REPL +├── message.rs # Conversation message types +├── prompt.rs # System prompt builder (section assembly, static content) ├── prompt/ -│ ├── environment.rs # Runtime environment detection (platform, git, date) -│ └── instructions.rs # Instruction file discovery and loading (CLAUDE.md, AGENTS.md) -├── tool.rs # Tool trait, registry, definitions +│ ├── environment.rs # Runtime environment detection (platform, git, date) +│ └── instructions.rs # Instruction file discovery and loading (CLAUDE.md, AGENTS.md) +├── tool.rs # Tool trait, registry, definitions └── tool/ - ├── bash.rs # Shell command execution with timeout - ├── edit.rs # Exact string replacement in files - ├── glob.rs # File pattern matching (glob) - ├── grep.rs # Content search via regex - ├── read.rs # File reading with line numbers and pagination - └── write.rs # File writing with directory creation + ├── bash.rs # Shell command execution with timeout + ├── edit.rs # Exact string replacement in files + ├── glob.rs # File pattern matching (glob) + ├── grep.rs # Content search via regex + ├── read.rs # File reading with line numbers and pagination + └── write.rs # File writing with directory creation ``` ## Coding Conventions diff --git a/Cargo.lock b/Cargo.lock index d0619106..fcabc025 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -191,6 +200,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -216,6 +234,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deranged" version = "0.5.8" @@ -225,6 +253,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -376,6 +414,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -817,11 +865,13 @@ dependencies = [ "security-framework", "serde", "serde_json", + "sha2", "tempfile", "time", "tokio", "tracing", "tracing-subscriber", + "xxhash-rust", ] [[package]] @@ -1216,6 +1266,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1548,6 +1609,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1584,6 +1651,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1898,6 +1971,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 1b93c1d9..654b1e41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ reqwest = { version = "0.12", default-features = false, features = [ security-framework = "3" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" tempfile = "3" time = { version = "0.3", features = ["local-offset"] } tokio = { version = "1", features = [ @@ -50,6 +51,7 @@ tracing-subscriber = { version = "0.3", default-features = false, features = [ "env-filter", "fmt", ] } +xxhash-rust = { version = "0.8", features = ["xxh64"] } [profile.release] lto = true diff --git a/crates/oxide-code/Cargo.toml b/crates/oxide-code/Cargo.toml index 11841267..0b73ab41 100644 --- a/crates/oxide-code/Cargo.toml +++ b/crates/oxide-code/Cargo.toml @@ -23,10 +23,12 @@ regex.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true time.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +xxhash-rust.workspace = true [target.'cfg(target_os = "macos")'.dependencies] security-framework.workspace = true diff --git a/crates/oxide-code/src/client.rs b/crates/oxide-code/src/client.rs index e5299972..aa06db7f 100644 --- a/crates/oxide-code/src/client.rs +++ b/crates/oxide-code/src/client.rs @@ -1 +1,2 @@ pub mod anthropic; +mod billing; diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 685664ef..89c7f795 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -4,8 +4,9 @@ use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_ use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; +use super::billing; use crate::config::{Auth, Config, ThinkingConfig}; -use crate::message::Message; +use crate::message::{ContentBlock, Message, Role}; use crate::tool::ToolDefinition; const API_VERSION: &str = "2023-06-01"; @@ -28,13 +29,17 @@ const SYSTEM_PROMPT_PREFIX: &str = "You are Claude Code, Anthropic's official CL struct CreateMessageRequest<'a> { model: &'a str, max_tokens: u32, - messages: &'a [Message], + /// Serialized before `messages` so the billing header's `cch=00000` + /// placeholder appears first in the JSON, making [`billing::inject_cch`]'s + /// single-occurrence replacement safe even when tool results contain the + /// literal placeholder string. system: Vec>, stream: bool, #[serde(skip_serializing_if = "Option::is_none")] tools: Option<&'a [ToolDefinition]>, #[serde(skip_serializing_if = "Option::is_none")] thinking: Option<&'a ThinkingConfig>, + messages: &'a [Message], } /// A text block in the system prompt array. The Anthropic API accepts `system` @@ -240,10 +245,28 @@ impl Client { system: Option<&str>, tools: &[ToolDefinition], ) -> Result>> { - let mut system_blocks = vec![SystemBlock { + let billing_header = if matches!(self.config.auth, Auth::OAuth(_)) { + let first_text = first_user_text(messages); + let fingerprint = billing::compute_fingerprint(first_text, CLAUDE_CLI_VERSION); + Some(billing::build_billing_header( + CLAUDE_CLI_VERSION, + &fingerprint, + )) + } else { + None + }; + + let mut system_blocks = Vec::new(); + if let Some(ref header) = billing_header { + system_blocks.push(SystemBlock { + r#type: "text", + text: header, + }); + } + system_blocks.push(SystemBlock { r#type: "text", text: SYSTEM_PROMPT_PREFIX, - }]; + }); if let Some(s) = system { system_blocks.push(SystemBlock { r#type: "text", @@ -252,22 +275,26 @@ impl Client { } let url = format!("{}/v1/messages", self.config.base_url); - let body = serde_json::to_value(CreateMessageRequest { + let mut body = serde_json::to_string(&CreateMessageRequest { model: &self.config.model, max_tokens: self.config.max_tokens, - messages, system: system_blocks, stream: true, tools: (!tools.is_empty()).then_some(tools), thinking: self.config.thinking.as_ref(), + messages, }) .context("failed to serialize request")?; + if billing_header.is_some() { + body = billing::inject_cch(&body); + } + let (tx, rx) = mpsc::channel(64); let http = self.http.clone(); tokio::spawn(async move { - let result = stream_sse(&http, &url, &body, &tx).await; + let result = stream_sse(&http, &url, body, &tx).await; if let Err(e) = result { _ = tx.send(Err(e)).await; } @@ -277,13 +304,27 @@ impl Client { } } +/// Extract the text of the first user message for fingerprint computation. +fn first_user_text(messages: &[Message]) -> &str { + messages + .iter() + .find(|m| m.role == Role::User) + .into_iter() + .flat_map(|m| &m.content) + .find_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .unwrap_or("") +} + async fn stream_sse( http: &reqwest::Client, url: &str, - body: &serde_json::Value, + body: String, tx: &mpsc::Sender>, ) -> Result<()> { - let response = http.post(url).json(body).send().await?; + let response = http.post(url).body(body).send().await?; let status = response.status(); if !status.is_success() { @@ -509,4 +550,37 @@ mod tests { let frame = "data: {not valid json}"; assert!(parse_sse_frame(frame).is_err()); } + + // ── first_user_text ── + + #[test] + fn first_user_text_extracts_from_first_user_message() { + let messages = vec![Message::user("hello world"), Message::assistant("hi")]; + assert_eq!(first_user_text(&messages), "hello world"); + } + + #[test] + fn first_user_text_returns_empty_for_no_user_messages() { + let messages = vec![Message::assistant("hi")]; + assert_eq!(first_user_text(&messages), ""); + } + + #[test] + fn first_user_text_returns_empty_for_empty_messages() { + let messages: Vec = vec![]; + assert_eq!(first_user_text(&messages), ""); + } + + #[test] + fn first_user_text_returns_empty_when_first_user_has_no_text() { + let messages = vec![Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "id".to_owned(), + content: "result".to_owned(), + is_error: false, + }], + }]; + assert_eq!(first_user_text(&messages), ""); + } } diff --git a/crates/oxide-code/src/client/billing.rs b/crates/oxide-code/src/client/billing.rs new file mode 100644 index 00000000..01798165 --- /dev/null +++ b/crates/oxide-code/src/client/billing.rs @@ -0,0 +1,172 @@ +use sha2::{Digest, Sha256}; +use xxhash_rust::xxh64; + +/// Salt for the version fingerprint (hardcoded in Claude Code JS source). +const FINGERPRINT_SALT: &str = "59cf53e54c78"; + +/// Character indices extracted from the first user message for fingerprinting. +const FINGERPRINT_INDICES: [usize; 3] = [4, 7, 20]; + +/// xxHash64 seed for the cch body hash (extracted from the Bun binary). +const CCH_SEED: u64 = 0x6E52_736A_C806_831E; + +/// Placeholder written into the billing header before the real hash is computed. +const CCH_PLACEHOLDER: &str = "cch=00000"; + +// ── Public API ── + +/// Compute the 3-character hex fingerprint suffix for `cc_version`. +/// +/// `SHA-256(salt + chars_at_indices + version)`, take first 3 hex chars. +/// Character extraction uses Unicode scalar indexing, which matches +/// JavaScript's UTF-16 code-unit indexing for all BMP characters. +pub(super) fn compute_fingerprint(first_user_message: &str, version: &str) -> String { + let chars: String = FINGERPRINT_INDICES + .iter() + .map(|&i| first_user_message.chars().nth(i).unwrap_or('0')) + .collect(); + + let input = format!("{FINGERPRINT_SALT}{chars}{version}"); + let hash = Sha256::digest(input.as_bytes()); + format!("{:02x}{:02x}", hash[0], hash[1])[..3].to_string() +} + +/// Build the billing attribution header with a `cch=00000` placeholder. +/// +/// The placeholder is later replaced by [`inject_cch`] with the computed hash. +pub(super) fn build_billing_header(version: &str, fingerprint: &str) -> String { + format!( + "x-anthropic-billing-header: \ + cc_version={version}.{fingerprint}; \ + cc_entrypoint=cli; \ + {CCH_PLACEHOLDER};" + ) +} + +/// Compute xxHash64 of the request body and replace the `cch` placeholder. +/// +/// The hash is computed over the body *with* the placeholder in place, +/// then the placeholder is replaced with the 5-char hex result. Only the +/// first occurrence is replaced — field ordering in +/// [`super::anthropic::CreateMessageRequest`] ensures `system` is serialized +/// before `messages`, so the billing header's placeholder comes first. +pub(super) fn inject_cch(body: &str) -> String { + debug_assert!( + body.contains(CCH_PLACEHOLDER), + "cch placeholder not found in request body" + ); + + let hash = xxh64::xxh64(body.as_bytes(), CCH_SEED); + let cch = format!("{:05x}", hash & 0xFFFFF); + body.replacen(CCH_PLACEHOLDER, &format!("cch={cch}"), 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── compute_fingerprint ── + + /// Verified via `echo -n "59cf53e54c78'li2.1.37" | shasum -a 256` → "9e71…". + /// Chars at indices [4, 7, 20] of "Say 'hello'…" are `'`, `l`, `i`. + #[test] + fn compute_fingerprint_known_vector() { + let fp = compute_fingerprint("Say 'hello' and nothing else.", "2.1.37"); + assert_eq!(fp, "9e7"); + } + + #[test] + fn compute_fingerprint_varies_with_version() { + let fp1 = compute_fingerprint("hello world", "2.1.37"); + let fp2 = compute_fingerprint("hello world", "2.1.87"); + assert_ne!( + fp1, fp2, + "different versions should produce different fingerprints" + ); + } + + #[test] + fn compute_fingerprint_short_message_pads_with_zero() { + // "Hi" (len 2): all fingerprint indices (4, 7, 20) are out of bounds, + // so chars default to '0'. Same result as an empty message. + let short = compute_fingerprint("Hi", "2.1.87"); + let empty = compute_fingerprint("", "2.1.87"); + assert_eq!(short, empty, "both should pad all positions with '0'"); + } + + #[test] + fn compute_fingerprint_partial_bounds() { + // "Hello" (len 5): index 4 = 'o', indices 7 and 20 out of bounds. + let fp = compute_fingerprint("Hello", "2.1.87"); + let fp_all_zero = compute_fingerprint("", "2.1.87"); + assert_eq!(fp.len(), 3); + assert!(fp.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!( + fp, fp_all_zero, + "partial in-bounds should differ from all-zero" + ); + } + + // ── build_billing_header ── + + #[test] + fn build_billing_header_format() { + let header = build_billing_header("2.1.87", "abc"); + assert_eq!( + header, + "x-anthropic-billing-header: cc_version=2.1.87.abc; cc_entrypoint=cli; cch=00000;" + ); + } + + // ── inject_cch ── + + #[test] + fn inject_cch_replaces_placeholder() { + let body = r#"{"system":[{"type":"text","text":"cch=00000;"}],"messages":[]}"#; + let result = inject_cch(body); + + assert!( + !result.contains(CCH_PLACEHOLDER), + "placeholder should be replaced" + ); + + let hash = xxh64::xxh64(body.as_bytes(), CCH_SEED); + let expected = format!("{:05x}", hash & 0xFFFFF); + assert!( + result.contains(&format!("cch={expected}")), + "result should contain cch={expected}, got: {result}" + ); + } + + #[test] + fn inject_cch_deterministic() { + let body = r#"{"system":[{"type":"text","text":"cch=00000;"}],"messages":[]}"#; + assert_eq!(inject_cch(body), inject_cch(body)); + } + + #[test] + fn inject_cch_produces_five_hex_chars() { + let body = r#"{"system":[{"type":"text","text":"cch=00000;"}]}"#; + let result = inject_cch(body); + + let cch_start = result.find("cch=").expect("cch= not found") + 4; + let cch_value = &result[cch_start..cch_start + 5]; + assert!( + cch_value.chars().all(|c| c.is_ascii_hexdigit()), + "cch should be 5 hex chars, got: {cch_value}" + ); + } + + #[test] + fn inject_cch_replaces_only_first_occurrence() { + // system (with placeholder) is before messages — matches our struct field order. + let body = r#"{"system":[{"type":"text","text":"cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"cch=00000"}]}]}"#; + let result = inject_cch(body); + + assert_eq!( + result.matches("cch=00000").count(), + 1, + "only the second occurrence (in messages) should remain" + ); + } +} diff --git a/docs/research/anthropic-api.md b/docs/research/anthropic-api.md index 9beed454..3b0949ad 100644 --- a/docs/research/anthropic-api.md +++ b/docs/research/anthropic-api.md @@ -77,25 +77,51 @@ The API validates that the **first non-attribution text block** matches one of t **Critical**: Concatenating the prefix into the prompt body as a single string causes the API to reject OAuth requests with 429, even though the same prefix content is present. The block-level separation is what the server checks. -### 3. Attribution header (optional, recommended) +### 3. Attribution header Claude Code prepends an attribution header as the very first system block: ```json -{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.87.a3f; cc_entrypoint=cli;"} +{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.87.a3f; cc_entrypoint=cli; cch=1b4e2;"} ``` -Format: `x-anthropic-billing-header: cc_version=.; cc_entrypoint=;` +Format: `x-anthropic-billing-header: cc_version=.; cc_entrypoint=; cch=;` -The fingerprint is a 3-character hex value computed per request: +#### Fingerprint (3-char version suffix) + +A 3-character hex value derived from conversation content: 1. Extract characters at indices `[4, 7, 20]` from the first user message text (use `"0"` if index is out of bounds). 2. Compute `SHA256(SALT + chars + VERSION)`, take the first 3 hex characters. -3. Salt: `59cf53e54c78` (hardcoded, must match server). +3. Salt: `59cf53e54c78` (hardcoded in `claude-code/src/utils/fingerprint.ts`, must match server). The entrypoint is `cli` for interactive sessions. -When `NATIVE_CLIENT_ATTESTATION` is enabled (compile-time feature flag in Bun), the header also includes a `cch=00000` placeholder that Bun's native HTTP stack (Zig) overwrites with a computed attestation token before sending. This is a tamper-proof mechanism that third-party tools cannot replicate. +#### cch (5-char request integrity hash) + +The `cch` field is a request integrity hash used for feature gating (fast mode) and billing attribution. It was reverse-engineered from Anthropic's custom Bun binary in February 2026 ([a10k.co writeup](https://a10k.co/b/reverse-engineering-claude-code-cch.html)). + +How it works: + +1. The JavaScript layer writes a `cch=00000` placeholder into the billing header (controlled by `feature('NATIVE_CLIENT_ATTESTATION')` compile-time flag). +2. The request body is serialized to JSON with the placeholder in place. +3. Bun's native HTTP stack (compiled Zig, `bun-anthropic/src/http/Attestation.zig`) intercepts the `fetch()` call, detects the placeholder, and computes `xxHash64(body_bytes, seed) & 0xFFFFF`. +4. The five `0` characters are overwritten in-place with the 5-char hex result before sending. + +Constants: + +- **Seed**: `0x6E52736AC806831E` (64-bit, embedded in the binary's data section). +- **Mask**: `& 0xFFFFF` (20 bits → 5 hex chars, zero-padded). + +The hash covers the entire serialized body (messages, tools, metadata, model, thinking config). The only safe post-hash modification is to non-billing system blocks, which the server excludes from its verification. + +**JSON key ordering matters**: `system` must be serialized before `messages` so the placeholder in the billing header appears first in the JSON. If tool results contain the literal `cch=00000`, serializing `messages` first would cause the replacement to hit the wrong occurrence. + +#### Known bug: cch substitution breaks prompt cache + +The Bun binary performs a global find-and-replace of `cch=` values across the entire serialized request body, including historical tool results. If any tool result in the conversation contains a `cch=XXXXX` string (e.g., from reading proxy logs or session JSONL files), the substitution rewrites those historical bytes on every turn, changing the conversation prefix and permanently invalidating the prompt cache. This wastes 30-50K+ tokens per turn and never self-heals. Tracked as [anthropics/claude-code#40652](https://github.com/anthropics/claude-code/issues/40652), partially mitigated in v2.1.90-91. + +oxide-code avoids this entirely: we serialize with `serde_json`, replace only the first occurrence via `str::replacen`, and never mutate historical message content. ### 4. Client identity headers @@ -124,7 +150,9 @@ As of April 4, 2026, Anthropic enforces that OAuth subscription credits (Pro / M - **API key** (`ANTHROPIC_API_KEY`) with standard per-token billing. - **Extra Usage** billing enabled on the account, which allows OAuth but bills per-token beyond the subscription. -The native client attestation (`cch` in the attribution header) is the primary technical enforcement mechanism. Third-party tools cannot compute the attestation token since it requires Anthropic's custom Bun binary. Without valid attestation, subscription-tier rate limits are not applied. +The `cch` hash is the primary technical enforcement mechanism. The algorithm (xxHash64, non-cryptographic) and constants are publicly known. No additional protections exist: no TLS fingerprinting, binary attestation, pre-registration handshake, replay detection, or connection association. Anthropic could escalate enforcement at any time — the current scheme is billing plumbing, not a security boundary. + +oxide-code computes valid `cch` hashes for OAuth requests. The fingerprint salt and xxHash64 seed are version-specific constants; they may change with Claude Code releases. ## API Version