@vacbo/opencode-anthropic-fix 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3370,7 +3370,7 @@ var init_cli = __esm({
3370
3370
  });
3371
3371
 
3372
3372
  // src/index.ts
3373
- import { randomUUID as randomUUID2 } from "node:crypto";
3373
+ import { randomUUID as randomUUID3 } from "node:crypto";
3374
3374
 
3375
3375
  // src/backoff.ts
3376
3376
  var QUOTA_EXHAUSTED_BACKOFFS = [6e4, 3e5, 18e5, 72e5];
@@ -6010,6 +6010,107 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
6010
6010
  // src/index.ts
6011
6011
  init_oauth();
6012
6012
 
6013
+ // src/headers/cch.ts
6014
+ var MASK64 = 0xffffffffffffffffn;
6015
+ var CCH_MASK = 0x0fffffn;
6016
+ var PRIME1 = 0x9e3779b185ebca87n;
6017
+ var PRIME2 = 0xc2b2ae3d27d4eb4fn;
6018
+ var PRIME3 = 0x165667b19e3779f9n;
6019
+ var PRIME4 = 0x85ebca77c2b2ae63n;
6020
+ var PRIME5 = 0x27d4eb2f165667c5n;
6021
+ var CCH_FIELD_PREFIX = "cch=";
6022
+ var CCH_PLACEHOLDER = "00000";
6023
+ var CCH_SEED = 0x6e52736ac806831en;
6024
+ var encoder = new TextEncoder();
6025
+ function toUint64(value) {
6026
+ return value & MASK64;
6027
+ }
6028
+ function rotateLeft64(value, bits) {
6029
+ const shift = BigInt(bits);
6030
+ return toUint64(value << shift | value >> 64n - shift);
6031
+ }
6032
+ function readUint32LE(view, offset) {
6033
+ return BigInt(view.getUint32(offset, true));
6034
+ }
6035
+ function readUint64LE(view, offset) {
6036
+ return view.getBigUint64(offset, true);
6037
+ }
6038
+ function round64(acc, input) {
6039
+ const mixed = toUint64(acc + toUint64(input * PRIME2));
6040
+ return toUint64(rotateLeft64(mixed, 31) * PRIME1);
6041
+ }
6042
+ function mergeRound64(acc, value) {
6043
+ const mixed = acc ^ round64(0n, value);
6044
+ return toUint64(toUint64(mixed) * PRIME1 + PRIME4);
6045
+ }
6046
+ function avalanche64(hash) {
6047
+ let mixed = hash ^ hash >> 33n;
6048
+ mixed = toUint64(mixed * PRIME2);
6049
+ mixed ^= mixed >> 29n;
6050
+ mixed = toUint64(mixed * PRIME3);
6051
+ mixed ^= mixed >> 32n;
6052
+ return toUint64(mixed);
6053
+ }
6054
+ function xxHash64(input, seed = CCH_SEED) {
6055
+ const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
6056
+ const length = input.byteLength;
6057
+ let offset = 0;
6058
+ let hash;
6059
+ if (length >= 32) {
6060
+ let v1 = toUint64(seed + PRIME1 + PRIME2);
6061
+ let v2 = toUint64(seed + PRIME2);
6062
+ let v3 = toUint64(seed);
6063
+ let v4 = toUint64(seed - PRIME1);
6064
+ while (offset <= length - 32) {
6065
+ v1 = round64(v1, readUint64LE(view, offset));
6066
+ v2 = round64(v2, readUint64LE(view, offset + 8));
6067
+ v3 = round64(v3, readUint64LE(view, offset + 16));
6068
+ v4 = round64(v4, readUint64LE(view, offset + 24));
6069
+ offset += 32;
6070
+ }
6071
+ hash = toUint64(rotateLeft64(v1, 1) + rotateLeft64(v2, 7) + rotateLeft64(v3, 12) + rotateLeft64(v4, 18));
6072
+ hash = mergeRound64(hash, v1);
6073
+ hash = mergeRound64(hash, v2);
6074
+ hash = mergeRound64(hash, v3);
6075
+ hash = mergeRound64(hash, v4);
6076
+ } else {
6077
+ hash = toUint64(seed + PRIME5);
6078
+ }
6079
+ hash = toUint64(hash + BigInt(length));
6080
+ while (offset <= length - 8) {
6081
+ const lane = round64(0n, readUint64LE(view, offset));
6082
+ hash ^= lane;
6083
+ hash = toUint64(rotateLeft64(hash, 27) * PRIME1 + PRIME4);
6084
+ offset += 8;
6085
+ }
6086
+ if (offset <= length - 4) {
6087
+ hash ^= toUint64(readUint32LE(view, offset) * PRIME1);
6088
+ hash = toUint64(rotateLeft64(hash, 23) * PRIME2 + PRIME3);
6089
+ offset += 4;
6090
+ }
6091
+ while (offset < length) {
6092
+ hash ^= toUint64(BigInt(view.getUint8(offset)) * PRIME5);
6093
+ hash = toUint64(rotateLeft64(hash, 11) * PRIME1);
6094
+ offset += 1;
6095
+ }
6096
+ return avalanche64(hash);
6097
+ }
6098
+ function computeNativeStyleCch(serializedBody) {
6099
+ const hash = xxHash64(encoder.encode(serializedBody), CCH_SEED);
6100
+ return (hash & CCH_MASK).toString(16).padStart(5, "0");
6101
+ }
6102
+ function replaceNativeStyleCch(serializedBody) {
6103
+ const sentinel = `${CCH_FIELD_PREFIX}${CCH_PLACEHOLDER}`;
6104
+ const fieldIndex = serializedBody.indexOf(sentinel);
6105
+ if (fieldIndex === -1) {
6106
+ return serializedBody;
6107
+ }
6108
+ const valueStart = fieldIndex + CCH_FIELD_PREFIX.length;
6109
+ const valueEnd = valueStart + CCH_PLACEHOLDER.length;
6110
+ const cch = computeNativeStyleCch(serializedBody);
6111
+ return `${serializedBody.slice(0, valueStart)}${cch}${serializedBody.slice(valueEnd)}`;
6112
+ }
6113
+
6013
6114
  // src/headers/billing.ts
6014
6115
  import { createHash as createHash2 } from "node:crypto";
6015
6116
  function buildAnthropicBillingHeader(claudeCliVersion, messages) {
@@ -6039,16 +6140,7 @@ function buildAnthropicBillingHeader(claudeCliVersion, messages) {
6039
6140
  }
6040
6141
  }
6041
6142
  const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "cli";
6042
- let cchValue;
6043
- if (Array.isArray(messages) && messages.length > 0) {
6044
- const bodyHint = JSON.stringify(messages).slice(0, 512);
6045
- const cchHash = createHash2("sha256").update(bodyHint + claudeCliVersion + Date.now().toString(36)).digest("hex");
6046
- cchValue = cchHash.slice(0, 5);
6047
- } else {
6048
- const buf = createHash2("sha256").update(Date.now().toString(36) + Math.random().toString(36)).digest("hex");
6049
- cchValue = buf.slice(0, 5);
6050
- }
6051
- return `x-anthropic-billing-header: cc_version=${claudeCliVersion}${versionSuffix}; cc_entrypoint=${entrypoint}; cch=${cchValue};`;
6143
+ return `x-anthropic-billing-header: cc_version=${claudeCliVersion}${versionSuffix}; cc_entrypoint=${entrypoint}; cch=${CCH_PLACEHOLDER};`;
6052
6144
  }
