opencode-anthropic-fix 0.0.32 → 0.0.34
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/index.mjs +159 -55
- package/lib/accounts.mjs +1 -0
- package/lib/cc-credentials.mjs +2 -2
- package/lib/cc-credentials.test.mjs +29 -15
- package/lib/config.mjs +8 -1
- package/lib/config.test.mjs +3 -3
- package/lib/oauth.mjs +1 -1
- package/lib/storage.mjs +4 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -821,7 +821,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
821
821
|
},
|
|
822
822
|
"adaptive-context": () => {
|
|
823
823
|
const enabled = value === "on" || value === "true" || value === "1";
|
|
824
|
-
saveConfig({ adaptive_context: { enabled } });
|
|
824
|
+
saveConfig({ adaptive_context: { ...config.adaptive_context, enabled } });
|
|
825
825
|
if (!config.adaptive_context)
|
|
826
826
|
config.adaptive_context = {
|
|
827
827
|
enabled: false,
|
|
@@ -833,6 +833,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
833
833
|
if (!enabled) {
|
|
834
834
|
adaptiveContextState.active = false;
|
|
835
835
|
adaptiveContextState.escalatedByError = false;
|
|
836
|
+
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
836
837
|
}
|
|
837
838
|
},
|
|
838
839
|
};
|
|
@@ -1825,6 +1826,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1825
1826
|
let serviceWideRetryCount = 0; // Track 529/503 retries (max 2 per RE doc §5.5)
|
|
1826
1827
|
let shouldRetryCount = 0; // Track x-should-retry forced retries (cap at 3)
|
|
1827
1828
|
let consecutive529Count = 0;
|
|
1829
|
+
let _adaptiveDecisionMade = false; // Ensure adaptive context decision is made only once per logical request
|
|
1830
|
+
let _adaptiveOverrideForRequest; // Cached adaptive override for all retry attempts
|
|
1828
1831
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1829
1832
|
// Select account — use pinned account on first attempt if available
|
|
1830
1833
|
const account =
|
|
@@ -1974,30 +1977,34 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1974
1977
|
);
|
|
1975
1978
|
const _reqProvider = detectProvider(requestUrl);
|
|
1976
1979
|
|
|
1977
|
-
// --- Adaptive 1M context decision ---
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1980
|
+
// --- Adaptive 1M context decision (once per logical request, not per retry) ---
|
|
1981
|
+
if (!_adaptiveDecisionMade) {
|
|
1982
|
+
_adaptiveDecisionMade = true;
|
|
1983
|
+
const _prevAdaptiveState = adaptiveContextState.active;
|
|
1984
|
+
const _use1MContext = resolveAdaptiveContext(
|
|
1985
|
+
requestInit.body,
|
|
1986
|
+
_reqModel,
|
|
1987
|
+
config.adaptive_context || {
|
|
1988
|
+
enabled: true,
|
|
1989
|
+
escalation_threshold: 150_000,
|
|
1990
|
+
deescalation_threshold: 100_000,
|
|
1991
|
+
},
|
|
1992
|
+
);
|
|
1993
|
+
// Emit visual cue on state transitions (only when adaptive mode is on)
|
|
1994
|
+
if (config.adaptive_context?.enabled && _prevAdaptiveState !== adaptiveContextState.active) {
|
|
1995
|
+
const label = adaptiveContextState.active ? "1M context ON" : "1M context OFF";
|
|
1996
|
+
const variant = adaptiveContextState.active ? "info" : "success";
|
|
1997
|
+
const est = estimatePromptTokens(requestInit.body);
|
|
1998
|
+
toast(`⬡ ${label} (est. ${Math.round(est / 1000)}K tokens)`, variant, {
|
|
1999
|
+
debounceKey: "adaptive-ctx",
|
|
2000
|
+
}).catch(() => {});
|
|
2001
|
+
}
|
|
2002
|
+
_adaptiveOverrideForRequest = config.adaptive_context?.enabled
|
|
2003
|
+
? { use1MContext: _use1MContext }
|
|
2004
|
+
: undefined;
|
|
1996
2005
|
}
|
|
1997
2006
|
|
|
1998
|
-
const _adaptiveOverride =
|
|
1999
|
-
? { use1MContext: _use1MContext }
|
|
2000
|
-
: undefined;
|
|
2007
|
+
const _adaptiveOverride = _adaptiveOverrideForRequest;
|
|
2001
2008
|
|
|
2002
2009
|
const computedBetaHeader = buildAnthropicBetaHeader(
|
|
2003
2010
|
"",
|
|
@@ -2207,26 +2214,34 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2207
2214
|
try {
|
|
2208
2215
|
const parsedBody = JSON.parse(body);
|
|
2209
2216
|
if (Array.isArray(parsedBody.messages) && parsedBody.messages.length > 4) {
|
|
2210
|
-
// Keep first 2 messages (initial context) and last
|
|
2217
|
+
// Keep first 2 messages (initial context) and last 2 messages (recent work).
|
|
2211
2218
|
// Ensure the trimmed array never ends with an assistant message (prefill),
|
|
2212
2219
|
// which would cause "does not support assistant message prefill" errors.
|
|
2213
|
-
let tailCount = 2;
|
|
2214
2220
|
const msgs = parsedBody.messages;
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
msgs.length - tailCount > 0
|
|
2220
|
-
) {
|
|
2221
|
-
tailCount++;
|
|
2222
|
-
}
|
|
2223
|
-
const tail = msgs.slice(-tailCount);
|
|
2224
|
-
// If tail still starts/ends badly, force a user wrapper
|
|
2221
|
+
const tail = msgs.slice(-2);
|
|
2222
|
+
// If tail ends with assistant, append a user message to fix the prefill issue.
|
|
2223
|
+
// Check if the assistant's last content block is tool_use — if so, synthesize
|
|
2224
|
+
// a tool_result instead of bare "Continue." to respect the tool protocol.
|
|
2225
2225
|
if (tail.length > 0 && tail[tail.length - 1]?.role === "assistant") {
|
|
2226
|
-
tail.
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2226
|
+
const lastAssistant = tail[tail.length - 1];
|
|
2227
|
+
const lastContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : [];
|
|
2228
|
+
const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
|
|
2229
|
+
if (toolUseBlocks.length > 0) {
|
|
2230
|
+
// Synthesize tool_result for each pending tool_use
|
|
2231
|
+
tail.push({
|
|
2232
|
+
role: "user",
|
|
2233
|
+
content: toolUseBlocks.map((tu) => ({
|
|
2234
|
+
type: "tool_result",
|
|
2235
|
+
tool_use_id: tu.id,
|
|
2236
|
+
content: "[Context trimmed — previous result unavailable]",
|
|
2237
|
+
})),
|
|
2238
|
+
});
|
|
2239
|
+
} else {
|
|
2240
|
+
tail.push({
|
|
2241
|
+
role: "user",
|
|
2242
|
+
content: [{ type: "text", text: "Continue." }],
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2230
2245
|
}
|
|
2231
2246
|
const trimmed = [
|
|
2232
2247
|
...msgs.slice(0, 2),
|
|
@@ -2635,6 +2650,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2635
2650
|
],
|
|
2636
2651
|
},
|
|
2637
2652
|
"experimental.session.compacting": async (input, output) => {
|
|
2653
|
+
// Reset adaptive context state on session compaction (new conversation boundary).
|
|
2654
|
+
// This prevents sticky escalation from leaking across conversations.
|
|
2655
|
+
adaptiveContextState.active = false;
|
|
2656
|
+
adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
|
|
2657
|
+
adaptiveContextState.escalatedByError = false;
|
|
2658
|
+
|
|
2638
2659
|
// Inject Anthropic-specific context into compaction
|
|
2639
2660
|
if (!accountManager) return;
|
|
2640
2661
|
const account = accountManager.getCurrentAccount();
|
|
@@ -2706,9 +2727,50 @@ const adaptiveContextState = {
|
|
|
2706
2727
|
*/
|
|
2707
2728
|
function estimatePromptTokens(bodyString) {
|
|
2708
2729
|
if (!bodyString || typeof bodyString !== "string") return 0;
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2730
|
+
try {
|
|
2731
|
+
const parsed = JSON.parse(bodyString);
|
|
2732
|
+
let charCount = 0;
|
|
2733
|
+
|
|
2734
|
+
// Count system prompt text
|
|
2735
|
+
if (Array.isArray(parsed.system)) {
|
|
2736
|
+
for (const block of parsed.system) {
|
|
2737
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
2738
|
+
charCount += block.text.length;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
} else if (typeof parsed.system === "string") {
|
|
2742
|
+
charCount += parsed.system.length;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// Count messages content (text blocks, tool results) — skip tool definitions
|
|
2746
|
+
if (Array.isArray(parsed.messages)) {
|
|
2747
|
+
for (const msg of parsed.messages) {
|
|
2748
|
+
if (typeof msg.content === "string") {
|
|
2749
|
+
charCount += msg.content.length;
|
|
2750
|
+
} else if (Array.isArray(msg.content)) {
|
|
2751
|
+
for (const block of msg.content) {
|
|
2752
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
2753
|
+
charCount += block.text.length;
|
|
2754
|
+
} else if (block.type === "tool_result" && typeof block.content === "string") {
|
|
2755
|
+
charCount += block.content.length;
|
|
2756
|
+
} else if (block.type === "tool_use") {
|
|
2757
|
+
// Count serialized input as tokens
|
|
2758
|
+
charCount += JSON.stringify(block.input || {}).length;
|
|
2759
|
+
} else if (block.type === "image" || block.type === "image_url") {
|
|
2760
|
+
// Images: ~2000 tokens per image (Anthropic tile-based counting)
|
|
2761
|
+
charCount += 8000; // 2000 tokens * 4 chars/token
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
// 4 chars/token heuristic for text content (reasonable for English + code + JSON)
|
|
2769
|
+
return Math.ceil(charCount / 4);
|
|
2770
|
+
} catch {
|
|
2771
|
+
// Fallback: raw body length / 4 if JSON parsing fails
|
|
2772
|
+
return Math.ceil(bodyString.length / 4);
|
|
2773
|
+
}
|
|
2712
2774
|
}
|
|
2713
2775
|
|
|
2714
2776
|
/**
|
|
@@ -2735,6 +2797,11 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
|
|
|
2735
2797
|
return hasOneMillionContext(model);
|
|
2736
2798
|
}
|
|
2737
2799
|
|
|
2800
|
+
// If experimental betas are disabled, context-1m will be stripped anyway — skip adaptive logic
|
|
2801
|
+
if (isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) {
|
|
2802
|
+
return false;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2738
2805
|
// Model must be eligible for 1M context at all
|
|
2739
2806
|
if (!hasOneMillionContext(model)) {
|
|
2740
2807
|
return false;
|
|
@@ -2762,6 +2829,10 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
|
|
|
2762
2829
|
return true;
|
|
2763
2830
|
} else {
|
|
2764
2831
|
// Currently inactive — consider escalation
|
|
2832
|
+
// Symmetric hysteresis: require at least 2 turns before re-escalation too
|
|
2833
|
+
if (turnsSinceTransition < 2 && adaptiveContextState.lastTransitionTurn > 0) {
|
|
2834
|
+
return false;
|
|
2835
|
+
}
|
|
2765
2836
|
if (estimatedTokens > adaptiveConfig.escalation_threshold) {
|
|
2766
2837
|
// Escalate
|
|
2767
2838
|
adaptiveContextState.active = true;
|
|
@@ -2952,7 +3023,7 @@ class TelemetryEmitter {
|
|
|
2952
3023
|
node_version: process.version,
|
|
2953
3024
|
terminal: process.env.TERM_PROGRAM || process.env.TERM || "",
|
|
2954
3025
|
version: this.#cliVersion,
|
|
2955
|
-
build_time:
|
|
3026
|
+
build_time: CLAUDE_CODE_BUILD_TIME,
|
|
2956
3027
|
is_ci: false,
|
|
2957
3028
|
},
|
|
2958
3029
|
...extras,
|
|
@@ -3062,16 +3133,18 @@ process.once("beforeExit", () => {
|
|
|
3062
3133
|
// Request building helpers (extracted from original fetch interceptor)
|
|
3063
3134
|
// ---------------------------------------------------------------------------
|
|
3064
3135
|
|
|
3065
|
-
const FALLBACK_CLAUDE_CLI_VERSION = "2.1.
|
|
3136
|
+
const FALLBACK_CLAUDE_CLI_VERSION = "2.1.84";
|
|
3066
3137
|
const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
|
|
3138
|
+
const CLAUDE_CODE_BUILD_TIME = "2026-03-25T23:48:41Z";
|
|
3067
3139
|
|
|
3068
|
-
// The @anthropic-ai/sdk version bundled with Claude Code v2.1.
|
|
3140
|
+
// The @anthropic-ai/sdk version bundled with Claude Code v2.1.84.
|
|
3069
3141
|
// This is distinct from the CLI version and goes in X-Stainless-Package-Version.
|
|
3070
|
-
// Verified by extracting VERSION="0.208.0" from the bundled cli.js of v2.1.
|
|
3142
|
+
// Verified by extracting VERSION="0.208.0" from the bundled cli.js of v2.1.84.
|
|
3071
3143
|
const ANTHROPIC_SDK_VERSION = "0.208.0";
|
|
3072
3144
|
|
|
3073
3145
|
// Map of CLI version → bundled SDK version (update when CLI version changes)
|
|
3074
3146
|
const CLI_TO_SDK_VERSION = new Map([
|
|
3147
|
+
["2.1.84", "0.208.0"],
|
|
3075
3148
|
["2.1.83", "0.208.0"],
|
|
3076
3149
|
["2.1.81", "0.208.0"],
|
|
3077
3150
|
["2.1.80", "0.208.0"],
|
|
@@ -3522,9 +3595,9 @@ function buildRequestMetadata(input) {
|
|
|
3522
3595
|
|
|
3523
3596
|
/**
|
|
3524
3597
|
* Build the billing header block for Claude Code system prompt injection.
|
|
3525
|
-
* Claude Code v2.1.
|
|
3598
|
+
* Claude Code v2.1.84: cch computed via NP1() from first user message, cc_version includes model ID.
|
|
3526
3599
|
*
|
|
3527
|
-
* @param {string} version - CLI version (e.g., "2.1.
|
|
3600
|
+
* @param {string} version - CLI version (e.g., "2.1.84")
|
|
3528
3601
|
* @param {string} [modelId] - Model ID to append (e.g., "claude-opus-4-6")
|
|
3529
3602
|
* @param {string} [firstUserMessage] - First user message text for cch hash computation
|
|
3530
3603
|
* @returns {string}
|
|
@@ -3849,7 +3922,7 @@ function buildAnthropicBetaHeader(
|
|
|
3849
3922
|
const haiku = isHaikuModel(model);
|
|
3850
3923
|
const isRoundRobin = strategy === "round-robin";
|
|
3851
3924
|
|
|
3852
|
-
// === ALWAYS-ON BETAS (Claude Code v2.1.
|
|
3925
|
+
// === ALWAYS-ON BETAS (Claude Code v2.1.84 base set) ===
|
|
3853
3926
|
// These are ALWAYS included regardless of env vars or feature flags.
|
|
3854
3927
|
if (!haiku) {
|
|
3855
3928
|
betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
|
|
@@ -4159,6 +4232,9 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
|
|
|
4159
4232
|
if (isTruthyEnv(process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION)) {
|
|
4160
4233
|
requestHeaders.set("x-anthropic-additional-protection", "true");
|
|
4161
4234
|
}
|
|
4235
|
+
|
|
4236
|
+
// Claude Code v2.1.84: x-client-request-id — unique UUID per request for debugging timeouts.
|
|
4237
|
+
requestHeaders.set("x-client-request-id", randomUUID());
|
|
4162
4238
|
}
|
|
4163
4239
|
requestHeaders.delete("x-api-key");
|
|
4164
4240
|
|
|
@@ -4211,6 +4287,15 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
|
4211
4287
|
if (thinkingActive) {
|
|
4212
4288
|
// Anthropic API rejects temperature when thinking is enabled
|
|
4213
4289
|
delete parsed.temperature;
|
|
4290
|
+
|
|
4291
|
+
// Claude Code v2.1.84: inject context_management body field when thinking
|
|
4292
|
+
// is active and context-management beta is in use. This tells the API how
|
|
4293
|
+
// to handle thinking blocks during context management operations.
|
|
4294
|
+
if (!parsed.context_management) {
|
|
4295
|
+
parsed.context_management = {
|
|
4296
|
+
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
|
|
4297
|
+
};
|
|
4298
|
+
}
|
|
4214
4299
|
} else {
|
|
4215
4300
|
// Claude Code always uses temperature: 1 for non-thinking requests (RE doc §5.2, never 0)
|
|
4216
4301
|
parsed.temperature = 1;
|
|
@@ -4293,10 +4378,26 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
|
4293
4378
|
if (Array.isArray(parsed.messages) && parsed.messages.length > 0) {
|
|
4294
4379
|
const lastMsg = parsed.messages[parsed.messages.length - 1];
|
|
4295
4380
|
if (lastMsg && lastMsg.role === "assistant") {
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4381
|
+
// Check if the assistant message contains tool_use blocks — if so,
|
|
4382
|
+
// synthesize tool_result responses instead of bare "Continue." to
|
|
4383
|
+
// respect the Anthropic tool protocol.
|
|
4384
|
+
const lastContent = Array.isArray(lastMsg.content) ? lastMsg.content : [];
|
|
4385
|
+
const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
|
|
4386
|
+
if (toolUseBlocks.length > 0) {
|
|
4387
|
+
parsed.messages.push({
|
|
4388
|
+
role: "user",
|
|
4389
|
+
content: toolUseBlocks.map((tu) => ({
|
|
4390
|
+
type: "tool_result",
|
|
4391
|
+
tool_use_id: tu.id,
|
|
4392
|
+
content: "[Result unavailable — conversation was restructured]",
|
|
4393
|
+
})),
|
|
4394
|
+
});
|
|
4395
|
+
} else {
|
|
4396
|
+
parsed.messages.push({
|
|
4397
|
+
role: "user",
|
|
4398
|
+
content: [{ type: "text", text: "Continue." }],
|
|
4399
|
+
});
|
|
4400
|
+
}
|
|
4300
4401
|
}
|
|
4301
4402
|
}
|
|
4302
4403
|
|
|
@@ -4751,9 +4852,12 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
|
|
|
4751
4852
|
const { readCCCredentials } = await import("./lib/cc-credentials.mjs");
|
|
4752
4853
|
const ccCreds = readCCCredentials();
|
|
4753
4854
|
const match = ccCreds.find((c) => c.refreshToken === account.refreshToken);
|
|
4754
|
-
|
|
4855
|
+
// Accept CC credential if:
|
|
4856
|
+
// - expiresAt is in the future (normal case), OR
|
|
4857
|
+
// - expiresAt is 0/missing (CC didn't provide expiry — trust the token, let API 401 if stale)
|
|
4858
|
+
if (match && (match.expiresAt === 0 || match.expiresAt > Date.now())) {
|
|
4755
4859
|
account.access = match.accessToken;
|
|
4756
|
-
account.expires = match.expiresAt;
|
|
4860
|
+
account.expires = match.expiresAt || Date.now() + 3600_000; // default 1h if unknown
|
|
4757
4861
|
markTokenStateUpdated(account);
|
|
4758
4862
|
if (onTokensUpdated) {
|
|
4759
4863
|
try {
|
package/lib/accounts.mjs
CHANGED
|
@@ -807,6 +807,7 @@ export class AccountManager {
|
|
|
807
807
|
lastFailureTime: acc.lastFailureTime,
|
|
808
808
|
lastSwitchReason: acc.lastSwitchReason || existing?.lastSwitchReason || "initial",
|
|
809
809
|
stats: acc.stats ?? existing?.stats ?? createDefaultStats(),
|
|
810
|
+
source: acc.source || existing?.source || "oauth",
|
|
810
811
|
};
|
|
811
812
|
});
|
|
812
813
|
|
package/lib/cc-credentials.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* @module cc-credentials
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { execSync } from "node:child_process";
|
|
19
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
20
20
|
import { readFileSync, existsSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { homedir } from "node:os";
|
|
@@ -77,7 +77,7 @@ function listKeychainServices() {
|
|
|
77
77
|
*/
|
|
78
78
|
function readKeychainService(service) {
|
|
79
79
|
try {
|
|
80
|
-
const raw =
|
|
80
|
+
const raw = execFileSync("security", ["find-generic-password", "-s", service, "-w"], {
|
|
81
81
|
timeout: 5_000,
|
|
82
82
|
encoding: "utf-8",
|
|
83
83
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
|
|
13
13
|
vi.mock("node:child_process", () => ({
|
|
14
14
|
execSync: vi.fn(),
|
|
15
|
+
execFileSync: vi.fn(),
|
|
15
16
|
}));
|
|
16
17
|
|
|
17
18
|
vi.mock("node:fs", () => ({
|
|
@@ -23,7 +24,7 @@ vi.mock("node:os", () => ({
|
|
|
23
24
|
homedir: vi.fn(() => "/home/testuser"),
|
|
24
25
|
}));
|
|
25
26
|
|
|
26
|
-
import { execSync } from "node:child_process";
|
|
27
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
27
28
|
import { readFileSync, existsSync } from "node:fs";
|
|
28
29
|
import { homedir } from "node:os";
|
|
29
30
|
|
|
@@ -149,10 +150,14 @@ describe("readCCCredentialsFromKeychain", () => {
|
|
|
149
150
|
it("reads single keychain service on macOS", () => {
|
|
150
151
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
151
152
|
|
|
153
|
+
// dump-keychain uses execSync, find-generic-password uses execFileSync
|
|
152
154
|
execSync.mockImplementation((cmd) => {
|
|
153
155
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
throw new Error("unexpected execSync cmd");
|
|
157
|
+
});
|
|
158
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
159
|
+
if (args && args.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
|
|
160
|
+
throw new Error("unexpected execFileSync cmd");
|
|
156
161
|
});
|
|
157
162
|
|
|
158
163
|
const result = readCCCredentialsFromKeychain();
|
|
@@ -168,12 +173,15 @@ describe("readCCCredentialsFromKeychain", () => {
|
|
|
168
173
|
let callIdx = 0;
|
|
169
174
|
execSync.mockImplementation((cmd) => {
|
|
170
175
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_MULTI;
|
|
171
|
-
|
|
176
|
+
throw new Error("unexpected execSync cmd");
|
|
177
|
+
});
|
|
178
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
179
|
+
if (args && args.includes("find-generic-password")) {
|
|
172
180
|
callIdx++;
|
|
173
181
|
const cred = callIdx === 1 ? VALID_CRED : VALID_CRED_2;
|
|
174
182
|
return JSON.stringify(cred);
|
|
175
183
|
}
|
|
176
|
-
throw new Error("unexpected cmd");
|
|
184
|
+
throw new Error("unexpected execFileSync cmd");
|
|
177
185
|
});
|
|
178
186
|
|
|
179
187
|
const result = readCCCredentialsFromKeychain();
|
|
@@ -197,13 +205,13 @@ describe("readCCCredentialsFromKeychain", () => {
|
|
|
197
205
|
|
|
198
206
|
execSync.mockImplementation((cmd) => {
|
|
199
207
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
200
|
-
if (cmd.includes("find-generic-password")) {
|
|
201
|
-
const err = new Error("not found");
|
|
202
|
-
err.status = 44;
|
|
203
|
-
throw err;
|
|
204
|
-
}
|
|
205
208
|
throw new Error("unexpected");
|
|
206
209
|
});
|
|
210
|
+
execFileSync.mockImplementation(() => {
|
|
211
|
+
const err = new Error("not found");
|
|
212
|
+
err.status = 44;
|
|
213
|
+
throw err;
|
|
214
|
+
});
|
|
207
215
|
|
|
208
216
|
expect(readCCCredentialsFromKeychain()).toEqual([]);
|
|
209
217
|
});
|
|
@@ -317,11 +325,14 @@ describe("readCCCredentials", () => {
|
|
|
317
325
|
it("combines keychain and file sources on macOS", () => {
|
|
318
326
|
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
319
327
|
|
|
320
|
-
// Keychain
|
|
328
|
+
// Keychain: dump-keychain via execSync, find-generic-password via execFileSync
|
|
321
329
|
execSync.mockImplementation((cmd) => {
|
|
322
330
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
323
|
-
|
|
324
|
-
|
|
331
|
+
throw new Error("unexpected execSync cmd");
|
|
332
|
+
});
|
|
333
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
334
|
+
if (args && args.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
|
|
335
|
+
throw new Error("unexpected execFileSync cmd");
|
|
325
336
|
});
|
|
326
337
|
|
|
327
338
|
// File returns a different credential
|
|
@@ -343,8 +354,11 @@ describe("readCCCredentials", () => {
|
|
|
343
354
|
|
|
344
355
|
execSync.mockImplementation((cmd) => {
|
|
345
356
|
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
346
|
-
|
|
347
|
-
|
|
357
|
+
throw new Error("unexpected execSync cmd");
|
|
358
|
+
});
|
|
359
|
+
execFileSync.mockImplementation((_bin, args) => {
|
|
360
|
+
if (args && args.includes("find-generic-password")) return JSON.stringify(keychainCred);
|
|
361
|
+
throw new Error("unexpected execFileSync cmd");
|
|
348
362
|
});
|
|
349
363
|
|
|
350
364
|
existsSync.mockReturnValue(true);
|
package/lib/config.mjs
CHANGED
|
@@ -150,7 +150,7 @@ export const DEFAULT_CONFIG = {
|
|
|
150
150
|
prefer_over_oauth: false,
|
|
151
151
|
},
|
|
152
152
|
adaptive_context: {
|
|
153
|
-
enabled:
|
|
153
|
+
enabled: true,
|
|
154
154
|
escalation_threshold: 150_000,
|
|
155
155
|
deescalation_threshold: 100_000,
|
|
156
156
|
},
|
|
@@ -417,6 +417,13 @@ function validateConfig(raw) {
|
|
|
417
417
|
DEFAULT_CONFIG.adaptive_context.deescalation_threshold,
|
|
418
418
|
),
|
|
419
419
|
};
|
|
420
|
+
// Cross-validate: deescalation must be strictly less than escalation to maintain hysteresis gap
|
|
421
|
+
if (config.adaptive_context.deescalation_threshold >= config.adaptive_context.escalation_threshold) {
|
|
422
|
+
config.adaptive_context.deescalation_threshold = Math.max(
|
|
423
|
+
20_000,
|
|
424
|
+
config.adaptive_context.escalation_threshold - 50_000,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
420
427
|
}
|
|
421
428
|
|
|
422
429
|
return config;
|
package/lib/config.test.mjs
CHANGED
|
@@ -308,11 +308,11 @@ describe("loadConfig", () => {
|
|
|
308
308
|
|
|
309
309
|
// --- adaptive_context config tests ---
|
|
310
310
|
|
|
311
|
-
it("has adaptive_context defaults (
|
|
311
|
+
it("has adaptive_context defaults (enabled)", () => {
|
|
312
312
|
existsSync.mockReturnValue(false);
|
|
313
313
|
const config = loadConfig();
|
|
314
314
|
expect(config.adaptive_context).toEqual({
|
|
315
|
-
enabled:
|
|
315
|
+
enabled: true,
|
|
316
316
|
escalation_threshold: 150_000,
|
|
317
317
|
deescalation_threshold: 100_000,
|
|
318
318
|
});
|
|
@@ -349,7 +349,7 @@ describe("loadConfig", () => {
|
|
|
349
349
|
existsSync.mockReturnValue(true);
|
|
350
350
|
readFileSync.mockReturnValue(JSON.stringify({ adaptive_context: { enabled: "yes" } }));
|
|
351
351
|
const config = loadConfig();
|
|
352
|
-
expect(config.adaptive_context.enabled).toBe(
|
|
352
|
+
expect(config.adaptive_context.enabled).toBe(true);
|
|
353
353
|
});
|
|
354
354
|
|
|
355
355
|
it("ignores non-number adaptive_context thresholds", () => {
|
package/lib/oauth.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const OAUTH_TOKEN_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/token`;
|
|
|
13
13
|
const OAUTH_REVOKE_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/revoke`;
|
|
14
14
|
// Real Claude Code uses axios 1.13.6 for OAuth calls — axios sets User-Agent
|
|
15
15
|
// automatically and adds Accept header. We must match this fingerprint exactly.
|
|
16
|
-
// CLI version profile: 2.1.
|
|
16
|
+
// CLI version profile: 2.1.84 (for reference; not sent on OAuth calls)
|
|
17
17
|
const OAUTH_AXIOS_VERSION = "1.13.6";
|
|
18
18
|
const OAUTH_USER_AGENT = `axios/${OAUTH_AXIOS_VERSION}`;
|
|
19
19
|
const OAUTH_ACCEPT = "application/json, text/plain, */*";
|
package/lib/storage.mjs
CHANGED
|
@@ -183,6 +183,10 @@ function validateAccount(raw, now) {
|
|
|
183
183
|
typeof acc.consecutiveFailures === "number" ? Math.max(0, Math.floor(acc.consecutiveFailures)) : 0,
|
|
184
184
|
lastFailureTime: typeof acc.lastFailureTime === "number" ? acc.lastFailureTime : null,
|
|
185
185
|
lastSwitchReason: typeof acc.lastSwitchReason === "string" ? acc.lastSwitchReason : undefined,
|
|
186
|
+
source:
|
|
187
|
+
typeof acc.source === "string" && ["oauth", "cc-keychain", "cc-file"].includes(acc.source)
|
|
188
|
+
? acc.source
|
|
189
|
+
: undefined,
|
|
186
190
|
stats: validateStats(acc.stats, now),
|
|
187
191
|
};
|
|
188
192
|
}
|