fix(tlsfingerprint): realign TLS+headers to Claude Code 2.1.114 baseline

Merge hai/snapshot fingerprint update into release branch, preserving
our architectural optimizations (SOCKS5 ContextDialer, identity_service
refactoring, slog migration, capture_fingerprint multi-file structure).

TLS defaults: 52→17 ciphers, 5→3 curves (drop MLKEM768/P521),
2→1 key share (X25519 only), 3→1 point format, 26→9 sig algs.
New probabilistic ECH GREASE + padding (~50% per handshake).
Extension order realigned: server_name first, encrypt_then_mac removed,
status_request/SCT added.

Headers: UA 2.1.114, Runtime v24.3.0 (bundled Node), Timeout 600.
New BetaStructuredOutputs20251215 for Haiku title-sidecar requests.
This commit is contained in:
erio
2026-04-19 22:21:51 +08:00
parent e20a57f53e
commit 26e7d969e8
12 changed files with 447 additions and 247 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.114.13
0.1.114.14
+34 -24
View File
@@ -18,6 +18,10 @@ const (
BetaContextManagement20250627 = "context-management-2025-06-27"
BetaPromptCachingScope20260105 = "prompt-caching-scope-2026-01-05"
BetaAdvisorTool20260301 = "advisor-tool-2026-03-01"
// Added 2026-04-19 from 2.1.114 haiku title-sidecar capture — haiku
// background requests (title generation) carry this but the main sonnet
// request does not.
BetaStructuredOutputs20251215 = "structured-outputs-2025-12-15"
)
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
@@ -52,13 +56,17 @@ const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + Beta
// CountTokensBetaHeader count_tokens 请求使用的 anthropic-beta header
const CountTokensBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaTokenCounting + clientExtraBetas
// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta)
const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking
// HaikuBetaHeader Haiku 模型(OAuth)使用的 anthropic-beta header.
//
// Captured 2026-04-19 from Claude Code 2.1.114 title-generation sidecar
// request (api-key mode). Matches the variant exactly except for the oauth
// token, which is prepended here for OAuth-credential accounts.
const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking + clientExtraBetas + "," + BetaStructuredOutputs20251215
// APIKeyBetaHeader API-key 账号使用的 anthropic-beta header.
//
// Exactly matches the real Claude Code 2.1.111 capture against x-api-key auth
// (2.1.112 is a patch bump with no observed beta-token change):
// Exactly matches Claude Code 2.1.114 main /v1/messages request against
// x-api-key auth (captured 2026-04-19):
//
// claude-code-20250219,
// interleaved-thinking-2025-05-14,
@@ -70,30 +78,32 @@ const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking
// (beta is auth-type conditional; non-OAuth requests never carry it).
const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + clientExtraBetas
// APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code)
const APIKeyHaikuBetaHeader = BetaInterleavedThinking
// APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下的 anthropic-beta header.
//
// Exact match to Claude Code 2.1.114 title-generation sidecar request captured
// 2026-04-19: interleaved-thinking, context-management, prompt-caching-scope,
// advisor-tool, structured-outputs. No claude-code and no oauth.
const APIKeyHaikuBetaHeader = BetaInterleavedThinking + clientExtraBetas + "," + BetaStructuredOutputs20251215
// DefaultHeaders 是 Claude Code 客户端默认请求头。
//
// Values re-verified 2026-04-17 from a live capture of Claude Code 2.1.111 on
// Node.js 24.14.1 / macOS arm64 (backend/tools/capture_fingerprint). UA bumped
// to 2.1.112 (latest on npm as of 2026-04-17) — patch-level Claude Code releases
// do not change Stainless package/runtime fields or the timeout. Bundled
// @anthropic-ai/sdk is still 0.81.0 — unchanged since the 2.1.109 capture.
// Keep these in sync with recent Claude CLI traffic to reduce the chance that
// Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
// Re-verified 2026-04-19 from a live capture of Claude Code 2.1.114 on
// macOS arm64 (backend/tools/capture_fingerprint). Critical: CC 2.1.114
// bundles its own Node 24.3.0 runtime; the host Node version is NOT what
// gets advertised in the X-Stainless-Runtime-Version header. Bundled
// @anthropic-ai/sdk is 0.81.0 — unchanged since the 2.1.109 capture.
// Keep these in sync with recent Claude CLI traffic to reduce the chance
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
var DefaultHeaders = map[string]string{
"User-Agent": "claude-cli/2.1.112 (external, sdk-cli)",
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.81.0",
"X-Stainless-OS": "MacOS",
"X-Stainless-Arch": "arm64",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v24.14.1",
// 2.1.111 capture shows timeout=300; 2.1.109 was 600. Matches the
// ~5-minute default @anthropic-ai/sdk recently moved to.
"X-Stainless-Timeout": "300",
"X-App": "cli",
"User-Agent": "claude-cli/2.1.114 (external, sdk-cli)",
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.81.0",
"X-Stainless-OS": "MacOS",
"X-Stainless-Arch": "arm64",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v24.3.0",
"X-Stainless-Timeout": "600",
"X-App": "cli",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
}
+122 -148
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"fmt"
"log/slog"
mrand "math/rand/v2"
"net"
"net/http"
"net/url"
@@ -52,179 +53,114 @@ type SOCKS5ProxyDialer struct {
proxyURL *url.URL
}
// Default TLS fingerprint values captured from Claude Code 2.1.109 on
// Node.js 24.14.1 / macOS arm64, pointed at a local capture server via
// `ANTHROPIC_BASE_URL`. Capture tool source at backend/tools/capture_fingerprint.
// Default TLS fingerprint values captured from Claude Code 2.1.114 on
// macOS arm64 (bundled Node 24.3.0), pointed at a local capture server via
// `ANTHROPIC_BASE_URL`. Capture tool: backend/tools/capture_fingerprint.
// Baseline JSON: backend/tools/capture_fingerprint/baselines/claude-code-2.1.114.json.
//
// Capture date: 2026-04-15
// JA3 string:
// Capture date: 2026-04-19 (30 CC requests across 5 invocation modes)
//
// 771,4866-4867-4865-49199-49195-49200-49196-158-49191-103-49192-107-163-159-
// 52393-52392-52394-49325-49311-49245-49249-49239-49235-162-49324-49310-49244-
// 49248-49238-49234-49188-106-49187-64-49162-49172-57-56-49161-49171-51-50-
// 157-49309-49233-156-49308-49232-61-60-53-47,
// 65281-0-11-10-35-16-22-23-13-43-45-51,
// 29-23-30-24-25-256-257,0-1-2
// JA3 string (without ECH GREASE, majority variant):
//
// JA3 hash: d67b094811e5145139d7cea5f014309f
// JA4: t13d5212h1 (part-a prefix — 52 ciphers, 12 extensions, http/1.1 ALPN)
// 771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49161-49171-49162-
// 49172-156-157-47-53,
// 0-23-65281-10-11-35-16-5-13-18-51-45-43,
// 29-23-24,0-1-2
//
// Critical findings from the capture that influenced these defaults:
// - Real Claude Code 2.1.109 advertises ONLY `http/1.1` in ALPN — it does
// NOT offer `h2`. Earlier concerns about Go-vs-Node HTTP/2 SETTINGS
// mismatch were moot because the real CLI does not use HTTP/2 on the
// api.anthropic.com path at all.
// - 52 cipher suites (not 17 — the previous hand-authored list was a
// substantial undercount).
// - 26 signature schemes, 8 supported groups (incl. 2 FFDHE + X448 + P521).
// - Extension order is: renegotiation_info, server_name, ec_point_formats,
// supported_groups, session_ticket, alpn, encrypt_then_mac(22),
// extended_master_secret, signature_algorithms, supported_versions,
// psk_key_exchange_modes, key_share. No ECH, no SCT, no status_request.
// JA3 hash: dc782a9d905fdcee1223a3d4e8108bc6 (no ECH GREASE — ~67% of conns)
// JA3 hash: d871d02cecbde59abbf8f4806134addf (with ECH GREASE — ~33% of conns)
// JA4: t13d1713h1 / t13d1714h1 (17 ciphers, 13 or 14 extensions, http/1.1 ALPN)
//
// CRITICAL — this is the REVERSE of what the 2026-04-15 rewrite assumed.
// Claude Code 2.1.112→2.1.114 simplified its TLS stack dramatically:
// - Cipher list 52 → 17. Node dropped its "OpenSSL enable-all" suite
// (ARIA/CCM/Brainpool/DHE legacy) in favor of a modern minimal set.
// - Curves 8 → 3. MLKEM768 (post-quantum hybrid) is GONE. X25519 is the
// single key share.
// - Signature schemes 26 → 9. Brainpool TLS 1.3 and Ed25519/Ed448 gone.
// - Extension count 12 → 13 (or 14 with ECH GREASE). encrypt_then_mac(22)
// dropped. status_request(5), SCT(18) added. ECH GREASE appears in
// ~33% of connections — probabilistic per-handshake.
// - Extension order completely different — server_name is NOW first, not
// renegotiation_info.
//
// If these defaults look "thin," that's the point: matching the real shape
// means NOT overselling TLS capabilities.
var (
// defaultCipherSuites — 52 cipher suites in the order Node.js 24.14.1
// OpenSSL sends them. Do NOT reorder: JA3 hash depends on order.
// defaultCipherSuites — 17 cipher suites in the exact order Claude Code
// 2.1.114 bundled Node 24.3.0 sends them. Do NOT reorder: JA3 hash
// depends on order.
defaultCipherSuites = []uint16{
// TLS 1.3 (note: 1302 comes before 1303/1301)
// TLS 1.3 (note: 1301 FIRST, unlike the old OpenSSL order)
0x1301, // TLS_AES_128_GCM_SHA256
0x1302, // TLS_AES_256_GCM_SHA384
0x1303, // TLS_CHACHA20_POLY1305_SHA256
0x1301, // TLS_AES_128_GCM_SHA256
// ECDHE + AES-GCM (RSA before ECDSA)
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
// ECDHE + AES-GCM (ECDSA before RSA, AES-128 before AES-256)
0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
// DHE_RSA + AES-GCM
0x009e, // TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
// ECDHE + AES-CBC-SHA256 (SHA256 HMAC variants)
0xc027, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
0x0067, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA256
0xc028, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384
0x006b, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA256
0x00a3, // TLS_DHE_DSS_WITH_AES_256_GCM_SHA384
0x009f, // TLS_DHE_RSA_WITH_AES_256_GCM_SHA384
// ChaCha20-Poly1305 family (3 variants: ECDSA, RSA, DHE)
// ChaCha20-Poly1305 (ECDSA before RSA)
0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
0xccaa, // TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256
// ECCPWD, camellia, ARIA (legacy PSK/ARIA from OpenSSL enable-all)
0xc0ad, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM
0xc09f, // TLS_DHE_RSA_WITH_AES_256_CCM
0xc05d, // TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384
0xc061, // TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384
0xc057, // TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384
0xc053, // TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384
0x00a2, // TLS_DHE_DSS_WITH_AES_128_GCM_SHA256
0xc0ac, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM
0xc09e, // TLS_DHE_RSA_WITH_AES_128_CCM
0xc05c, // TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256
0xc060, // TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256
0xc056, // TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256
0xc052, // TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256
// ECDHE/DHE + CBC-SHA256/384 (legacy)
0xc024, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
0x006a, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA256
0xc023, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
0x0040, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA256
// ECDHE + AES-CBC-SHA (legacy — SHA1 HMAC)
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
0x0039, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA
0x0038, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA
// ECDHE + AES-CBC-SHA (legacy — SHA1 HMAC, kept for compat)
0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
0x0033, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA
0x0032, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
// RSA key exchange + AES-GCM (non-PFS)
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
0xc09d, // TLS_RSA_WITH_AES_256_CCM
0xc051, // TLS_RSA_WITH_ARIA_256_GCM_SHA384
0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256
0xc09c, // TLS_RSA_WITH_AES_128_CCM
0xc050, // TLS_RSA_WITH_ARIA_128_GCM_SHA256
// RSA + AES-CBC-SHA256 (legacy)
0x003d, // TLS_RSA_WITH_AES_256_CBC_SHA256
0x003c, // TLS_RSA_WITH_AES_128_CBC_SHA256
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
// RSA + AES-CBC-SHA (very legacy)
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
}
// defaultCurves — supported groups we advertise.
// defaultCurves — 3 supported groups Claude Code 2.1.114 advertises.
//
// Real Claude Code / Node.js 24 advertises 8 groups (incl. x448,
// ffdhe2048, ffdhe3072) but utls's HelloRetryRequest path can only
// regenerate key shares for curves in curveForCurveID (X25519, P256,
// P384, P521). If we advertise a group utls can't handle and the
// server picks it via HRR, handshake fails with
// "tls: CurvePreferences includes unsupported curve".
//
// X25519MLKEM768 is kept because defaultKeyShareGroups always sends its
// key share on the initial ClientHello, so servers never need to HRR
// back to it. The randomizer must preserve this invariant.
// The 2.1.109 era had 8 groups including post-quantum X25519MLKEM768.
// 2.1.114 reverts to a minimal set of classical ECDHE curves only —
// no PQ hybrid, no FFDHE, no P521, no x448. This matches what modern
// undici (Node 24.x bundled fetch API) offers.
defaultCurves = []utls.CurveID{
utls.X25519MLKEM768, // 0x11ec — post-quantum hybrid
utls.X25519, // 0x001d
utls.CurveP256, // 0x0017 (secp256r1)
utls.CurveP384, // 0x0018 (secp384r1)
utls.CurveP521, // 0x0019 (secp521r1)
utls.X25519, // 0x001d
utls.CurveP256, // 0x0017 (secp256r1)
utls.CurveP384, // 0x0018 (secp384r1)
}
// defaultKeyShareGroups — X25519MLKEM768 + X25519, matching the real
// Claude Code capture (the real CLI sends two key shares: a ~1216-byte
// MLKEM payload and a 32-byte X25519 public key).
// defaultKeyShareGroups — single X25519 key share. MLKEM768 is no
// longer advertised as of 2.1.114 (see defaultCurves note).
defaultKeyShareGroups = []utls.CurveID{
utls.X25519MLKEM768,
utls.X25519,
}
// defaultPointFormats — 3 formats (uncompressed, ansiX962, compressed).
// defaultPointFormats — Claude Code 2.1.114 advertises only
// uncompressed. The 2.1.109 baseline listed all 3 classical formats,
// but the bundled Node 24.3.0 TLS stack now sends just one.
defaultPointFormats = []uint16{
0, // uncompressed
1, // ansiX962_compressed_prime
2, // ansiX962_compressed_char2
}
// defaultSignatureAlgorithms — 26 schemes Node.js 24.14.1 advertises,
// in capture order. Includes Brainpool TLS 1.3 curves and several
// legacy SHA1 entries.
// defaultSignatureAlgorithms — 9 schemes Claude Code 2.1.114 advertises,
// in capture order. Dramatically reduced from 2.1.109's 26-scheme list:
// no Brainpool TLS 1.3, no Ed25519/Ed448, no experimental 0x0904/5/6,
// no legacy DSA/SHA224 variants. Just modern RSA-PSS + ECDSA + legacy
// RSA-PKCS1-SHA1 floor.
defaultSignatureAlgorithms = []utls.SignatureScheme{
0x0905, // experimental TLS 1.3 (Node.js OpenSSL)
0x0906, // experimental TLS 1.3
0x0904, // experimental TLS 1.3
0x0403, // ecdsa_secp256r1_sha256
0x0503, // ecdsa_secp384r1_sha384
0x0603, // ecdsa_secp521r1_sha512
0x0807, // ed25519
0x0808, // ed448
0x081a, // ecdsa_brainpoolP256r1tls13_sha256
0x081b, // ecdsa_brainpoolP384r1tls13_sha384
0x081c, // ecdsa_brainpoolP512r1tls13_sha512
0x0809, // rsa_pss_pss_sha256
0x080a, // rsa_pss_pss_sha384
0x080b, // rsa_pss_pss_sha512
0x0804, // rsa_pss_rsae_sha256
0x0805, // rsa_pss_rsae_sha384
0x0806, // rsa_pss_rsae_sha512
0x0401, // rsa_pkcs1_sha256
0x0503, // ecdsa_secp384r1_sha384
0x0805, // rsa_pss_rsae_sha384
0x0501, // rsa_pkcs1_sha384
0x0806, // rsa_pss_rsae_sha512
0x0601, // rsa_pkcs1_sha512
0x0303, // SHA224-ECDSA (legacy)
0x0301, // SHA224-RSA (legacy)
0x0302, // SHA224-DSA (legacy)
0x0402, // SHA256-DSA (legacy)
0x0502, // SHA384-DSA (legacy)
0x0602, // SHA512-DSA (legacy)
0x0201, // rsa_pkcs1_sha1 (legacy floor)
}
)
@@ -427,32 +363,67 @@ func toUTLSCurves(curves []uint16) []utls.CurveID {
return result
}
// defaultExtensionOrder — 12 extensions in the exact order Node.js 24.14.1
// sends them (captured from live Claude Code 2.1.109).
// defaultExtensionOrder — 13 base extensions in the exact order Claude Code
// 2.1.114 (bundled Node 24.3.0) sends them when ECH/padding are OFF. Real
// CC alternates between two variants (observed ratio ~55:45):
//
// Differences from the previous hand-authored list:
// - Dropped: encrypted_client_hello (65037 / ECH) — real Node.js 24 does
// NOT send ECH unless a server config is known.
// - Dropped: status_request (5) and signed_certificate_timestamp (18) —
// Node.js 24's default TLS extension set doesn't include them.
// - Added: encrypt_then_mac (22) — RFC 7366, emitted by OpenSSL.
// - Reordered: renegotiation_info is now FIRST; extended_master_secret
// moved after encrypt_then_mac; ec_point_formats precedes supported_groups.
// "Rich" variant (~55%): +ECH at position 1 AND +padding (21) at end.
// 15 raw extensions, JA3 d871d02cecbde59abbf8f4806134addf,
// JA4 t13d1714h1.
// "Plain" variant (~45%): no ECH, no padding. 13 raw extensions,
// JA3 dc782a9d905fdcee1223a3d4e8108bc6, JA4 t13d1713h1.
//
// ECH and padding appear together, not independently — probably because
// both are emitted by the same OpenSSL "extended hello" code path in Node.
//
// Versus the 2026-04-15 list:
// - server_name is FIRST (was #2 after renegotiation_info)
// - extended_master_secret moved UP before renegotiation_info
// - supported_groups now BEFORE ec_point_formats (was reversed)
// - encrypt_then_mac (22) REMOVED
// - status_request (5) and signed_certificate_timestamp (18) ADDED back
// - key_share → psk_key_exchange_modes → supported_versions trailing triple
//
// Used when Profile.Extensions is empty.
var defaultExtensionOrder = []uint16{
65281, // renegotiation_info (0xff01)
0, // server_name
11, // ec_point_formats
23, // extended_master_secret
65281, // renegotiation_info (0xff01)
10, // supported_groups
11, // ec_point_formats
35, // session_ticket
16, // alpn
22, // encrypt_then_mac (RFC 7366) — sent by OpenSSL/Node.js
23, // extended_master_secret
5, // status_request (OCSP)
13, // signature_algorithms
43, // supported_versions
45, // psk_key_exchange_modes
18, // signed_certificate_timestamp
51, // key_share
45, // psk_key_exchange_modes
43, // supported_versions
}
// echAndPaddingProbability is the empirically measured frequency of the
// "rich" ClientHello variant (GREASE ECH + padding) in Claude Code 2.1.114
// captures. Observed ratio: 22 of 40 first-round captures plus 14 of 30
// follow-up captures = 36/70 ≈ 0.51. Per-connection independent roll.
const echAndPaddingProbability = 0.50
// maybeEnrichExtensions returns extOrder unchanged (plain variant, ~50%) or
// a copy with 0xfe0d inserted at index 1 AND 21 (padding) appended at end
// (rich variant, ~50%). Called per-handshake so each connection rolls
// independently — matches the per-connection randomness observed in real CC.
func maybeEnrichExtensions(extOrder []uint16) []uint16 {
if mrand.Float64() >= echAndPaddingProbability {
return extOrder
}
if len(extOrder) < 1 || extOrder[0] != 0 {
return extOrder
}
out := make([]uint16, 0, len(extOrder)+2)
out = append(out, extOrder[0]) // server_name
out = append(out, 0xfe0d) // encrypted_client_hello (GREASE)
out = append(out, extOrder[1:]...) // 23, 65281, ..., 43
out = append(out, 21) // padding (RFC 7685)
return out
}
// isGREASEValue checks if a uint16 value matches the TLS GREASE pattern (0x?a?a).
@@ -522,9 +493,11 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
}
// Determine extension order
extOrder := defaultExtensionOrder
var extOrder []uint16
if profile != nil && len(profile.Extensions) > 0 {
extOrder = profile.Extensions
} else {
extOrder = maybeEnrichExtensions(defaultExtensionOrder)
}
// Build extensions list from the ordered IDs.
@@ -567,11 +540,12 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
// Send GREASE ECH with random payload — mimics Node.js behavior when no real ECHConfig is available.
// An empty GenericExtension causes "error decoding message" from servers that validate ECH format.
extensions = append(extensions, &utls.GREASEEncryptedClientHelloExtension{})
case 21: // padding (RFC 7685) — BoringPaddingStyle pads CH to ≥512 bytes
extensions = append(extensions, &utls.UtlsPaddingExtension{GetPaddingLen: utls.BoringPaddingStyle})
case 0xff01: // renegotiation_info
extensions = append(extensions, &utls.RenegotiationInfoExtension{})
default:
// Unknown extension — send as GenericExtension (type ID + empty data).
// This covers encrypt_then_mac(22) and any future extensions.
extensions = append(extensions, &utls.GenericExtension{Id: id})
}
}
@@ -137,7 +137,7 @@ func TestDialerAgainstCaptureServer(t *testing.T) {
}
effectiveKeyShare := tc.profile.KeyShareGroups
if len(effectiveKeyShare) == 0 {
effectiveKeyShare = []uint16{4588, 29} // X25519MLKEM768 + X25519 (Claude Code CLI default)
effectiveKeyShare = []uint16{29} // X25519 only (Claude Code 2.1.114 default)
}
effectivePSKModes := tc.profile.PSKModes
if len(effectivePSKModes) == 0 {
@@ -361,8 +361,8 @@ func TestBuildClientHelloSpecNewFields(t *testing.T) {
t.Errorf("default versions: got %v, want 2 entries", e.Versions)
}
case *utls.KeyShareExtension:
if len(e.KeyShares) != 2 {
t.Errorf("default key shares: got %d, want 2", len(e.KeyShares))
if len(e.KeyShares) != 1 {
t.Errorf("default key shares: got %d, want 1", len(e.KeyShares))
}
}
}
@@ -40,8 +40,8 @@ func skipIfExternalServiceUnavailable(t *testing.T, err error) {
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
// This test uses tls.peet.ws to verify the fingerprint.
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
// Expected JA3 hash: 048900f5ae64cc2a49a44389a5406191 (Claude Code 2.1.114 baseline)
// Expected JA4 cipher hash: b262b3658495
func TestJA3Fingerprint(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
@@ -163,10 +163,12 @@ func skipNetworkTest(t *testing.T) {
// TestDialerWithProfile tests that different profiles produce different fingerprints.
func TestDialerWithProfile(t *testing.T) {
// Create two dialers with different profiles
// Use explicit extensions on profile1 to avoid the probabilistic
// maybeEnrichExtensions path — ensures a deterministic extension count.
profile1 := &Profile{
Name: "Profile 1 - No GREASE",
EnableGREASE: false,
Extensions: []uint16{0, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43},
}
profile2 := &Profile{
Name: "Profile 2 - With GREASE",
@@ -176,15 +178,12 @@ func TestDialerWithProfile(t *testing.T) {
dialer1 := NewDialer(profile1, nil)
dialer2 := NewDialer(profile2, nil)
// Build specs and compare
// Note: We can't directly compare JA3 without making network requests
// but we can verify the specs are different
spec1 := buildClientHelloSpecFromProfile(dialer1.profile)
spec2 := buildClientHelloSpecFromProfile(dialer2.profile)
// Profile with GREASE should have more extensions
// Profile with GREASE should have 2 extra bookend extensions
if len(spec2.Extensions) <= len(spec1.Extensions) {
t.Error("expected GREASE profile to have more extensions")
t.Errorf("expected GREASE profile to have more extensions: got %d vs %d", len(spec2.Extensions), len(spec1.Extensions))
}
}
@@ -5,34 +5,36 @@ import (
)
// GenerateRandomizedProfile returns a new Profile that perturbs the
// Claude Code 2.1.112 baseline along several account-safe axes so every
// Claude Code 2.1.114 baseline along several account-safe axes so every
// account can carry a unique JA3 hash while still looking like "some
// Node.js-family TLS client."
//
// Intentionally conservative: the baseline cipher / signature-algorithm
// shape is preserved; only localized swaps happen within bands so the
// overall PFS-preferred ordering stays intact. Anthropic classifying on
// "JA3 equals exact Claude Code fingerprint" would lose this account,
// but classifying on "entire pool hashes identically" (the actual
// clustering risk) stops working.
// overall "TLS 1.3 first, ECDHE before RSA, AES-GCM before CBC" ordering
// stays intact. Anthropic classifying on "JA3 equals exact Claude Code
// fingerprint" would lose this account, but classifying on "entire pool
// hashes identically" (the actual clustering risk) stops working.
//
// Axes randomized:
// 1. Cipher suites — 6–10 localized swaps within the PFS band [3:29]
// and the RSA band [41:] (indices into the baseline order).
// 2. Signature algorithms — localized swaps within RSA-PSS-PSS,
// RSA-PSS-RSAE, and legacy-DSA groups.
// 3. GREASE — 30% on, 70% off (matches the rare but plausible case of
// a Chrome-behavior wrapper around the Node stack).
// Axes randomized (for the 17-cipher / 9-sigalg 2.1.114 baseline):
// 1. Cipher suites — 2–4 localized swaps within the ECDHE PFS band [3:13]
// and 0–1 swaps within the RSA band [13:]. TLS 1.3 ciphers [0:3] stay
// in baseline order — reordering those is the strongest mimic tell.
// 2. Signature algorithms — localized swaps within the SHA-384 pair and
// the SHA-512 pair. The leading ECDSA-SHA256/RSA-PSS-rsae-SHA256/
// RSA-PKCS1-SHA256 triple stays pinned (it's a stable Node.js
// signature and reordering it is detectable).
// 3. GREASE — 30% on, 70% off. Separate from ECH GREASE (that's rolled
// per-handshake in the dialer).
// 4. ALPN — always http/1.1 only. Do NOT advertise h2: Go's http.Transport
// with a custom DialTLSContext cannot speak HTTP/2. If the server
// negotiates h2 via ALPN, the transport writes HTTP/1.1 over the
// connection and reads back HTTP/2 SETTINGS/WINDOW_UPDATE/GOAWAY
// frames, surfacing as "malformed HTTP response" errors. Real Claude
// Code only sends http/1.1 in ALPN anyway (see dialer.go:73).
// 5. Key share groups — always MLKEM768+X25519. Cannot drop MLKEM768
// here: supported_groups still advertises it, and if the server HRRs
// back to MLKEM768, utls's HRR path can't regenerate its dual-key
// share and fails with "CurvePreferences includes unsupported curve".
// Code 2.1.114 only sends http/1.1 in ALPN anyway (see dialer.go).
// 5. Key share groups — always X25519 only. MLKEM768 was dropped in the
// 2.1.114 baseline; adding it here would leave "JA3 says no PQ hybrid
// but key_share sends one" as a detection signal.
//
// Returns a freshly-allocated Profile so callers may mutate safely.
// Uses math/rand/v2 package-global source (seeded by runtime).
@@ -42,42 +44,43 @@ func GenerateRandomizedProfile() *Profile {
// --- cipher suites ---
ciphers := make([]uint16, len(defaultCipherSuites))
copy(ciphers, defaultCipherSuites)
// Localized swaps in the PFS (post-TLS1.3) band. Index bounds derived
// from the baseline grouping in dialer.go; keeps RSA ciphers at the
// bottom so the real-world "prefer forward-secrecy" shape holds.
pfsLo, pfsHi := 3, 29
// ECDHE PFS band — indices 3..12 in the 2.1.114 baseline: ECDHE+AES-GCM
// (3..6), ChaCha20 pair (7..8), ECDHE+AES-CBC-SHA (9..12). Localized
// swaps here produce many JA3 variants without leaving the band.
pfsLo, pfsHi := 3, 13
if pfsHi > len(ciphers) {
pfsHi = len(ciphers)
}
swapCountPFS := 6 + rand.IntN(5) // 6..10
for range swapCountPFS {
a := pfsLo + rand.IntN(pfsHi-pfsLo)
b := pfsLo + rand.IntN(pfsHi-pfsLo)
ciphers[a], ciphers[b] = ciphers[b], ciphers[a]
}
// RSA band — smaller, fewer swaps so we don't completely scramble it.
rsaLo := 41
if rsaLo < len(ciphers) {
swapCountRSA := 2 + rand.IntN(3) // 2..4
for range swapCountRSA {
a := rsaLo + rand.IntN(len(ciphers)-rsaLo)
b := rsaLo + rand.IntN(len(ciphers)-rsaLo)
if pfsHi-pfsLo >= 2 {
swapCountPFS := 2 + rand.IntN(3) // 2..4
for range swapCountPFS {
a := pfsLo + rand.IntN(pfsHi-pfsLo)
b := pfsLo + rand.IntN(pfsHi-pfsLo)
ciphers[a], ciphers[b] = ciphers[b], ciphers[a]
}
}
// RSA band — indices 13..16 (4 ciphers: RSA+AES-GCM-128/256, RSA+CBC-128/256).
// One swap max — the band is small enough that more scrambling would be
// obviously non-baseline.
rsaLo := 13
if rsaLo+2 <= len(ciphers) && rand.IntN(2) == 0 {
a := rsaLo + rand.IntN(len(ciphers)-rsaLo)
b := rsaLo + rand.IntN(len(ciphers)-rsaLo)
ciphers[a], ciphers[b] = ciphers[b], ciphers[a]
}
p.CipherSuites = ciphers
// --- signature algorithms ---
// defaultSignatureAlgorithms uses utls.SignatureScheme, convert to
// uint16 for storage while we shuffle.
// uint16 for storage while we shuffle. Baseline order:
// 0:ecdsa_sha256, 1:rsa_pss_rsae_sha256, 2:rsa_pkcs1_sha256,
// 3:ecdsa_sha384, 4:rsa_pss_rsae_sha384, 5:rsa_pkcs1_sha384,
// 6:rsa_pss_rsae_sha512, 7:rsa_pkcs1_sha512,
// 8:rsa_pkcs1_sha1
sigs := make([]uint16, len(defaultSignatureAlgorithms))
for i, s := range defaultSignatureAlgorithms {
sigs[i] = uint16(s)
}
// Shuffle three groups that contain multiple peers. Indices into the
// baseline ordering (see dialer.go:198). Keeping experimental TLS 1.3
// (0..2) and ECDSA (3..5) unchanged — those are the strongest leading
// signals real Node clients send in order.
shuffleWithin := func(lo, hi int) {
if hi > len(sigs) || hi-lo < 2 {
return
@@ -85,9 +88,10 @@ func GenerateRandomizedProfile() *Profile {
sub := sigs[lo:hi]
rand.Shuffle(len(sub), func(i, j int) { sub[i], sub[j] = sub[j], sub[i] })
}
shuffleWithin(11, 14) // RSA-PSS-PSS (0x0809..0x080b)
shuffleWithin(14, 17) // RSA-PSS-RSAE (0x0804..0x0806)
shuffleWithin(20, 26) // legacy SHA224/256/384/512 DSA/etc.
// Shuffle the SHA384 and SHA512 pairs locally. The SHA256 triple and
// legacy SHA1 floor (0..2, 8) stay pinned — those are strong mimic tells.
shuffleWithin(4, 6) // rsa_pss_rsae_sha384 ↔ rsa_pkcs1_sha384
shuffleWithin(6, 8) // rsa_pss_rsae_sha512 ↔ rsa_pkcs1_sha512
p.SignatureAlgorithms = sigs
// --- GREASE ---
@@ -99,14 +103,12 @@ func GenerateRandomizedProfile() *Profile {
p.ALPNProtocols = []string{"http/1.1"}
// --- Key share groups ---
// Always MLKEM768+X25519 — see axis 5 in the doc comment for why
// dropping MLKEM768 here is unsafe with the current utls HRR path.
p.KeyShareGroups = []uint16{uint16(0x11ec), uint16(29)} // MLKEM768, X25519
// Always X25519 only — matches 2.1.114 baseline.
p.KeyShareGroups = []uint16{uint16(29)} // X25519
// Leave curves/point_formats/supported_versions/psk_modes/extensions
// empty so the dialer falls back to baseline values. Perturbing those
// axes further without live captures to validate would risk leaving
// the plausible-Node-client envelope.
// empty so the dialer falls back to baseline values (incl. the
// probabilistic ECH GREASE roll in defaultExtensionOrder).
return p
}
+10 -9
View File
@@ -54,24 +54,25 @@ func SessionHashFromContext(ctx context.Context) string {
// 默认指纹值(当客户端未提供时使用)
//
// Re-verified 2026-04-17 against a live capture of Claude Code 2.1.111 on
// Node.js 24.14.1 / macOS arm64 (capture tool: backend/tools/capture_fingerprint).
// UA bumped to 2.1.112 (latest on npm as of 2026-04-17) — patch-level Claude
// Code releases keep the same Stainless fields. Bundled @anthropic-ai/sdk is
// still 0.81.0. These values match the real CLI's request headers exactly —
// Re-verified 2026-04-19 against a live capture of Claude Code 2.1.114 on
// macOS arm64. Baseline at backend/tools/capture_fingerprint/baselines/
// claude-code-2.1.114.json. Critical: CC 2.1.114 bundles its own Node 24.3.0
// runtime; the host Node version is NOT what gets advertised in the
// X-Stainless-Runtime-Version header. Bundled @anthropic-ai/sdk is 0.81.0.
// These values match the real CLI's request headers exactly —
// particularly:
// - UserAgent: "claude-cli/2.1.112 (external, sdk-cli)" (note: "sdk-cli", NOT "cli")
// - UserAgent: "claude-cli/2.1.114 (external, sdk-cli)" (note: "sdk-cli", NOT "cli")
// - StainlessPackageVersion: "0.81.0"
// - StainlessOS: "MacOS" (case: mixed, not "Linux")
// - StainlessRuntimeVersion: "v24.14.1"
// - StainlessRuntimeVersion: "v24.3.0" (bundled, not host)
var defaultFingerprint = Fingerprint{
UserAgent: "claude-cli/2.1.112 (external, sdk-cli)",
UserAgent: "claude-cli/2.1.114 (external, sdk-cli)",
StainlessLang: "js",
StainlessPackageVersion: "0.81.0",
StainlessOS: "MacOS",
StainlessArch: "arm64",
StainlessRuntime: "node",
StainlessRuntimeVersion: "v24.14.1",
StainlessRuntimeVersion: "v24.3.0",
}
// Fingerprint represents account fingerprint data
@@ -20,7 +20,7 @@ func TestRewriteUserID_EquivalenceAfterRefactor(t *testing.T) {
const (
accountUUID = "11111111-2222-3333-4444-555555555555"
clientID = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
fingerprintUA = "claude-cli/2.1.112 (external, sdk-cli)"
fingerprintUA = "claude-cli/2.1.114 (external, sdk-cli)"
legacyValidUID = "user_abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789_account_99999999-8888-7777-6666-555555555555_session_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
)
@@ -119,7 +119,7 @@ func TestRewriteUserIDWithMasking_StickyPath_EarlyReturns(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, err := svc.RewriteUserIDWithMasking(ctx, tc.body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)")
result, err := svc.RewriteUserIDWithMasking(ctx, tc.body, account, "acc-uuid", "client-id", "claude-cli/2.1.114 (external, sdk-cli)")
require.NoError(t, err)
require.Equal(t, string(tc.body), string(result))
})
@@ -143,17 +143,17 @@ func TestRewriteUserIDWithMasking_StickyPath_CacheHitReusesUUID(t *testing.T) {
const legacyValidUID = "user_abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789_account_99999999-8888-7777-6666-555555555555_session_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
body := []byte(`{"metadata":{"user_id":"` + legacyValidUID + `"}}`)
r1, err := svc.RewriteUserIDWithMasking(ctx, body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)")
r1, err := svc.RewriteUserIDWithMasking(ctx, body, account, "acc-uuid", "client-id", "claude-cli/2.1.114 (external, sdk-cli)")
require.NoError(t, err)
r2, err := svc.RewriteUserIDWithMasking(ctx, body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)")
r2, err := svc.RewriteUserIDWithMasking(ctx, body, account, "acc-uuid", "client-id", "claude-cli/2.1.114 (external, sdk-cli)")
require.NoError(t, err)
require.Equal(t, string(r1), string(r2), "sticky cache hit must yield byte-identical rewrite")
// Sanity: a different sessionHash must produce different output.
ctxOther := WithSessionHash(context.Background(), "different-session-h")
rOther, err := svc.RewriteUserIDWithMasking(ctxOther, body, account, "acc-uuid", "client-id", "claude-cli/2.1.112 (external, sdk-cli)")
rOther, err := svc.RewriteUserIDWithMasking(ctxOther, body, account, "acc-uuid", "client-id", "claude-cli/2.1.114 (external, sdk-cli)")
require.NoError(t, err)
require.NotEqual(t, string(r1), string(rOther), "different sessionHash must yield different sticky session id")
}
@@ -280,7 +280,7 @@ func (s *TLSFingerprintProfileService) RandomizeForAccount(ctx context.Context,
// 2. 生成新 profile
generated := tlsfingerprint.GenerateRandomizedProfile()
description := fmt.Sprintf("Auto-randomized fingerprint for account #%d (%s baseline, perturbed)", accountID, "Claude Code 2.1.112")
description := fmt.Sprintf("Auto-randomized fingerprint for account #%d (%s baseline, perturbed)", accountID, "Claude Code 2.1.114")
profileName := fmt.Sprintf("%s%d-%d", autoGeneratedProfileNamePrefix, accountID, time.Now().Unix())
newProfile := &model.TLSFingerprintProfile{
@@ -0,0 +1,107 @@
{
"captured_at": "2026-04-19T04:39:46Z",
"remote_addr": "127.0.0.1:61818",
"server_name": "localhost",
"tls_version": "TLS 1.3",
"negotiated_proto": "http/1.1",
"client_hello_raw": "010001fc03035911f99fecac3cc90a1251fea8fb722ac5f5021dec67bb4cb816c41bc523ee7220c539e0ce4ecce98fa8c623c887fd861d285ad713d66c04227f55e02164a9dc230022130113021303c02bc02fc02cc030cca9cca8c009c013c00ac014009c009d002f0035010001910000000e000c0000096c6f63616c686f7374fe0d00ba0000010001080020a12b0c8b0db48cbf999568d621238b4a6cef89cdc1375f824a044e1a4852535d0090cbc4c6a0866cc58ab4195d6329d6ab67155415be6158952da54cd9c3cbdc944f73c15c21457d9c1f2983919b643b97f3c89cf1c3151a9e9870898d59b50ea1847b9c32b331baa6ed8b03cdee26717121712bd5646d72adc40bc5cfaeeab17f2f045bcd586b7a0f23595d39404c44ba49f604b9f40e2b1cdabb330d3a5238fb041c44587d3c1f3fe56632d041181c5bfe00170000ff01000100000a00080006001d00170018000b00020100002300000010000b000908687474702f312e31000500050100000000000d0014001204030804040105030805050108060601020100120000003300260024001d0020f1720607b0bfa3b8369b31a326a54ec274a4619e5e269073126f2c1dd24e410f002d00020101002b000504030403030015003100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"cipher_suites": [
"0x1301",
"0x1302",
"0x1303",
"0xc02b",
"0xc02f",
"0xc02c",
"0xc030",
"0xcca9",
"0xcca8",
"0xc009",
"0xc013",
"0xc00a",
"0xc014",
"0x009c",
"0x009d",
"0x002f",
"0x0035"
],
"extensions": [
"server_name (0)",
"*tls.GREASEEncryptedClientHelloExtension",
"extended_master_secret (23)",
"renegotiation_info (0xff01)",
"supported_groups (10)",
"ec_point_formats (11)",
"session_ticket (35)",
"application_layer_protocol_negotiation (16)",
"status_request (5)",
"signature_algorithms (13)",
"signed_certificate_timestamp (18)",
"key_share (51)",
"psk_key_exchange_modes (45)",
"supported_versions (43)",
"padding (21)"
],
"curves": [
"0x001d",
"0x0017",
"0x0018"
],
"point_formats": [
"0x00"
],
"signature_algorithms": [
"0x0403",
"0x0804",
"0x0401",
"0x0503",
"0x0805",
"0x0501",
"0x0806",
"0x0601",
"0x0201"
],
"supported_versions": [
"0x0304",
"0x0303"
],
"key_share_groups": [
"0x001d"
],
"alpn_protos": [
"http/1.1"
],
"psk_modes": [
"0x01"
],
"ja3_string": "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49161-49171-49162-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-21,29-23-24,0",
"ja3_hash": "d871d02cecbde59abbf8f4806134addf",
"ja4": "t13d1714h1",
"http1": {
"request_line": "POST /v1/messages?beta=true HTTP/1.1",
"headers_in_order": [
"Accept: application/json",
"Content-Type: application/json",
"User-Agent: claude-cli/2.1.114 (external, sdk-cli)",
"X-Claude-Code-Session-Id: 2053d87f-8152-4f29-a0db-0b64722a1fd6",
"X-Stainless-Arch: arm64",
"X-Stainless-Lang: js",
"X-Stainless-OS: MacOS",
"X-Stainless-Package-Version: 0.81.0",
"X-Stainless-Retry-Count: 0",
"X-Stainless-Runtime: node",
"X-Stainless-Runtime-Version: v24.3.0",
"X-Stainless-Timeout: 600",
"anthropic-beta: interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,structured-outputs-2025-12-15",
"anthropic-dangerous-direct-browser-access: true",
"anthropic-version: 2023-06-01",
"x-api-key: <redacted>",
"x-app: cli",
"Connection: keep-alive",
"Host: localhost:8443",
"Accept-Encoding: gzip, deflate, br, zstd",
"Content-Length: 1527"
],
"body_preview": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Run the bash tool to echo foo\"}]}],\"system\":[{\"type\":\"text\",\"text\":\"x-anthropic-billing-header: cc_version=2.1.114.a7c; cc_entrypoint=sdk-cli; cch=ceefa;\"},{\"type\":\"text\",\"text\":\"You are a Claude agent, built on Anthropic's Claude Agent SDK.\"},{\"type\":\"text\",\"text\":\"Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this coding session. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns.\\n\\nReturn JSON with a single \\\"title\\\" field.\\n\\nGood examples:\\n{\\\"title\\\": \\\"Fix login button on mobile\\\"}\\n{\\\"title\\\": \\\"Add OAuth authentication\\\"}\\n{\\\"title\\\": \\\"Debug failing CI tests\\\"}\\n{\\\"title\\\": \\\"Refactor API client error handling\\\"}\\n\\nBad (too vague): {\\\"title\\\": \\\"Code changes\\\"}\\nBad (too long): {\\\"title\\\": \\\"Investigate and fix the issue where the login button does not respond on mobile devices\\\"}\\nBad (wrong case): {\\\"title\\\": \\\"Fix Login Button On Mobile\\\"}\"}],\"tools\":[],\"metadata\":{\"user_id\":\"{\\\"device_id\\\":\\\"7bea6f9313f3c20e3440b4c4e4c354631176ea4bd8ea6344a2dc93e2319a68ac\\\",\\\"account_uuid\\\":\\\"\\\",\\\"session_id\\\":\\\"2053d87f-8152-4f29-a0db-0b64722a1fd6\\\"}\"},\"max_tokens\":32000,\"temperature\":1,\"output_config\":{\"format\":{\"type\":\"json_schema\",\"schema\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"}},\"required\":[\"title\"],\"additionalProperties\":false}}},\"stream\":true}",
"body_bytes": 1527
}
}
File diff suppressed because one or more lines are too long