6053
6145
 
6054
6146
  // src/system-prompt/normalize.ts
@@ -6415,7 +6507,7 @@ function transformRequestBody(body, signature, runtime, relocateThirdPartyPrompt
6415
6507
  return msg;
6416
6508
  });
6417
6509
  }
6418
- return JSON.stringify(parsed);
6510
+ return replaceNativeStyleCch(JSON.stringify(parsed));
6419
6511
  } catch (err) {
6420
6512
  if (err instanceof SyntaxError) {
6421
6513
  debugLog?.("body parse failed:", err.message);
@@ -6809,7 +6901,7 @@ function transformResponse(response, onUsage, onAccountError, onStreamError) {
6809
6901
  if (!response.body || !isEventStreamResponse(response)) return response;
6810
6902
  const reader = response.body.getReader();
6811
6903
  const decoder = new TextDecoder("utf-8", { fatal: true });
6812
- const encoder = new TextEncoder();
6904
+ const encoder2 = new TextEncoder();
6813
6905
  const stats = {
6814
6906
  inputTokens: 0,
6815
6907
  outputTokens: 0,
@@ -6867,7 +6959,7 @@ function transformResponse(response, onUsage, onAccountError, onStreamError) {
6867
6959
  if (eventType === "error") {
6868
6960
  hasSeenError = true;
6869
6961
  }
6870
- controller.enqueue(encoder.encode(formatSSEEventBlock(eventType, parsedRecord, strictEventValidation)));
6962
+ controller.enqueue(encoder2.encode(formatSSEEventBlock(eventType, parsedRecord, strictEventValidation)));
6871
6963
  if (eventType === "error" && strictEventValidation) {
6872
6964
  throw new Error(getErrorMessage(parsedRecord));
6873
6965
  }
@@ -7006,7 +7098,7 @@ async function acquireRefreshLock(accountId, options = {}) {
7006
7098
  const handle = await fs2.open(lockPath, "wx", 384);
7007
7099
  try {
7008
7100
  await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now(), owner }), "utf-8");
7009
- const stat = await handle.stat();
7101
+ const stat = await handle.stat({ bigint: true });
7010
7102
  return { acquired: true, lockPath, owner, lockInode: stat.ino };
7011
7103
  } finally {
7012
7104
  await handle.close();
@@ -7017,8 +7109,8 @@ async function acquireRefreshLock(accountId, options = {}) {
7017
7109
  throw error;
7018
7110
  }
7019
7111
  try {
7020
- const stat = await fs2.stat(lockPath);
7021
- if (Date.now() - stat.mtimeMs > staleMs) {
7112
+ const stat = await fs2.stat(lockPath, { bigint: true });
7113
+ if (Date.now() - Number(stat.mtimeMs) > staleMs) {
7022
7114
  await fs2.unlink(lockPath);
7023
7115
  continue;
7024
7116
  }
@@ -7035,7 +7127,7 @@ async function acquireRefreshLock(accountId, options = {}) {
7035
7127
  async function releaseRefreshLock(lock) {
7036
7128
  const lockPath = typeof lock === "string" || lock === null ? lock : lock.lockPath;
7037
7129
  const owner = typeof lock === "object" && lock ? lock.owner || null : null;
7038
- const lockInode = typeof lock === "object" && lock ? lock.lockInode || null : null;
7130
+ const lockInode = typeof lock === "object" && lock ? lock.lockInode ?? null : null;
7039
7131
  if (!lockPath) return;
7040
7132
  if (owner) {
7041
7133
  try {
@@ -7045,7 +7137,7 @@ async function releaseRefreshLock(lock) {
7045
7137
  return;
7046
7138
  }
7047
7139
  if (lockInode) {
7048
- const stat = await fs2.stat(lockPath);
7140
+ const stat = await fs2.stat(lockPath, { bigint: true });
7049
7141
  if (stat.ino !== lockInode) {
7050
7142
  return;
7051
7143
  }
@@ -7257,6 +7349,7 @@ function formatSwitchReason(status, reason) {
7257
7349
 
7258
7350
  // src/bun-fetch.ts
7259
7351
  import { execFileSync, spawn } from "node:child_process";
7352
+ import { randomUUID as randomUUID2 } from "node:crypto";
7260
7353
  import { existsSync as existsSync5 } from "node:fs";
7261
7354
  import { dirname as dirname4, join as join6 } from "node:path";
7262
7355
  import * as readline from "node:readline";
@@ -7476,20 +7569,38 @@ function buildProxyRequestInit(input, init) {
7476
7569
  ...signal ? { signal } : {}
7477
7570
  };
7478
7571
  }
7572
+ var DEBUG_DUMP_DIR = "/tmp";
7573
+ var DEBUG_LATEST_REQUEST_PATH = `${DEBUG_DUMP_DIR}/opencode-last-request.json`;
7574
+ var DEBUG_LATEST_HEADERS_PATH = `${DEBUG_DUMP_DIR}/opencode-last-headers.json`;
7575
+ function makeDebugDumpId() {
7576
+ const filesystemSafeTimestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
7577
+ const subMillisecondCollisionGuard = randomUUID2().slice(0, 8);
7578
+ return `${filesystemSafeTimestamp}-${subMillisecondCollisionGuard}`;
7579
+ }
7479
7580
  async function writeDebugArtifacts(url, init) {
7480
7581
  if (!init.body || !url.includes("/v1/messages") || url.includes("count_tokens")) {
7481
- return;
7582
+ return null;
7482
7583
  }
7483
7584
  const { writeFileSync: writeFileSync5 } = await import("node:fs");
7484
- writeFileSync5(
7485
- "/tmp/opencode-last-request.json",
7486
- typeof init.body === "string" ? init.body : JSON.stringify(init.body)
7487
- );
7585
+ const id = makeDebugDumpId();
7586
+ const requestPath = `${DEBUG_DUMP_DIR}/opencode-request-${id}.json`;
7587
+ const headersPath = `${DEBUG_DUMP_DIR}/opencode-headers-${id}.json`;
7588
+ const bodyText = typeof init.body === "string" ? init.body : JSON.stringify(init.body);
7488
7589
  const logHeaders = {};
7489
7590
  toHeaders(init.headers).forEach((value, key) => {
7490
7591
  logHeaders[key] = key === "authorization" ? "Bearer ***" : value;
7491
7592
  });
7492
- writeFileSync5("/tmp/opencode-last-headers.json", JSON.stringify(logHeaders, null, 2));
7593
+ const headersText = JSON.stringify(logHeaders, null, 2);
7594
+ writeFileSync5(requestPath, bodyText);
7595
+ writeFileSync5(headersPath, headersText);
7596
+ writeFileSync5(DEBUG_LATEST_REQUEST_PATH, bodyText);
7597
+ writeFileSync5(DEBUG_LATEST_HEADERS_PATH, headersText);
7598
+ return {
7599
+ requestPath,
7600
+ headersPath,
7601
+ latestRequestPath: DEBUG_LATEST_REQUEST_PATH,
7602
+ latestHeadersPath: DEBUG_LATEST_HEADERS_PATH
7603
+ };
7493
7604
  }
7494
7605
  function createBunFetch(options = {}) {
7495
7606
  const breaker = createCircuitBreaker({
@@ -7703,9 +7814,11 @@ function createBunFetch(options = {}) {
7703
7814
  }
7704
7815
  if (resolveDebug(debugOverride)) {
7705
7816
  try {
7706
- await writeDebugArtifacts(url, init ?? {});
7707
- if ((init?.body ?? null) !== null && url.includes("/v1/messages") && !url.includes("count_tokens")) {
7708
- console.error("[opencode-anthropic-auth] Dumped request to /tmp/opencode-last-request.json");
7817
+ const dumped = await writeDebugArtifacts(url, init ?? {});
7818
+ if (dumped) {
7819
+ console.error(
7820
+ `[opencode-anthropic-auth] Dumped request to ${dumped.requestPath} (latest alias: ${dumped.latestRequestPath})`
7821
+ );
7709
7822
  }
7710
7823
  } catch (error) {
7711
7824
  console.error("[opencode-anthropic-auth] Failed to dump request:", error);
@@ -8023,7 +8136,7 @@ async function AnthropicAuthPlugin({
8023
8136
  return bunFetchInstance.fetch(input, init);
8024
8137
  };
8025
8138
  let claudeCliVersion = FALLBACK_CLAUDE_CLI_VERSION;
8026
- const signatureSessionId = randomUUID2();
8139
+ const signatureSessionId = randomUUID3();
8027
8140
  const signatureUserId = getOrCreateSignatureUserId();
8028
8141
  if (shouldFetchClaudeCodeVersion) {
8029
8142
  fetchLatestClaudeCodeVersion().then((version) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vacbo/opencode-anthropic-fix",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "main": "dist/opencode-anthropic-auth-plugin.js",
5
5
  "bin": {
6
6
  "opencode-anthropic-auth": "dist/opencode-anthropic-auth-cli.mjs",
@@ -20,6 +20,8 @@
20
20
  "build": "bun scripts/build.ts",
21
21
  "prepublishOnly": "npm run build && npm test",
22
22
  "test": "vitest run",
23
+ "check:cch-drift": "bun scripts/drift-checker/check-cch-constants.ts --fail-on-drift",
24
+ "check:cch-drift:npm": "bun scripts/drift-checker/check-cch-constants.ts --npm-version latest --fail-on-drift",
23
25
  "test:watch": "vitest",
24
26
  "lint": "eslint .",
25
27
  "lint:fix": "eslint --fix .",
@@ -57,7 +57,7 @@ describe("CC 2.1.98 — Full request fingerprint comparison", () => {
57
57
  console.log(`║ Axios version: 1.13.6 (token endpoint UA)`);
58
58
  console.log(`║ anthropic-ver: 2023-06-01`);
59
59
  console.log(`║ x-app: cli`);
60
- console.log(`║ cch: 00000 (placeholder, not attested on Node.js)`);
60
+ console.log(`║ cch: 00000 placeholder xxHash64(serialized body, seed 0x6E52736AC806831E)`);
61
61
  console.log(`║ Identity: You are Claude Code, Anthropic's official CLI for Claude.`);
62
62
  console.log(`║ Identity cache: {"type":"ephemeral","scope":"global","ttl":"1h"}`);
63
63
  console.log(`║ Client ID: 9d1c250a-e61b-44d9-88ed-5944d1962f5e`);
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ EXPECTED_CCH_PLACEHOLDER,
5
+ EXPECTED_CCH_SALT,
6
+ EXPECTED_CCH_SEED,
7
+ EXPECTED_XXHASH64_PRIMES,
8
+ bigintToLittleEndianBytes,
9
+ findAllOccurrences,
10
+ scanCchConstants,
11
+ } from "../drift/cch-constants.js";
12
+
13
+ describe("cch drift checker helpers", () => {
14
+ it("encodes bigint constants as little-endian bytes", () => {
15
+ expect(Array.from(bigintToLittleEndianBytes(EXPECTED_CCH_SEED))).toEqual([
16
+ 0x1e, 0x83, 0x06, 0xc8, 0x6a, 0x73, 0x52, 0x6e,
17
+ ]);
18
+ });
19
+
20
+ it("finds all occurrences of a byte pattern", () => {
21
+ const haystack = new Uint8Array([1, 2, 3, 1, 2, 3, 4]);
22
+ const needle = new Uint8Array([1, 2, 3]);
23
+ expect(findAllOccurrences(haystack, needle)).toEqual([0, 3]);
24
+ });
25
+ });
26
+
27
+ describe("scanCchConstants", () => {
28
+ it("passes when all standalone constants are present", () => {
29
+ const bytes = new Uint8Array([
30
+ ...new TextEncoder().encode(`xx cch=${EXPECTED_CCH_PLACEHOLDER} yy ${EXPECTED_CCH_SALT} zz`),
31
+ ...bigintToLittleEndianBytes(EXPECTED_CCH_SEED),
32
+ ...EXPECTED_XXHASH64_PRIMES.flatMap((prime) => Array.from(bigintToLittleEndianBytes(prime))),
33
+ ]);
34
+
35
+ const report = scanCchConstants(bytes, "synthetic-standalone", "standalone");
36
+ expect(report.passed).toBe(true);
37
+ expect(report.findings).toEqual([]);
38
+ expect(report.checked.placeholder).toBeGreaterThan(0);
39
+ expect(report.checked.salt).toBeGreaterThan(0);
40
+ expect(report.checked.seed).toBeGreaterThan(0);
41
+ expect(report.checked.primes.every((count) => count > 0)).toBe(true);
42
+ });
43
+
44
+ it("fails critically when placeholder and seed are missing", () => {
45
+ const bytes = new TextEncoder().encode(`missing ${EXPECTED_CCH_SALT}`);
46
+ const report = scanCchConstants(bytes, "broken-standalone", "standalone");
47
+
48
+ expect(report.passed).toBe(false);
49
+ expect(report.findings.map((finding) => finding.name)).toContain("cch placeholder");
50
+ expect(report.findings.map((finding) => finding.name)).toContain("native cch seed");
51
+ });
52
+
53
+ it("only requires placeholder and salt for npm cli bundles", () => {
54
+ const bytes = new TextEncoder().encode(`prefix cch=${EXPECTED_CCH_PLACEHOLDER}; ${EXPECTED_CCH_SALT} suffix`);
55
+ const report = scanCchConstants(bytes, "synthetic-bundle", "bundle");
56
+
57
+ expect(report.passed).toBe(true);
58
+ expect(report.checked.seed).toBe(0);
59
+ expect(report.findings).toEqual([]);
60
+ });
61
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buildAnthropicBillingHeader } from "../headers/billing.js";
4
+ import { CCH_PLACEHOLDER, computeNativeStyleCch, replaceNativeStyleCch, xxHash64 } from "../headers/cch.js";
5
+ import { transformRequestBody } from "../request/body.js";
6
+
7
+ describe("native-style cch", () => {
8
+ it("matches independently verified xxHash64 reference values", () => {
9
+ expect(xxHash64(new TextEncoder().encode(""))).toBe(0x6e798575d82647edn);
10
+ expect(xxHash64(new TextEncoder().encode("a"))).toBe(0x8404a7f0a8a6bcaen);
11
+ expect(xxHash64(new TextEncoder().encode("hello"))).toBe(0x95ab2f66e009922an);
12
+ });
13
+
14
+ it("derives the expected 5-char cch from independently verified vectors", () => {
15
+ expect(computeNativeStyleCch("")).toBe("647ed");
16
+ expect(computeNativeStyleCch("cch=00000")).toBe("1d7f8");
17
+ expect(
18
+ computeNativeStyleCch("x-anthropic-billing-header: cc_version=2.1.101.0e7; cc_entrypoint=cli; cch=00000;"),
19
+ ).toBe("101fb");
20
+ });
21
+
22
+ it("replaces the placeholder in a serialized body", () => {
23
+ const serialized =
24
+ '{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.101.0e7; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"Reply with the single word: OK"}]}';
25
+
26
+ expect(replaceNativeStyleCch(serialized)).toContain("cch=0ca30");
27
+ expect(replaceNativeStyleCch(serialized)).not.toContain(`cch=${CCH_PLACEHOLDER}`);
28
+ });
29
+
30
+ it("leaves strings without the placeholder unchanged", () => {
31
+ const serialized = '{"system":[{"type":"text","text":"no billing block here"}]}';
32
+ expect(replaceNativeStyleCch(serialized)).toBe(serialized);
33
+ });
34
+ });
35
+
36
+ describe("billing header placeholder + transformRequestBody replacement", () => {
37
+ const signature = {
38
+ enabled: true,
39
+ claudeCliVersion: "2.1.101",
40
+ promptCompactionMode: "minimal" as const,
41
+ sanitizeSystemPrompt: false,
42
+ };
43
+ const runtime = {
44
+ persistentUserId: "0".repeat(64),
45
+ accountId: "acct-1",
46
+ sessionId: "session-1",
47
+ };
48
+
49
+ it("buildAnthropicBillingHeader emits the native placeholder", () => {
50
+ process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = "true";
51
+ const header = buildAnthropicBillingHeader("2.1.101", [{ role: "user", content: "Hello world from a test" }]);
52
+ expect(header).toContain(`cch=${CCH_PLACEHOLDER};`);
53
+ });
54
+
55
+ it("transformRequestBody replaces the placeholder after serialization", () => {
56
+ const body = JSON.stringify({
57
+ model: "claude-sonnet-4-5",
58
+ max_tokens: 1024,
59
+ stream: false,
60
+ messages: [{ role: "user", content: "Reply with the single word: OK" }],
61
+ system: "You are a helpful assistant.",
62
+ });
63
+
64
+ const transformed = transformRequestBody(body, signature, runtime);
65
+ expect(transformed).toBeDefined();
66
+ expect(transformed).not.toContain(`cch=${CCH_PLACEHOLDER}`);
67
+
68
+ const actualCch = transformed?.match(/cch=([0-9a-f]{5})/)?.[1];
69
+ const placeholderBody = transformed?.replace(/cch=[0-9a-f]{5}/, `cch=${CCH_PLACEHOLDER}`);
70
+
71
+ expect(actualCch).toBeDefined();
72
+ expect(placeholderBody).toBeDefined();
73
+ expect(actualCch).toBe(computeNativeStyleCch(placeholderBody!));
74
+ expect(transformed).toBe(replaceNativeStyleCch(placeholderBody!));
75
+ });
76
+ });
@@ -176,10 +176,9 @@ describe("CC 2.1.98 — Billing header", () => {
176
176
  restoreEnv(originalEnv);
177
177
  });
178
178
 
179
- it("contains a 5-char hex cch value (not the literal 00000 placeholder)", () => {
179
+ it("contains the native placeholder cch before post-serialization replacement", () => {
180
180
  const header = buildAnthropicBillingHeader(CC_VERSION, []);
181
- expect(header).toMatch(/cch=[0-9a-f]{5};/);
182
- expect(header).not.toContain("cch=00000;");
181
+ expect(header).toContain("cch=00000;");
183
182
  });
184
183
 
185
184
  it("contains the correct cc_version", () => {
@@ -379,3 +379,106 @@ describe("createBunFetch runtime lifecycle (RED until T20)", () => {
379
379
  expect(proxyA.child.killSignals).toEqual([]);
380
380
  });
381
381
  });
382
+
383
+ describe("createBunFetch debug request dumping", () => {
384
+ const UNIQUE_REQUEST_PATTERN =
385
+ /^\/tmp\/opencode-request-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-[0-9a-f]{8}\.json$/;
386
+ const UNIQUE_HEADERS_PATTERN =
387
+ /^\/tmp\/opencode-headers-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-[0-9a-f]{8}\.json$/;
388
+ const LATEST_REQUEST_PATH = "/tmp/opencode-last-request.json";
389
+ const LATEST_HEADERS_PATH = "/tmp/opencode-last-headers.json";
390
+
391
+ function writtenPaths(): string[] {
392
+ return writeFileSyncMock.mock.calls.map((call) => String(call[0]));
393
+ }
394
+
395
+ it("writes a uniquely-named request file AND a latest-alias file when debug=true", async () => {
396
+ const proxy = createMockBunProxy();
397
+ spawnMock.mockImplementation(proxy.mockSpawn);
398
+ installMockFetch();
399
+
400
+ const moduleNs = await loadBunFetchModule();
401
+ const createBunFetch = getCreateBunFetch(moduleNs);
402
+ const instance = createBunFetch({ debug: true });
403
+
404
+ proxy.simulateStdoutBanner(42001);
405
+
406
+ await instance.fetch("https://api.anthropic.com/v1/messages?beta=true", {
407
+ method: "POST",
408
+ body: JSON.stringify({ hello: "world" }),
409
+ });
410
+
411
+ const paths = writtenPaths();
412
+ const uniqueRequest = paths.find((path) => UNIQUE_REQUEST_PATTERN.test(path));
413
+ const uniqueHeaders = paths.find((path) => UNIQUE_HEADERS_PATTERN.test(path));
414
+
415
+ expect(uniqueRequest, "expected a uniquely-named request dump").toBeDefined();
416
+ expect(uniqueHeaders, "expected a uniquely-named headers dump").toBeDefined();
417
+ expect(paths).toContain(LATEST_REQUEST_PATH);
418
+ expect(paths).toContain(LATEST_HEADERS_PATH);
419
+ });
420
+
421
+ it("produces a different unique path for each sequential debug request", async () => {
422
+ const proxy = createMockBunProxy();
423
+ spawnMock.mockImplementation(proxy.mockSpawn);
424
+ installMockFetch();
425
+
426
+ const moduleNs = await loadBunFetchModule();
427
+ const createBunFetch = getCreateBunFetch(moduleNs);
428
+ const instance = createBunFetch({ debug: true });
429
+
430
+ proxy.simulateStdoutBanner(42002);
431
+
432
+ await instance.fetch("https://api.anthropic.com/v1/messages?beta=true", {
433
+ method: "POST",
434
+ body: JSON.stringify({ n: 1 }),
435
+ });
436
+ await instance.fetch("https://api.anthropic.com/v1/messages?beta=true", {
437
+ method: "POST",
438
+ body: JSON.stringify({ n: 2 }),
439
+ });
440
+
441
+ const uniquePaths = writtenPaths().filter((path) => UNIQUE_REQUEST_PATTERN.test(path));
442
+
443
+ expect(uniquePaths).toHaveLength(2);
444
+ expect(uniquePaths[0]).not.toBe(uniquePaths[1]);
445
+ });
446
+
447
+ it("does not dump any artifact for count_tokens requests even when debug=true", async () => {
448
+ const proxy = createMockBunProxy();
449
+ spawnMock.mockImplementation(proxy.mockSpawn);
450
+ installMockFetch();
451
+
452
+ const moduleNs = await loadBunFetchModule();
453
+ const createBunFetch = getCreateBunFetch(moduleNs);
454
+ const instance = createBunFetch({ debug: true });
455
+
456
+ proxy.simulateStdoutBanner(42003);
457
+
458
+ await instance.fetch("https://api.anthropic.com/v1/messages/count_tokens", {
459
+ method: "POST",
460
+ body: JSON.stringify({ hello: "world" }),
461
+ });
462
+
463
+ expect(writeFileSyncMock).not.toHaveBeenCalled();
464
+ });
465
+
466
+ it("does not dump any artifact when debug=false", async () => {
467
+ const proxy = createMockBunProxy();
468
+ spawnMock.mockImplementation(proxy.mockSpawn);
469
+ installMockFetch();
470
+
471
+ const moduleNs = await loadBunFetchModule();
472
+ const createBunFetch = getCreateBunFetch(moduleNs);
473
+ const instance = createBunFetch({ debug: false });
474
+
475
+ proxy.simulateStdoutBanner(42004);
476
+
477
+ await instance.fetch("https://api.anthropic.com/v1/messages?beta=true", {
478
+ method: "POST",
479
+ body: JSON.stringify({ hello: "world" }),
480
+ });
481
+
482
+ expect(writeFileSyncMock).not.toHaveBeenCalled();
483
+ });
484
+ });
package/src/bun-fetch.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { existsSync } from "node:fs";
3
4
  import { dirname, join } from "node:path";
4
5
  import * as readline from "node:readline";
@@ -119,23 +120,52 @@ function buildProxyRequestInit(input: FetchInput, init?: RequestInit): RequestIn
119
120
  };
120
121
  }
121
122
 
122
- async function writeDebugArtifacts(url: string, init: RequestInit): Promise<void> {
123
+ const DEBUG_DUMP_DIR = "/tmp";
124
+ const DEBUG_LATEST_REQUEST_PATH = `${DEBUG_DUMP_DIR}/opencode-last-request.json`;
125
+ const DEBUG_LATEST_HEADERS_PATH = `${DEBUG_DUMP_DIR}/opencode-last-headers.json`;
126
+
127
+ interface DebugDumpPaths {
128
+ requestPath: string;
129
+ headersPath: string;
130
+ latestRequestPath: string;
131
+ latestHeadersPath: string;
132
+ }
133
+
134
+ function makeDebugDumpId(): string {
135
+ const filesystemSafeTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
136
+ const subMillisecondCollisionGuard = randomUUID().slice(0, 8);
137
+ return `${filesystemSafeTimestamp}-${subMillisecondCollisionGuard}`;
138
+ }
139
+
140
+ async function writeDebugArtifacts(url: string, init: RequestInit): Promise<DebugDumpPaths | null> {
123
141
  if (!init.body || !url.includes("/v1/messages") || url.includes("count_tokens")) {
124
- return;
142
+ return null;
125
143
  }
126
144
 
127
145
  const { writeFileSync } = await import("node:fs");
128
- writeFileSync(
129
- "/tmp/opencode-last-request.json",
130
- typeof init.body === "string" ? init.body : JSON.stringify(init.body),
131
- );
146
+ const id = makeDebugDumpId();
147
+ const requestPath = `${DEBUG_DUMP_DIR}/opencode-request-${id}.json`;
148
+ const headersPath = `${DEBUG_DUMP_DIR}/opencode-headers-${id}.json`;
149
+
150
+ const bodyText = typeof init.body === "string" ? init.body : JSON.stringify(init.body);
132
151
 
133
152
  const logHeaders: Record<string, string> = {};
134
153
  toHeaders(init.headers).forEach((value, key) => {
135
154
  logHeaders[key] = key === "authorization" ? "Bearer ***" : value;
136
155
  });
156
+ const headersText = JSON.stringify(logHeaders, null, 2);
157
+
158
+ writeFileSync(requestPath, bodyText);
159
+ writeFileSync(headersPath, headersText);
160
+ writeFileSync(DEBUG_LATEST_REQUEST_PATH, bodyText);
161
+ writeFileSync(DEBUG_LATEST_HEADERS_PATH, headersText);
137
162
 
138
- writeFileSync("/tmp/opencode-last-headers.json", JSON.stringify(logHeaders, null, 2));
163
+ return {
164
+ requestPath,
165
+ headersPath,
166
+ latestRequestPath: DEBUG_LATEST_REQUEST_PATH,
167
+ latestHeadersPath: DEBUG_LATEST_HEADERS_PATH,
168
+ };
139
169
  }
140
170
 
141
171
  export function createBunFetch(options: BunFetchOptions = {}): BunFetchInstance {
@@ -400,10 +430,12 @@ export function createBunFetch(options: BunFetchOptions = {}): BunFetchInstance
400
430
 
401
431
  if (resolveDebug(debugOverride)) {
402
432
  try {
403
- await writeDebugArtifacts(url, init ?? {});
404
- if ((init?.body ?? null) !== null && url.includes("/v1/messages") && !url.includes("count_tokens")) {
433
+ const dumped = await writeDebugArtifacts(url, init ?? {});
434
+ if (dumped) {
405
435
  // eslint-disable-next-line no-console -- debug-gated diagnostic; confirms request artifact dump location
406
- console.error("[opencode-anthropic-auth] Dumped request to /tmp/opencode-last-request.json");
436
+ console.error(
437
+ `[opencode-anthropic-auth] Dumped request to ${dumped.requestPath} (latest alias: ${dumped.latestRequestPath})`,
438
+ );
407
439
  }
408
440
  } catch (error) {
409
441
  // eslint-disable-next-line no-console -- error-path diagnostic surfaced to stderr for operator visibility
@@ -0,0 +1,133 @@
1
+ export const EXPECTED_CCH_PLACEHOLDER = "00000";
2
+ export const EXPECTED_CCH_SALT = "59cf53e54c78";
3
+ export const EXPECTED_CCH_SEED = 0x6e52_736a_c806_831en;
4
+
5
+ export const EXPECTED_XXHASH64_PRIMES = [
6
+ 0x9e37_79b1_85eb_ca87n,
7
+ 0xc2b2_ae3d_27d4_eb4fn,
8
+ 0x1656_67b1_9e37_79f9n,
9
+ 0x85eb_ca77_c2b2_ae63n,
10
+ 0x27d4_eb2f_1656_67c5n,
11
+ ] as const;
12
+
13
+ export type DriftSeverity = "critical" | "warning";
14
+
15
+ export interface DriftFinding {
16
+ name: string;
17
+ severity: DriftSeverity;
18
+ expected: string;
19
+ actual: string;
20
+ count: number;
21
+ }
22
+
23
+ export interface DriftScanReport {
24
+ target: string;
25
+ mode: "standalone" | "bundle";
26
+ findings: DriftFinding[];
27
+ checked: {
28
+ placeholder: number;
29
+ salt: number;
30
+ seed: number;
31
+ primes: number[];
32
+ };
33
+ passed: boolean;
34
+ }
35
+
36
+ function encodeAscii(value: string): Uint8Array {
37
+ return new TextEncoder().encode(value);
38
+ }
39
+
40
+ export function bigintToLittleEndianBytes(value: bigint): Uint8Array {
41
+ const bytes = new Uint8Array(8);
42
+ let remaining = value;
43
+ for (let index = 0; index < bytes.length; index += 1) {
44
+ bytes[index] = Number(remaining & 0xffn);
45
+ remaining >>= 8n;
46
+ }
47
+ return bytes;
48
+ }
49
+
50
+ export function findAllOccurrences(haystack: Uint8Array, needle: Uint8Array): number[] {
51
+ if (needle.length === 0 || haystack.length < needle.length) {
52
+ return [];
53
+ }
54
+
55
+ const matches: number[] = [];
56
+ outer: for (let start = 0; start <= haystack.length - needle.length; start += 1) {
57
+ for (let offset = 0; offset < needle.length; offset += 1) {
58
+ if (haystack[start + offset] !== needle[offset]) {
59
+ continue outer;
60
+ }
61
+ }
62
+ matches.push(start);
63
+ }
64
+ return matches;
65
+ }
66
+
67
+ function addFinding(
68
+ findings: DriftFinding[],
69
+ count: number,
70
+ name: string,
71
+ severity: DriftSeverity,
72
+ expected: string,
73
+ actual: string,
74
+ ): void {
75
+ if (count > 0) {
76
+ return;
77
+ }
78
+ findings.push({ name, severity, expected, actual, count });
79
+ }
80
+
81
+ export function scanCchConstants(bytes: Uint8Array, target: string, mode: "standalone" | "bundle"): DriftScanReport {
82
+ const placeholderMatches = findAllOccurrences(bytes, encodeAscii(`cch=${EXPECTED_CCH_PLACEHOLDER}`));
83
+ const saltMatches = findAllOccurrences(bytes, encodeAscii(EXPECTED_CCH_SALT));
84
+ const seedMatches = findAllOccurrences(bytes, bigintToLittleEndianBytes(EXPECTED_CCH_SEED));
85
+ const primeMatches = EXPECTED_XXHASH64_PRIMES.map(
86
+ (prime) => findAllOccurrences(bytes, bigintToLittleEndianBytes(prime)).length,
87
+ );
88
+
89
+ const findings: DriftFinding[] = [];
90
+ addFinding(
91
+ findings,
92
+ placeholderMatches.length,
93
+ "cch placeholder",
94
+ "critical",
95
+ `cch=${EXPECTED_CCH_PLACEHOLDER}`,
96
+ "not found",
97
+ );
98
+ addFinding(findings, saltMatches.length, "cc_version salt", "critical", EXPECTED_CCH_SALT, "not found");
99
+
100
+ if (mode === "standalone") {
101
+ addFinding(
102
+ findings,
103
+ seedMatches.length,
104
+ "native cch seed",
105
+ "critical",
106
+ `0x${EXPECTED_CCH_SEED.toString(16)}`,
107
+ "not found",
108
+ );
109
+ for (const [index, count] of primeMatches.entries()) {
110
+ addFinding(
111
+ findings,
112
+ count,
113
+ `xxHash64 prime ${index + 1}`,
114
+ "warning",
115
+ `0x${EXPECTED_XXHASH64_PRIMES[index].toString(16)}`,
116
+ "not found",
117
+ );
118
+ }
119
+ }
120
+
121
+ return {
122
+ target,
123
+ mode,
124
+ findings,
125
+ checked: {
126
+ placeholder: placeholderMatches.length,
127
+ salt: saltMatches.length,
128
+ seed: seedMatches.length,
129
+ primes: primeMatches,
130
+ },
131
+ passed: findings.length === 0,
132
+ };
133
+ }
@@ -1,12 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { CCH_PLACEHOLDER } from "./cch.js";
2
3
  import { isFalsyEnv } from "../env.js";
3
4
 
4
5
  export function buildAnthropicBillingHeader(claudeCliVersion: string, messages: unknown[]): string {
5
6
  if (isFalsyEnv(process.env.CLAUDE_CODE_ATTRIBUTION_HEADER)) return "";
6
7
 
7
- // CC derives a 3-char hash from the first user message content using SHA-256
8
- // with salt "59cf53e54c78", extracting chars at positions [4,7,20] and appending
9
- // the CLI version, then taking the first 3 hex chars of that combined string.
8
+ // CC derives the 3-char cc_version suffix from the first user message using
9
+ // SHA-256 with salt "59cf53e54c78" and positions [4,7,20]. The cch field is
10
+ // emitted here as the literal placeholder "00000" and replaced later, after
11
+ // full-body serialization, by replaceNativeStyleCch() in src/headers/cch.ts.
10
12
  let versionSuffix = "";
11
13
  if (Array.isArray(messages)) {
12
14
  // Find first user message (CC uses first non-meta user turn)
@@ -38,34 +40,5 @@ export function buildAnthropicBillingHeader(claudeCliVersion: string, messages:
38
40
 
39
41
  const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "cli";
40
42
 
41
- // ---------------------------------------------------------------------------
42
- // Billing header construction — mimics CC's mk_() function with two deliberate gaps:
43
- // 1. cc_workload field: CC tracks this via AsyncLocalStorage for session-level
44
- // workload attribution. Not applicable to the plugin (no workload tracking).
45
- // See .omc/research/cch-source-analysis.md:124-131
46
- // 2. cch value: CC uses placeholder "00000". Plugin computes a deterministic hash
47
- // from prompt content for consistent routing. See cch-source-analysis.md:28-39
48
- // ---------------------------------------------------------------------------
49
-
50
- // CC's Bun binary computes a 5-char hex attestation hash via Attestation.zig
51
- // and overwrites the "00000" placeholder before sending. On Node.js (npm CC)
52
- // the placeholder is sent as-is. The server may reject literal "00000" and
53
- // route to extra usage. Generate a body-derived 5-char hex hash to mimic
54
- // the attestation without the Zig layer.
55
- let cchValue: string;
56
- if (Array.isArray(messages) && messages.length > 0) {
57
- const bodyHint = JSON.stringify(messages).slice(0, 512);
58
- const cchHash = createHash("sha256")
59
- .update(bodyHint + claudeCliVersion + Date.now().toString(36))
60
- .digest("hex");
61
- cchValue = cchHash.slice(0, 5);
62
- } else {
63
- // Fallback: random 5-char hex
64
- const buf = createHash("sha256")
65
- .update(Date.now().toString(36) + Math.random().toString(36))
66
- .digest("hex");
67
- cchValue = buf.slice(0, 5);
68
- }
69
-
70
- return `x-anthropic-billing-header: cc_version=${claudeCliVersion}${versionSuffix}; cc_entrypoint=${entrypoint}; cch=${cchValue};`;
43
+ return `x-anthropic-billing-header: cc_version=${claudeCliVersion}${versionSuffix}; cc_entrypoint=${entrypoint}; cch=${CCH_PLACEHOLDER};`;
71
44
  }
@@ -0,0 +1,120 @@
1
+ const MASK64 = 0xffff_ffff_ffff_ffffn;
2
+ const CCH_MASK = 0x0f_ffffn;
3
+ const PRIME1 = 0x9e37_79b1_85eb_ca87n;
4
+ const PRIME2 = 0xc2b2_ae3d_27d4_eb4fn;
5
+ const PRIME3 = 0x1656_67b1_9e37_79f9n;
6
+ const PRIME4 = 0x85eb_ca77_c2b2_ae63n;
7
+ const PRIME5 = 0x27d4_eb2f_1656_67c5n;
8
+ const CCH_FIELD_PREFIX = "cch=";
9
+
10
+ export const CCH_PLACEHOLDER = "00000";
11
+ export const CCH_SEED = 0x6e52_736a_c806_831en;
12
+
13
+ const encoder = new TextEncoder();
14
+
15
+ function toUint64(value: bigint): bigint {
16
+ return value & MASK64;
17
+ }
18
+
19
+ function rotateLeft64(value: bigint, bits: number): bigint {
20
+ const shift = BigInt(bits);
21
+ return toUint64((value << shift) | (value >> (64n - shift)));
22
+ }
23
+
24
+ function readUint32LE(view: DataView, offset: number): bigint {
25
+ return BigInt(view.getUint32(offset, true));
26
+ }
27
+
28
+ function readUint64LE(view: DataView, offset: number): bigint {
29
+ return view.getBigUint64(offset, true);
30
+ }
31
+
32
+ function round64(acc: bigint, input: bigint): bigint {
33
+ const mixed = toUint64(acc + toUint64(input * PRIME2));
34
+ return toUint64(rotateLeft64(mixed, 31) * PRIME1);
35
+ }
36
+
37
+ function mergeRound64(acc: bigint, value: bigint): bigint {
38
+ const mixed = acc ^ round64(0n, value);
39
+ return toUint64(toUint64(mixed) * PRIME1 + PRIME4);
40
+ }
41
+
42
+ function avalanche64(hash: bigint): bigint {
43
+ let mixed = hash ^ (hash >> 33n);
44
+ mixed = toUint64(mixed * PRIME2);
45
+ mixed ^= mixed >> 29n;
46
+ mixed = toUint64(mixed * PRIME3);
47
+ mixed ^= mixed >> 32n;
48
+ return toUint64(mixed);
49
+ }
50
+
51
+ export function xxHash64(input: Uint8Array, seed: bigint = CCH_SEED): bigint {
52
+ const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
53
+ const length = input.byteLength;
54
+ let offset = 0;
55
+ let hash: bigint;
56
+
57
+ if (length >= 32) {
58
+ let v1 = toUint64(seed + PRIME1 + PRIME2);
59
+ let v2 = toUint64(seed + PRIME2);
60
+ let v3 = toUint64(seed);
61
+ let v4 = toUint64(seed - PRIME1);
62
+
63
+ while (offset <= length - 32) {
64
+ v1 = round64(v1, readUint64LE(view, offset));
65
+ v2 = round64(v2, readUint64LE(view, offset + 8));
66
+ v3 = round64(v3, readUint64LE(view, offset + 16));
67
+ v4 = round64(v4, readUint64LE(view, offset + 24));
68
+ offset += 32;
69
+ }
70
+
71
+ hash = toUint64(rotateLeft64(v1, 1) + rotateLeft64(v2, 7) + rotateLeft64(v3, 12) + rotateLeft64(v4, 18));
72
+ hash = mergeRound64(hash, v1);
73
+ hash = mergeRound64(hash, v2);
74
+ hash = mergeRound64(hash, v3);
75
+ hash = mergeRound64(hash, v4);
76
+ } else {
77
+ hash = toUint64(seed + PRIME5);
78
+ }
79
+
80
+ hash = toUint64(hash + BigInt(length));
81
+
82
+ while (offset <= length - 8) {
83
+ const lane = round64(0n, readUint64LE(view, offset));
84
+ hash ^= lane;
85
+ hash = toUint64(rotateLeft64(hash, 27) * PRIME1 + PRIME4);
86
+ offset += 8;
87
+ }
88
+
89
+ if (offset <= length - 4) {
90
+ hash ^= toUint64(readUint32LE(view, offset) * PRIME1);
91
+ hash = toUint64(rotateLeft64(hash, 23) * PRIME2 + PRIME3);
92
+ offset += 4;
93
+ }
94
+
95
+ while (offset < length) {
96
+ hash ^= toUint64(BigInt(view.getUint8(offset)) * PRIME5);
97
+ hash = toUint64(rotateLeft64(hash, 11) * PRIME1);
98
+ offset += 1;
99
+ }
100
+
101
+ return avalanche64(hash);
102
+ }
103
+
104
+ export function computeNativeStyleCch(serializedBody: string): string {
105
+ const hash = xxHash64(encoder.encode(serializedBody), CCH_SEED);
106
+ return (hash & CCH_MASK).toString(16).padStart(5, "0");
107
+ }
108
+
109
+ export function replaceNativeStyleCch(serializedBody: string): string {
110
+ const sentinel = `${CCH_FIELD_PREFIX}${CCH_PLACEHOLDER}`;
111
+ const fieldIndex = serializedBody.indexOf(sentinel);
112
+ if (fieldIndex === -1) {
113
+ return serializedBody;
114
+ }
115
+
116
+ const valueStart = fieldIndex + CCH_FIELD_PREFIX.length;
117
+ const valueEnd = valueStart + CCH_PLACEHOLDER.length;
118
+ const cch = computeNativeStyleCch(serializedBody);
119
+ return `${serializedBody.slice(0, valueStart)}${cch}${serializedBody.slice(valueEnd)}`;
120
+ }
@@ -102,15 +102,23 @@ describe("refresh-lock", () => {
102
102
  const first = await acquireRefreshLock("acc-4");
103
103
  expect(first.acquired).toBe(true);
104
104
  const firstLockPath = first.lockPath!;
105
+ const originalLockInode = first.lockInode;
106
+ expect(originalLockInode).not.toBeNull();
105
107
 
106
- // Replace lock file with a new inode that reuses owner text.
107
- await fs.unlink(firstLockPath);
108
+ // Keep the same owner text but pass a deliberately mismatched inode. This
109
+ // tests the safety check directly without relying on filesystem-specific
110
+ // inode allocation behavior, which can aggressively recycle inode numbers
111
+ // on Linux CI runners.
108
112
  await fs.writeFile(firstLockPath, JSON.stringify({ owner: first.owner, createdAt: Date.now() }), {
109
113
  encoding: "utf-8",
110
114
  mode: 0o600,
111
115
  });
112
116
 
113
- await releaseRefreshLock(first);
117
+ await releaseRefreshLock({
118
+ lockPath: firstLockPath,
119
+ owner: first.owner,
120
+ lockInode: originalLockInode! + 1n,
121
+ });
114
122
 
115
123
  await expect(fs.stat(firstLockPath)).resolves.toBeTruthy();
116
124
 
@@ -20,7 +20,7 @@ export interface RefreshLockResult {
20
20
  acquired: boolean;
21
21
  lockPath: string | null;
22
22
  owner: string | null;
23
- lockInode: number | null;
23
+ lockInode: bigint | null;
24
24
  }
25
25
 
26
26
  export interface AcquireLockOptions {
@@ -51,7 +51,7 @@ export async function acquireRefreshLock(
51
51
  const handle = await fs.open(lockPath, "wx", 0o600);
52
52
  try {
53
53
  await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now(), owner }), "utf-8");
54
- const stat = await handle.stat();
54
+ const stat = await handle.stat({ bigint: true });
55
55
  return { acquired: true, lockPath, owner, lockInode: stat.ino };
56
56
  } finally {
57
57
  await handle.close();
@@ -63,8 +63,8 @@ export async function acquireRefreshLock(
63
63
  }
64
64
 
65
65
  try {
66
- const stat = await fs.stat(lockPath);
67
- if (Date.now() - stat.mtimeMs > staleMs) {
66
+ const stat = await fs.stat(lockPath, { bigint: true });
67
+ if (Date.now() - Number(stat.mtimeMs) > staleMs) {
68
68
  await fs.unlink(lockPath);
69
69
  continue;
70
70
  }
@@ -86,7 +86,7 @@ export type ReleaseLockInput =
86
86
  | {
87
87
  lockPath: string | null;
88
88
  owner?: string | null;
89
- lockInode?: number | null;
89
+ lockInode?: bigint | null;
90
90
  }
91
91
  | string
92
92
  | null;
@@ -97,7 +97,7 @@ export type ReleaseLockInput =
97
97
  export async function releaseRefreshLock(lock: ReleaseLockInput): Promise<void> {
98
98
  const lockPath = typeof lock === "string" || lock === null ? lock : lock.lockPath;
99
99
  const owner = typeof lock === "object" && lock ? lock.owner || null : null;
100
- const lockInode = typeof lock === "object" && lock ? lock.lockInode || null : null;
100
+ const lockInode = typeof lock === "object" && lock ? (lock.lockInode ?? null) : null;
101
101
 
102
102
  if (!lockPath) return;
103
103
 
@@ -112,7 +112,7 @@ export async function releaseRefreshLock(lock: ReleaseLockInput): Promise<void>
112
112
  }
113
113
 
114
114
  if (lockInode) {
115
- const stat = await fs.stat(lockPath);
115
+ const stat = await fs.stat(lockPath, { bigint: true });
116
116
  if (stat.ino !== lockInode) {
117
117
  return;
118
118
  }
@@ -126,21 +126,9 @@ describe("transformRequestBody - body cloning for retries", () => {
126
126
  tools: [{ name: "read_file", description: "Read a file" }],
127
127
  });
128
128
 
129
- // The cch billing hash mixes Date.now() into its input (src/headers/billing.ts)
130
- // to mimic CC's per-request attestation. Freeze the clock so the two calls
131
- // produce byte-identical output and this test stays about clone-safety, not
132
- // about an accidental millisecond collision. Without this, the idempotency
133
- // assertion flakes whenever the two calls cross a millisecond boundary under
134
- // load (husky pre-push, CI workers, etc.).
135
- vi.useFakeTimers();
136
- vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
137
- try {
138
- const result1 = transformRequestBody(originalBody, mockSignature, mockRuntime);
139
- const result2 = transformRequestBody(originalBody, mockSignature, mockRuntime);
140
- expect(result1).toBe(result2);
141
- } finally {
142
- vi.useRealTimers();
143
- }
129
+ const result1 = transformRequestBody(originalBody, mockSignature, mockRuntime);
130
+ const result2 = transformRequestBody(originalBody, mockSignature, mockRuntime);
131
+ expect(result1).toBe(result2);
144
132
 
145
133
  const parsedOriginal = JSON.parse(originalBody);
146
134
  expect(parsedOriginal.tools[0].name).toBe("read_file");
@@ -156,22 +144,12 @@ describe("transformRequestBody - body cloning for retries", () => {
156
144
  messages: [{ role: "user", content: "test" }],
157
145
  });
158
146
 
159
- // Same Date.now()-in-cch flake as the clone-safety test above. Freeze the
160
- // clock so two transformRequestBody calls on the same body produce
161
- // byte-identical output. See src/headers/billing.ts:59 for why the hash
162
- // is time-mixed, and the clone-safety test above for the full rationale.
163
- vi.useFakeTimers();
164
- vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
165
- try {
166
- const result1 = transformRequestBody(body, mockSignature, mockRuntime);
167
- expect(result1).toBeDefined();
168
-
169
- const result2 = transformRequestBody(body, mockSignature, mockRuntime);
170
- expect(result2).toBeDefined();
171
- expect(result1).toBe(result2);
172
- } finally {
173
- vi.useRealTimers();
174
- }
147
+ const result1 = transformRequestBody(body, mockSignature, mockRuntime);
148
+ expect(result1).toBeDefined();
149
+
150
+ const result2 = transformRequestBody(body, mockSignature, mockRuntime);
151
+ expect(result2).toBeDefined();
152
+ expect(result1).toBe(result2);
175
153
  });
176
154
  });
177
155
 
@@ -3,6 +3,7 @@
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
5
  import { CLAUDE_CODE_IDENTITY_STRING, KNOWN_IDENTITY_STRINGS } from "../constants.js";
6
+ import { replaceNativeStyleCch } from "../headers/cch.js";
6
7
  import { buildSystemPromptBlocks } from "../system-prompt/builder.js";
7
8
  import { normalizeSystemTextBlocks } from "../system-prompt/normalize.js";
8
9
  import { normalizeThinkingBlock } from "../thinking.js";
@@ -319,7 +320,7 @@ export function transformRequestBody(
319
320
  return msg;
320
321
  });
321
322
  }
322
- return JSON.stringify(parsed);
323
+ return replaceNativeStyleCch(JSON.stringify(parsed));
323
324
  } catch (err) {
324
325
  if (err instanceof SyntaxError) {
325
326
  debugLog?.("body parse failed:", err.message);
@@ -29,7 +29,8 @@
29
29
  // - CC tool naming conventions can evolve independently from this file. Current
30
30
  // plugin-specific tool prefix notes live in body/request docs, not here.
31
31
  //
32
- // See src/headers/billing.ts for billing-specific gaps and attestation notes.
32
+ // See src/headers/billing.ts for version-suffix derivation and src/headers/cch.ts
33
+ // for placeholder replacement and native-style cch computation.
33
34
  // ===========================================================================
34
35
 
35
36
  import {