@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.
- package/dist/opencode-anthropic-auth-plugin.js +142 -29
- package/package.json +3 -1
- package/src/__tests__/cc-comparison.test.ts +1 -1
- package/src/__tests__/cch-drift-checker.test.ts +61 -0
- package/src/__tests__/cch-native-style.test.ts +76 -0
- package/src/__tests__/fingerprint-regression.test.ts +2 -3
- package/src/bun-fetch.test.ts +103 -0
- package/src/bun-fetch.ts +42 -10
- package/src/drift/cch-constants.ts +133 -0
- package/src/headers/billing.ts +6 -33
- package/src/headers/cch.ts +120 -0
- package/src/refresh-lock.test.ts +11 -3
- package/src/refresh-lock.ts +7 -7
- package/src/request/body.history.test.ts +9 -31
- package/src/request/body.ts +2 -1
- package/src/system-prompt/builder.ts +2 -1
|
@@ -3370,7 +3370,7 @@ var init_cli = __esm({
|
|
|
3370
3370
|
});
|
|
3371
3371
|
|
|
3372
3372
|
// src/index.ts
|
|
3373
|
-
import { randomUUID as
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
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
|
-
|
|
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 (
|
|
7708
|
-
console.error(
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
179
|
+
it("contains the native placeholder cch before post-serialization replacement", () => {
|
|
180
180
|
const header = buildAnthropicBillingHeader(CC_VERSION, []);
|
|
181
|
-
expect(header).
|
|
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", () => {
|
package/src/bun-fetch.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
+
}
|
package/src/headers/billing.ts
CHANGED
|
@@ -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
|
|
8
|
-
// with salt "59cf53e54c78"
|
|
9
|
-
//
|
|
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
|
+
}
|
package/src/refresh-lock.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
107
|
-
|
|
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(
|
|
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
|
|
package/src/refresh-lock.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface RefreshLockResult {
|
|
|
20
20
|
acquired: boolean;
|
|
21
21
|
lockPath: string | null;
|
|
22
22
|
owner: string | null;
|
|
23
|
-
lockInode:
|
|
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?:
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
package/src/request/body.ts
CHANGED
|
@@ -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
|
|
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 {
|