opencode-anthropic-fix 0.0.29 → 0.0.31
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 +63 -3
- package/lib/accounts.mjs +92 -0
- package/lib/cc-credentials.mjs +253 -0
- package/lib/cc-credentials.test.mjs +377 -0
- package/lib/config.mjs +35 -0
- package/lib/storage.mjs +1 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -1527,6 +1527,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
1527
1527
|
async function refreshIdleAccount(account) {
|
|
1528
1528
|
if (!accountManager) return;
|
|
1529
1529
|
if (idleRefreshInFlight.has(account.id)) return;
|
|
1530
|
+
// CC-sourced accounts don't use OAuth idle refresh
|
|
1531
|
+
if (account.source === "cc-keychain" || account.source === "cc-file") return;
|
|
1530
1532
|
|
|
1531
1533
|
idleRefreshInFlight.add(account.id);
|
|
1532
1534
|
const attemptedRefreshToken = account.refreshToken;
|
|
@@ -2145,9 +2147,29 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2145
2147
|
try {
|
|
2146
2148
|
const parsedBody = JSON.parse(body);
|
|
2147
2149
|
if (Array.isArray(parsedBody.messages) && parsedBody.messages.length > 4) {
|
|
2148
|
-
// Keep first 2 messages (initial context) and last
|
|
2150
|
+
// Keep first 2 messages (initial context) and last N messages (recent work).
|
|
2151
|
+
// Ensure the trimmed array never ends with an assistant message (prefill),
|
|
2152
|
+
// which would cause "does not support assistant message prefill" errors.
|
|
2153
|
+
let tailCount = 2;
|
|
2154
|
+
const msgs = parsedBody.messages;
|
|
2155
|
+
// Expand tail if it would end with an assistant message
|
|
2156
|
+
while (
|
|
2157
|
+
tailCount < msgs.length - 2 &&
|
|
2158
|
+
msgs[msgs.length - tailCount]?.role !== "user" &&
|
|
2159
|
+
msgs.length - tailCount > 0
|
|
2160
|
+
) {
|
|
2161
|
+
tailCount++;
|
|
2162
|
+
}
|
|
2163
|
+
const tail = msgs.slice(-tailCount);
|
|
2164
|
+
// If tail still starts/ends badly, force a user wrapper
|
|
2165
|
+
if (tail.length > 0 && tail[tail.length - 1]?.role === "assistant") {
|
|
2166
|
+
tail.push({
|
|
2167
|
+
role: "user",
|
|
2168
|
+
content: [{ type: "text", text: "Continue." }],
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2149
2171
|
const trimmed = [
|
|
2150
|
-
...
|
|
2172
|
+
...msgs.slice(0, 2),
|
|
2151
2173
|
{
|
|
2152
2174
|
role: "user",
|
|
2153
2175
|
content: [
|
|
@@ -2157,7 +2179,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
2157
2179
|
},
|
|
2158
2180
|
],
|
|
2159
2181
|
},
|
|
2160
|
-
...
|
|
2182
|
+
...tail,
|
|
2161
2183
|
];
|
|
2162
2184
|
parsedBody.messages = trimmed;
|
|
2163
2185
|
requestInit.body = JSON.stringify(parsedBody);
|
|
@@ -4091,6 +4113,19 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
|
|
|
4091
4113
|
if (fastModeEnabled && parsed.model && (isOpus46Model(parsed.model) || isSonnet46Model(parsed.model))) {
|
|
4092
4114
|
parsed.speed = "fast";
|
|
4093
4115
|
}
|
|
4116
|
+
|
|
4117
|
+
// Guard: ensure messages array never ends with an assistant message.
|
|
4118
|
+
// The API rejects this as "assistant message prefill" when extended thinking is active.
|
|
4119
|
+
if (Array.isArray(parsed.messages) && parsed.messages.length > 0) {
|
|
4120
|
+
const lastMsg = parsed.messages[parsed.messages.length - 1];
|
|
4121
|
+
if (lastMsg && lastMsg.role === "assistant") {
|
|
4122
|
+
parsed.messages.push({
|
|
4123
|
+
role: "user",
|
|
4124
|
+
content: [{ type: "text", text: "Continue." }],
|
|
4125
|
+
});
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4094
4129
|
return JSON.stringify(parsed);
|
|
4095
4130
|
} catch {
|
|
4096
4131
|
// ignore parse errors
|
|
@@ -4535,6 +4570,31 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
|
|
|
4535
4570
|
* @throws {Error} If refresh fails
|
|
4536
4571
|
*/
|
|
4537
4572
|
async function refreshAccountToken(account, client, source = "foreground", { onTokensUpdated } = {}) {
|
|
4573
|
+
// CC-sourced accounts must NEVER enter the OAuth HTTP refresh flow.
|
|
4574
|
+
// Instead, re-read credentials from the CC source. If they're still
|
|
4575
|
+
// expired, let the caller handle it (the account will be skipped).
|
|
4576
|
+
if (account.source === "cc-keychain" || account.source === "cc-file") {
|
|
4577
|
+
const { readCCCredentials } = await import("./lib/cc-credentials.mjs");
|
|
4578
|
+
const ccCreds = readCCCredentials();
|
|
4579
|
+
const match = ccCreds.find((c) => c.refreshToken === account.refreshToken);
|
|
4580
|
+
if (match && match.expiresAt > Date.now()) {
|
|
4581
|
+
account.access = match.accessToken;
|
|
4582
|
+
account.expires = match.expiresAt;
|
|
4583
|
+
markTokenStateUpdated(account);
|
|
4584
|
+
if (onTokensUpdated) {
|
|
4585
|
+
try {
|
|
4586
|
+
await onTokensUpdated();
|
|
4587
|
+
} catch {
|
|
4588
|
+
// best-effort
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
return account.access;
|
|
4592
|
+
}
|
|
4593
|
+
// Could not refresh from CC source – token may be stale.
|
|
4594
|
+
// Throw so the caller falls through to the next account.
|
|
4595
|
+
throw new Error(`CC credential expired (source: ${account.source})`);
|
|
4596
|
+
}
|
|
4597
|
+
|
|
4538
4598
|
const lockResult = await acquireRefreshLock(account.id, {
|
|
4539
4599
|
timeoutMs: 2_000,
|
|
4540
4600
|
backoffMs: 60,
|
package/lib/accounts.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { loadAccounts, saveAccounts, createDefaultStats } from "./storage.mjs";
|
|
3
3
|
import { HealthScoreTracker, TokenBucketTracker, selectAccount } from "./rotation.mjs";
|
|
4
4
|
import { calculateBackoffMs } from "./backoff.mjs";
|
|
5
|
+
import { readCCCredentials } from "./cc-credentials.mjs";
|
|
5
6
|
|
|
6
7
|
/** QA fix M12: hash token fragment instead of leaking raw prefix */
|
|
7
8
|
function hashTokenFragment(token) {
|
|
@@ -20,6 +21,10 @@ function hashTokenFragment(token) {
|
|
|
20
21
|
* @typedef {import('./storage.mjs').AccountStats} AccountStats
|
|
21
22
|
*/
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {'oauth' | 'cc-keychain' | 'cc-file'} AccountSource
|
|
26
|
+
*/
|
|
27
|
+
|
|
23
28
|
/**
|
|
24
29
|
* @typedef {object} ManagedAccount
|
|
25
30
|
* @property {string} id
|
|
@@ -37,6 +42,7 @@ function hashTokenFragment(token) {
|
|
|
37
42
|
* @property {number | null} lastFailureTime
|
|
38
43
|
* @property {string} [lastSwitchReason]
|
|
39
44
|
* @property {AccountStats} stats
|
|
45
|
+
* @property {AccountSource} [source] - Origin of the account. CC accounts must not enter OAuth HTTP refresh.
|
|
40
46
|
*/
|
|
41
47
|
|
|
42
48
|
const MAX_ACCOUNTS = 10;
|
|
@@ -113,6 +119,7 @@ export class AccountManager {
|
|
|
113
119
|
lastFailureTime: acc.lastFailureTime,
|
|
114
120
|
lastSwitchReason: acc.lastSwitchReason,
|
|
115
121
|
stats: acc.stats ?? createDefaultStats(acc.addedAt),
|
|
122
|
+
source: acc.source || "oauth",
|
|
116
123
|
}));
|
|
117
124
|
|
|
118
125
|
manager.#currentIndex =
|
|
@@ -136,6 +143,9 @@ export class AccountManager {
|
|
|
136
143
|
}
|
|
137
144
|
}
|
|
138
145
|
|
|
146
|
+
// Merge CC credentials if enabled
|
|
147
|
+
manager.#mergeCC(config);
|
|
148
|
+
|
|
139
149
|
return manager;
|
|
140
150
|
}
|
|
141
151
|
|
|
@@ -159,14 +169,95 @@ export class AccountManager {
|
|
|
159
169
|
lastFailureTime: null,
|
|
160
170
|
lastSwitchReason: "initial",
|
|
161
171
|
stats: createDefaultStats(now),
|
|
172
|
+
source: "oauth",
|
|
162
173
|
},
|
|
163
174
|
];
|
|
164
175
|
manager.#currentIndex = 0;
|
|
165
176
|
}
|
|
166
177
|
|
|
178
|
+
// Merge CC credentials if enabled
|
|
179
|
+
manager.#mergeCC(config);
|
|
180
|
+
|
|
167
181
|
return manager;
|
|
168
182
|
}
|
|
169
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Merge Claude Code credentials into the account pool.
|
|
186
|
+
* CC accounts are matched by refreshToken to avoid duplicates.
|
|
187
|
+
*
|
|
188
|
+
* @param {AnthropicAuthConfig} config
|
|
189
|
+
*/
|
|
190
|
+
#mergeCC(config) {
|
|
191
|
+
if (!config.cc_credential_reuse?.enabled || !config.cc_credential_reuse?.auto_detect) return;
|
|
192
|
+
|
|
193
|
+
/** @type {import('./cc-credentials.mjs').CCCredential[]} */
|
|
194
|
+
let ccCreds;
|
|
195
|
+
try {
|
|
196
|
+
ccCreds = readCCCredentials();
|
|
197
|
+
} catch {
|
|
198
|
+
return; // best-effort
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!ccCreds.length) return;
|
|
202
|
+
|
|
203
|
+
for (const cc of ccCreds) {
|
|
204
|
+
// Skip if refresh token already known
|
|
205
|
+
const existing = this.#accounts.find((a) => a.refreshToken === cc.refreshToken);
|
|
206
|
+
if (existing) {
|
|
207
|
+
// Update access token if CC's is fresher
|
|
208
|
+
if (cc.expiresAt > (existing.expires || 0)) {
|
|
209
|
+
existing.access = cc.accessToken;
|
|
210
|
+
existing.expires = cc.expiresAt;
|
|
211
|
+
existing.tokenUpdatedAt = Date.now();
|
|
212
|
+
}
|
|
213
|
+
// Ensure source is marked
|
|
214
|
+
if (!existing.source || existing.source === "oauth") {
|
|
215
|
+
existing.source = cc.source;
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (this.#accounts.length >= MAX_ACCOUNTS) break;
|
|
221
|
+
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
/** @type {ManagedAccount} */
|
|
224
|
+
const account = {
|
|
225
|
+
id: `cc:${now}:${hashTokenFragment(cc.refreshToken)}`,
|
|
226
|
+
index: this.#accounts.length,
|
|
227
|
+
email: undefined,
|
|
228
|
+
refreshToken: cc.refreshToken,
|
|
229
|
+
access: cc.accessToken,
|
|
230
|
+
expires: cc.expiresAt,
|
|
231
|
+
tokenUpdatedAt: now,
|
|
232
|
+
addedAt: now,
|
|
233
|
+
lastUsed: 0,
|
|
234
|
+
enabled: true,
|
|
235
|
+
rateLimitResetTimes: {},
|
|
236
|
+
consecutiveFailures: 0,
|
|
237
|
+
lastFailureTime: null,
|
|
238
|
+
lastSwitchReason: "cc-auto-detect",
|
|
239
|
+
stats: createDefaultStats(now),
|
|
240
|
+
source: cc.source,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
if (config.cc_credential_reuse.prefer_over_oauth) {
|
|
244
|
+
this.#accounts.unshift(account);
|
|
245
|
+
// Re-index after unshift
|
|
246
|
+
this.#accounts.forEach((a, i) => {
|
|
247
|
+
a.index = i;
|
|
248
|
+
});
|
|
249
|
+
if (this.#currentIndex >= 0) this.#currentIndex++;
|
|
250
|
+
} else {
|
|
251
|
+
this.#accounts.push(account);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// If this is the first account, make it active
|
|
255
|
+
if (this.#accounts.length === 1) {
|
|
256
|
+
this.#currentIndex = 0;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
170
261
|
/**
|
|
171
262
|
* Get the number of enabled accounts.
|
|
172
263
|
* @returns {number}
|
|
@@ -642,6 +733,7 @@ export class AccountManager {
|
|
|
642
733
|
lastFailureTime: acc.lastFailureTime,
|
|
643
734
|
lastSwitchReason: acc.lastSwitchReason,
|
|
644
735
|
stats: mergedStats,
|
|
736
|
+
source: acc.source,
|
|
645
737
|
};
|
|
646
738
|
}),
|
|
647
739
|
activeIndex: Math.max(0, this.#currentIndex),
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CC (Claude Code) credential reader.
|
|
3
|
+
*
|
|
4
|
+
* Reads OAuth tokens that Claude Code has already obtained and stored,
|
|
5
|
+
* allowing opencode-anthropic-fix to piggy-back on those tokens instead
|
|
6
|
+
* of maintaining its own OAuth flow.
|
|
7
|
+
*
|
|
8
|
+
* Two sources are supported:
|
|
9
|
+
* 1. macOS Keychain – `security find-generic-password` for services
|
|
10
|
+
* matching "Claude Code-credentials*"
|
|
11
|
+
* 2. File-based – `~/.claude/.credentials.json`
|
|
12
|
+
*
|
|
13
|
+
* CC accounts MUST NEVER enter the normal OAuth HTTP token-refresh path
|
|
14
|
+
* because the client_id would mismatch and corrupt CC's stored tokens.
|
|
15
|
+
*
|
|
16
|
+
* @module cc-credentials
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execSync } from "node:child_process";
|
|
20
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {'cc-keychain' | 'cc-file'} CCCredentialSource
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} CCCredential
|
|
30
|
+
* @property {string} accessToken
|
|
31
|
+
* @property {string} refreshToken
|
|
32
|
+
* @property {number} expiresAt - ms-epoch
|
|
33
|
+
* @property {string} [subscriptionType] - e.g. "claude_pro"
|
|
34
|
+
* @property {CCCredentialSource} source
|
|
35
|
+
* @property {string} label - human-readable label for logging/toasts
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Keychain reader (macOS only)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* List all keychain services matching the CC credential pattern.
|
|
44
|
+
* Returns an array of service names (e.g. "Claude Code-credentials-0").
|
|
45
|
+
*
|
|
46
|
+
* @returns {string[]}
|
|
47
|
+
*/
|
|
48
|
+
function listKeychainServices() {
|
|
49
|
+
if (process.platform !== "darwin") return [];
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const dump = execSync("security dump-keychain", {
|
|
53
|
+
timeout: 5_000,
|
|
54
|
+
encoding: "utf-8",
|
|
55
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** @type {string[]} */
|
|
59
|
+
const services = [];
|
|
60
|
+
const regex = /"svce"<blob>="(Claude Code-credentials[^"]*)"/g;
|
|
61
|
+
let match;
|
|
62
|
+
while ((match = regex.exec(dump)) !== null) {
|
|
63
|
+
const svc = match[1];
|
|
64
|
+
if (!services.includes(svc)) services.push(svc);
|
|
65
|
+
}
|
|
66
|
+
return services;
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read a single CC credential from macOS Keychain by service name.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} service
|
|
76
|
+
* @returns {CCCredential | null}
|
|
77
|
+
*/
|
|
78
|
+
function readKeychainService(service) {
|
|
79
|
+
try {
|
|
80
|
+
const raw = execSync(`security find-generic-password -s "${service}" -w`, {
|
|
81
|
+
timeout: 5_000,
|
|
82
|
+
encoding: "utf-8",
|
|
83
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
84
|
+
}).trim();
|
|
85
|
+
|
|
86
|
+
return parseCCCredentialData(raw, "cc-keychain", `keychain:${service}`);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// Exit code 36 = user denied, 44 = not found, 128 = killed
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read all CC credentials stored in the macOS Keychain.
|
|
95
|
+
*
|
|
96
|
+
* @returns {CCCredential[]}
|
|
97
|
+
*/
|
|
98
|
+
export function readCCCredentialsFromKeychain() {
|
|
99
|
+
const services = listKeychainServices();
|
|
100
|
+
/** @type {CCCredential[]} */
|
|
101
|
+
const credentials = [];
|
|
102
|
+
|
|
103
|
+
for (const svc of services) {
|
|
104
|
+
const cred = readKeychainService(svc);
|
|
105
|
+
if (cred) credentials.push(cred);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return credentials;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// File reader (~/.claude/.credentials.json)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read CC credentials from the file-based store.
|
|
117
|
+
*
|
|
118
|
+
* The file can have two shapes:
|
|
119
|
+
* - Wrapped: `{ claudeAiOauth: { accessToken, refreshToken, expiresAt, ... } }`
|
|
120
|
+
* - Flat: `{ accessToken, refreshToken, expiresAt, ... }`
|
|
121
|
+
*
|
|
122
|
+
* Additionally it may be an array of such objects.
|
|
123
|
+
*
|
|
124
|
+
* Entries with `scope: "mcp"` or missing accessToken/refreshToken are filtered.
|
|
125
|
+
*
|
|
126
|
+
* @returns {CCCredential[]}
|
|
127
|
+
*/
|
|
128
|
+
export function readCCCredentialsFromFile() {
|
|
129
|
+
const credPath = join(homedir(), ".claude", ".credentials.json");
|
|
130
|
+
|
|
131
|
+
if (!existsSync(credPath)) return [];
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const raw = readFileSync(credPath, "utf-8");
|
|
135
|
+
const parsed = JSON.parse(raw);
|
|
136
|
+
|
|
137
|
+
/** @type {CCCredential[]} */
|
|
138
|
+
const results = [];
|
|
139
|
+
|
|
140
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < entries.length; i++) {
|
|
143
|
+
const entry = entries[i];
|
|
144
|
+
if (!entry || typeof entry !== "object") continue;
|
|
145
|
+
|
|
146
|
+
// Unwrap "claudeAiOauth" wrapper if present
|
|
147
|
+
const data = entry.claudeAiOauth && typeof entry.claudeAiOauth === "object" ? entry.claudeAiOauth : entry;
|
|
148
|
+
|
|
149
|
+
// Skip MCP-only entries
|
|
150
|
+
if (data.scope === "mcp") continue;
|
|
151
|
+
|
|
152
|
+
const cred = shapeToCCCredential(data, "cc-file", `file:entry-${i}`);
|
|
153
|
+
if (cred) results.push(cred);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return results;
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Unified reader
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read all discoverable CC credentials from all sources.
|
|
168
|
+
*
|
|
169
|
+
* @returns {CCCredential[]}
|
|
170
|
+
*/
|
|
171
|
+
export function readCCCredentials() {
|
|
172
|
+
/** @type {CCCredential[]} */
|
|
173
|
+
const all = [];
|
|
174
|
+
|
|
175
|
+
// Keychain (macOS only)
|
|
176
|
+
all.push(...readCCCredentialsFromKeychain());
|
|
177
|
+
|
|
178
|
+
// File-based (cross-platform)
|
|
179
|
+
all.push(...readCCCredentialsFromFile());
|
|
180
|
+
|
|
181
|
+
// De-duplicate by refreshToken (prefer keychain over file)
|
|
182
|
+
/** @type {Map<string, CCCredential>} */
|
|
183
|
+
const seen = new Map();
|
|
184
|
+
for (const cred of all) {
|
|
185
|
+
const existing = seen.get(cred.refreshToken);
|
|
186
|
+
if (!existing || (cred.source === "cc-keychain" && existing.source === "cc-file")) {
|
|
187
|
+
seen.set(cred.refreshToken, cred);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return [...seen.values()];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Parsing helpers
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Parse a raw JSON string into a CCCredential.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} jsonStr
|
|
202
|
+
* @param {CCCredentialSource} source
|
|
203
|
+
* @param {string} label
|
|
204
|
+
* @returns {CCCredential | null}
|
|
205
|
+
*/
|
|
206
|
+
export function parseCCCredentialData(jsonStr, source, label) {
|
|
207
|
+
try {
|
|
208
|
+
const data = JSON.parse(jsonStr);
|
|
209
|
+
if (!data || typeof data !== "object") return null;
|
|
210
|
+
|
|
211
|
+
// Handle wrapped format
|
|
212
|
+
const inner = data.claudeAiOauth && typeof data.claudeAiOauth === "object" ? data.claudeAiOauth : data;
|
|
213
|
+
|
|
214
|
+
return shapeToCCCredential(inner, source, label);
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Validate and shape an object into a CCCredential.
|
|
222
|
+
*
|
|
223
|
+
* @param {Record<string, unknown>} data
|
|
224
|
+
* @param {CCCredentialSource} source
|
|
225
|
+
* @param {string} label
|
|
226
|
+
* @returns {CCCredential | null}
|
|
227
|
+
*/
|
|
228
|
+
function shapeToCCCredential(data, source, label) {
|
|
229
|
+
const accessToken = typeof data.accessToken === "string" ? data.accessToken : null;
|
|
230
|
+
const refreshToken = typeof data.refreshToken === "string" ? data.refreshToken : null;
|
|
231
|
+
|
|
232
|
+
if (!accessToken || !refreshToken) return null;
|
|
233
|
+
|
|
234
|
+
// expiresAt can be number (ms-epoch) or ISO string
|
|
235
|
+
let expiresAt = 0;
|
|
236
|
+
if (typeof data.expiresAt === "number") {
|
|
237
|
+
expiresAt = data.expiresAt;
|
|
238
|
+
} else if (typeof data.expiresAt === "string") {
|
|
239
|
+
const parsed = Date.parse(data.expiresAt);
|
|
240
|
+
if (!Number.isNaN(parsed)) expiresAt = parsed;
|
|
241
|
+
} else if (typeof data.expires_at === "number") {
|
|
242
|
+
expiresAt = data.expires_at;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
accessToken,
|
|
247
|
+
refreshToken,
|
|
248
|
+
expiresAt,
|
|
249
|
+
subscriptionType: typeof data.subscriptionType === "string" ? data.subscriptionType : undefined,
|
|
250
|
+
source,
|
|
251
|
+
label,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
readCCCredentialsFromKeychain,
|
|
4
|
+
readCCCredentialsFromFile,
|
|
5
|
+
readCCCredentials,
|
|
6
|
+
parseCCCredentialData,
|
|
7
|
+
} from "./cc-credentials.mjs";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Mocks
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
vi.mock("node:child_process", () => ({
|
|
14
|
+
execSync: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("node:fs", () => ({
|
|
18
|
+
readFileSync: vi.fn(),
|
|
19
|
+
existsSync: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("node:os", () => ({
|
|
23
|
+
homedir: vi.fn(() => "/home/testuser"),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { execSync } from "node:child_process";
|
|
27
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Fixtures
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const VALID_CRED = {
|
|
35
|
+
accessToken: "acc-123",
|
|
36
|
+
refreshToken: "ref-456",
|
|
37
|
+
expiresAt: Date.now() + 3_600_000,
|
|
38
|
+
subscriptionType: "claude_pro",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const VALID_CRED_2 = {
|
|
42
|
+
accessToken: "acc-789",
|
|
43
|
+
refreshToken: "ref-012",
|
|
44
|
+
expiresAt: Date.now() + 7_200_000,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const MCP_ONLY_CRED = {
|
|
48
|
+
accessToken: "mcp-acc",
|
|
49
|
+
refreshToken: "mcp-ref",
|
|
50
|
+
expiresAt: Date.now() + 3_600_000,
|
|
51
|
+
scope: "mcp",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const KEYCHAIN_DUMP_SINGLE = `
|
|
55
|
+
keychain: "/Users/test/Library/Keychains/login.keychain-db"
|
|
56
|
+
0x00000007 <blob>="Claude Code-credentials"
|
|
57
|
+
"svce"<blob>="Claude Code-credentials"
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const KEYCHAIN_DUMP_MULTI = `
|
|
61
|
+
keychain: "/Users/test/Library/Keychains/login.keychain-db"
|
|
62
|
+
"svce"<blob>="Claude Code-credentials-0"
|
|
63
|
+
"svce"<blob>="Claude Code-credentials-1"
|
|
64
|
+
"svce"<blob>="Claude Code-credentials-0"
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// parseCCCredentialData
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe("parseCCCredentialData", () => {
|
|
72
|
+
it("parses valid flat JSON", () => {
|
|
73
|
+
const result = parseCCCredentialData(JSON.stringify(VALID_CRED), "cc-file", "test");
|
|
74
|
+
expect(result).not.toBeNull();
|
|
75
|
+
expect(result.accessToken).toBe("acc-123");
|
|
76
|
+
expect(result.refreshToken).toBe("ref-456");
|
|
77
|
+
expect(result.subscriptionType).toBe("claude_pro");
|
|
78
|
+
expect(result.source).toBe("cc-file");
|
|
79
|
+
expect(result.label).toBe("test");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("parses wrapped claudeAiOauth format", () => {
|
|
83
|
+
const wrapped = { claudeAiOauth: VALID_CRED };
|
|
84
|
+
const result = parseCCCredentialData(JSON.stringify(wrapped), "cc-keychain", "kc");
|
|
85
|
+
expect(result).not.toBeNull();
|
|
86
|
+
expect(result.accessToken).toBe("acc-123");
|
|
87
|
+
expect(result.source).toBe("cc-keychain");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns null for invalid JSON", () => {
|
|
91
|
+
expect(parseCCCredentialData("not json", "cc-file", "x")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns null for missing accessToken", () => {
|
|
95
|
+
const data = { refreshToken: "ref" };
|
|
96
|
+
expect(parseCCCredentialData(JSON.stringify(data), "cc-file", "x")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns null for missing refreshToken", () => {
|
|
100
|
+
const data = { accessToken: "acc" };
|
|
101
|
+
expect(parseCCCredentialData(JSON.stringify(data), "cc-file", "x")).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns null for non-object JSON", () => {
|
|
105
|
+
expect(parseCCCredentialData('"hello"', "cc-file", "x")).toBeNull();
|
|
106
|
+
expect(parseCCCredentialData("42", "cc-file", "x")).toBeNull();
|
|
107
|
+
expect(parseCCCredentialData("null", "cc-file", "x")).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("handles expiresAt as ISO string", () => {
|
|
111
|
+
const data = { ...VALID_CRED, expiresAt: "2026-06-15T12:00:00Z" };
|
|
112
|
+
const result = parseCCCredentialData(JSON.stringify(data), "cc-file", "x");
|
|
113
|
+
expect(result).not.toBeNull();
|
|
114
|
+
expect(result.expiresAt).toBe(Date.parse("2026-06-15T12:00:00Z"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("handles expires_at fallback field", () => {
|
|
118
|
+
const data = { accessToken: "a", refreshToken: "r", expires_at: 999999 };
|
|
119
|
+
const result = parseCCCredentialData(JSON.stringify(data), "cc-file", "x");
|
|
120
|
+
expect(result).not.toBeNull();
|
|
121
|
+
expect(result.expiresAt).toBe(999999);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("defaults expiresAt to 0 when missing", () => {
|
|
125
|
+
const data = { accessToken: "a", refreshToken: "r" };
|
|
126
|
+
const result = parseCCCredentialData(JSON.stringify(data), "cc-file", "x");
|
|
127
|
+
expect(result.expiresAt).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// readCCCredentialsFromKeychain
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
describe("readCCCredentialsFromKeychain", () => {
|
|
136
|
+
const origPlatform = process.platform;
|
|
137
|
+
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
Object.defineProperty(process, "platform", { value: origPlatform });
|
|
140
|
+
vi.resetAllMocks();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns empty on non-darwin platforms", () => {
|
|
144
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
145
|
+
expect(readCCCredentialsFromKeychain()).toEqual([]);
|
|
146
|
+
expect(execSync).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("reads single keychain service on macOS", () => {
|
|
150
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
151
|
+
|
|
152
|
+
execSync.mockImplementation((cmd) => {
|
|
153
|
+
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
154
|
+
if (cmd.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
|
|
155
|
+
throw new Error("unexpected cmd");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const result = readCCCredentialsFromKeychain();
|
|
159
|
+
expect(result).toHaveLength(1);
|
|
160
|
+
expect(result[0].accessToken).toBe("acc-123");
|
|
161
|
+
expect(result[0].source).toBe("cc-keychain");
|
|
162
|
+
expect(result[0].label).toContain("keychain:");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("reads multiple keychain services and deduplicates service names", () => {
|
|
166
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
167
|
+
|
|
168
|
+
let callIdx = 0;
|
|
169
|
+
execSync.mockImplementation((cmd) => {
|
|
170
|
+
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_MULTI;
|
|
171
|
+
if (cmd.includes("find-generic-password")) {
|
|
172
|
+
callIdx++;
|
|
173
|
+
const cred = callIdx === 1 ? VALID_CRED : VALID_CRED_2;
|
|
174
|
+
return JSON.stringify(cred);
|
|
175
|
+
}
|
|
176
|
+
throw new Error("unexpected cmd");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const result = readCCCredentialsFromKeychain();
|
|
180
|
+
// KEYCHAIN_DUMP_MULTI has credentials-0 twice, but should be deduped to 2 services
|
|
181
|
+
expect(result).toHaveLength(2);
|
|
182
|
+
expect(result[0].accessToken).toBe("acc-123");
|
|
183
|
+
expect(result[1].accessToken).toBe("acc-789");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns empty when dump-keychain throws", () => {
|
|
187
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
188
|
+
execSync.mockImplementation(() => {
|
|
189
|
+
throw new Error("no keychain");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(readCCCredentialsFromKeychain()).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("skips services where find-generic-password fails (exit 44)", () => {
|
|
196
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
197
|
+
|
|
198
|
+
execSync.mockImplementation((cmd) => {
|
|
199
|
+
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
|
+
throw new Error("unexpected");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(readCCCredentialsFromKeychain()).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// readCCCredentialsFromFile
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
describe("readCCCredentialsFromFile", () => {
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
vi.resetAllMocks();
|
|
219
|
+
homedir.mockReturnValue("/home/testuser");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns empty when credentials file does not exist", () => {
|
|
223
|
+
existsSync.mockReturnValue(false);
|
|
224
|
+
expect(readCCCredentialsFromFile()).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("reads flat credential object", () => {
|
|
228
|
+
existsSync.mockReturnValue(true);
|
|
229
|
+
readFileSync.mockReturnValue(JSON.stringify(VALID_CRED));
|
|
230
|
+
|
|
231
|
+
const result = readCCCredentialsFromFile();
|
|
232
|
+
expect(result).toHaveLength(1);
|
|
233
|
+
expect(result[0].accessToken).toBe("acc-123");
|
|
234
|
+
expect(result[0].source).toBe("cc-file");
|
|
235
|
+
expect(result[0].label).toBe("file:entry-0");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("reads wrapped claudeAiOauth format", () => {
|
|
239
|
+
existsSync.mockReturnValue(true);
|
|
240
|
+
readFileSync.mockReturnValue(JSON.stringify({ claudeAiOauth: VALID_CRED }));
|
|
241
|
+
|
|
242
|
+
const result = readCCCredentialsFromFile();
|
|
243
|
+
expect(result).toHaveLength(1);
|
|
244
|
+
expect(result[0].accessToken).toBe("acc-123");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("reads array of credentials", () => {
|
|
248
|
+
existsSync.mockReturnValue(true);
|
|
249
|
+
readFileSync.mockReturnValue(JSON.stringify([VALID_CRED, VALID_CRED_2]));
|
|
250
|
+
|
|
251
|
+
const result = readCCCredentialsFromFile();
|
|
252
|
+
expect(result).toHaveLength(2);
|
|
253
|
+
expect(result[0].accessToken).toBe("acc-123");
|
|
254
|
+
expect(result[1].accessToken).toBe("acc-789");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("filters MCP-only entries", () => {
|
|
258
|
+
existsSync.mockReturnValue(true);
|
|
259
|
+
readFileSync.mockReturnValue(JSON.stringify([VALID_CRED, MCP_ONLY_CRED]));
|
|
260
|
+
|
|
261
|
+
const result = readCCCredentialsFromFile();
|
|
262
|
+
expect(result).toHaveLength(1);
|
|
263
|
+
expect(result[0].accessToken).toBe("acc-123");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("skips non-object entries in array", () => {
|
|
267
|
+
existsSync.mockReturnValue(true);
|
|
268
|
+
readFileSync.mockReturnValue(JSON.stringify([null, 42, "hello", VALID_CRED]));
|
|
269
|
+
|
|
270
|
+
const result = readCCCredentialsFromFile();
|
|
271
|
+
expect(result).toHaveLength(1);
|
|
272
|
+
expect(result[0].accessToken).toBe("acc-123");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("returns empty on invalid JSON", () => {
|
|
276
|
+
existsSync.mockReturnValue(true);
|
|
277
|
+
readFileSync.mockReturnValue("not json at all");
|
|
278
|
+
|
|
279
|
+
expect(readCCCredentialsFromFile()).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("returns empty on read error", () => {
|
|
283
|
+
existsSync.mockReturnValue(true);
|
|
284
|
+
readFileSync.mockImplementation(() => {
|
|
285
|
+
throw new Error("EACCES");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(readCCCredentialsFromFile()).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("handles entries missing required fields gracefully", () => {
|
|
292
|
+
existsSync.mockReturnValue(true);
|
|
293
|
+
readFileSync.mockReturnValue(JSON.stringify([{ accessToken: "only-access" }, VALID_CRED]));
|
|
294
|
+
|
|
295
|
+
const result = readCCCredentialsFromFile();
|
|
296
|
+
expect(result).toHaveLength(1);
|
|
297
|
+
expect(result[0].accessToken).toBe("acc-123");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// readCCCredentials (unified)
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
describe("readCCCredentials", () => {
|
|
306
|
+
const origPlatform = process.platform;
|
|
307
|
+
|
|
308
|
+
beforeEach(() => {
|
|
309
|
+
vi.resetAllMocks();
|
|
310
|
+
homedir.mockReturnValue("/home/testuser");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
afterEach(() => {
|
|
314
|
+
Object.defineProperty(process, "platform", { value: origPlatform });
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("combines keychain and file sources on macOS", () => {
|
|
318
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
319
|
+
|
|
320
|
+
// Keychain returns one credential
|
|
321
|
+
execSync.mockImplementation((cmd) => {
|
|
322
|
+
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
323
|
+
if (cmd.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
|
|
324
|
+
throw new Error("unexpected");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// File returns a different credential
|
|
328
|
+
existsSync.mockReturnValue(true);
|
|
329
|
+
readFileSync.mockReturnValue(JSON.stringify(VALID_CRED_2));
|
|
330
|
+
|
|
331
|
+
const result = readCCCredentials();
|
|
332
|
+
expect(result).toHaveLength(2);
|
|
333
|
+
expect(result.some((c) => c.source === "cc-keychain")).toBe(true);
|
|
334
|
+
expect(result.some((c) => c.source === "cc-file")).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("deduplicates by refreshToken, preferring keychain", () => {
|
|
338
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
339
|
+
|
|
340
|
+
// Both sources return same refreshToken
|
|
341
|
+
const keychainCred = { ...VALID_CRED, accessToken: "keychain-token" };
|
|
342
|
+
const fileCred = { ...VALID_CRED, accessToken: "file-token" };
|
|
343
|
+
|
|
344
|
+
execSync.mockImplementation((cmd) => {
|
|
345
|
+
if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
|
|
346
|
+
if (cmd.includes("find-generic-password")) return JSON.stringify(keychainCred);
|
|
347
|
+
throw new Error("unexpected");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
existsSync.mockReturnValue(true);
|
|
351
|
+
readFileSync.mockReturnValue(JSON.stringify(fileCred));
|
|
352
|
+
|
|
353
|
+
const result = readCCCredentials();
|
|
354
|
+
expect(result).toHaveLength(1);
|
|
355
|
+
expect(result[0].source).toBe("cc-keychain");
|
|
356
|
+
expect(result[0].accessToken).toBe("keychain-token");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("returns only file credentials on Linux", () => {
|
|
360
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
361
|
+
|
|
362
|
+
existsSync.mockReturnValue(true);
|
|
363
|
+
readFileSync.mockReturnValue(JSON.stringify(VALID_CRED));
|
|
364
|
+
|
|
365
|
+
const result = readCCCredentials();
|
|
366
|
+
expect(result).toHaveLength(1);
|
|
367
|
+
expect(result[0].source).toBe("cc-file");
|
|
368
|
+
expect(execSync).not.toHaveBeenCalled();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("returns empty when no sources available", () => {
|
|
372
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
373
|
+
existsSync.mockReturnValue(false);
|
|
374
|
+
|
|
375
|
+
expect(readCCCredentials()).toEqual([]);
|
|
376
|
+
});
|
|
377
|
+
});
|
package/lib/config.mjs
CHANGED
|
@@ -53,6 +53,13 @@ import { randomBytes } from "node:crypto";
|
|
|
53
53
|
* @property {number} hit_rate_warning_threshold - Warn when avg cache hit rate falls below this (0-1). Default: 0.3
|
|
54
54
|
*/
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {object} CCCredentialReuseConfig
|
|
58
|
+
* @property {boolean} enabled - Master switch for CC credential reuse. Default: false.
|
|
59
|
+
* @property {boolean} auto_detect - Auto-discover CC credentials on startup. Default: true.
|
|
60
|
+
* @property {boolean} prefer_over_oauth - When true, CC credentials are tried before plugin-managed OAuth accounts. Default: false.
|
|
61
|
+
*/
|
|
62
|
+
|
|
56
63
|
/**
|
|
57
64
|
* @typedef {object} AnthropicAuthConfig
|
|
58
65
|
* @property {AccountSelectionStrategy} account_selection_strategy
|
|
@@ -70,6 +77,7 @@ import { randomBytes } from "node:crypto";
|
|
|
70
77
|
* @property {boolean} fast_mode
|
|
71
78
|
* @property {{emulate_minimal: boolean}} telemetry
|
|
72
79
|
* @property {boolean} usage_toast
|
|
80
|
+
* @property {CCCredentialReuseConfig} cc_credential_reuse
|
|
73
81
|
*/
|
|
74
82
|
|
|
75
83
|
/** @type {AnthropicAuthConfig} */
|
|
@@ -128,6 +136,11 @@ export const DEFAULT_CONFIG = {
|
|
|
128
136
|
disable: [],
|
|
129
137
|
billing_header: true,
|
|
130
138
|
},
|
|
139
|
+
cc_credential_reuse: {
|
|
140
|
+
enabled: false,
|
|
141
|
+
auto_detect: true,
|
|
142
|
+
prefer_over_oauth: false,
|
|
143
|
+
},
|
|
131
144
|
};
|
|
132
145
|
|
|
133
146
|
export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
|
|
@@ -153,6 +166,7 @@ function createDefaultConfig() {
|
|
|
153
166
|
fast_mode: DEFAULT_CONFIG.fast_mode,
|
|
154
167
|
telemetry: { ...DEFAULT_CONFIG.telemetry },
|
|
155
168
|
headers: { ...DEFAULT_CONFIG.headers, overrides: {}, disable: [] },
|
|
169
|
+
cc_credential_reuse: { ...DEFAULT_CONFIG.cc_credential_reuse },
|
|
156
170
|
};
|
|
157
171
|
}
|
|
158
172
|
|
|
@@ -357,6 +371,20 @@ function validateConfig(raw) {
|
|
|
357
371
|
// Usage toast
|
|
358
372
|
config.usage_toast = typeof raw.usage_toast === "boolean" ? raw.usage_toast : DEFAULT_CONFIG.usage_toast;
|
|
359
373
|
|
|
374
|
+
// CC credential reuse sub-config
|
|
375
|
+
if (raw.cc_credential_reuse && typeof raw.cc_credential_reuse === "object") {
|
|
376
|
+
const cc = /** @type {Record<string, unknown>} */ (raw.cc_credential_reuse);
|
|
377
|
+
config.cc_credential_reuse = {
|
|
378
|
+
enabled: typeof cc.enabled === "boolean" ? cc.enabled : DEFAULT_CONFIG.cc_credential_reuse.enabled,
|
|
379
|
+
auto_detect:
|
|
380
|
+
typeof cc.auto_detect === "boolean" ? cc.auto_detect : DEFAULT_CONFIG.cc_credential_reuse.auto_detect,
|
|
381
|
+
prefer_over_oauth:
|
|
382
|
+
typeof cc.prefer_over_oauth === "boolean"
|
|
383
|
+
? cc.prefer_over_oauth
|
|
384
|
+
: DEFAULT_CONFIG.cc_credential_reuse.prefer_over_oauth,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
360
388
|
return config;
|
|
361
389
|
}
|
|
362
390
|
|
|
@@ -429,6 +457,13 @@ function applyEnvOverrides(config) {
|
|
|
429
457
|
config.override_model_limits.enabled = false;
|
|
430
458
|
}
|
|
431
459
|
|
|
460
|
+
if (env.OPENCODE_ANTHROPIC_CC_CREDENTIALS === "1" || env.OPENCODE_ANTHROPIC_CC_CREDENTIALS === "true") {
|
|
461
|
+
config.cc_credential_reuse.enabled = true;
|
|
462
|
+
}
|
|
463
|
+
if (env.OPENCODE_ANTHROPIC_CC_CREDENTIALS === "0" || env.OPENCODE_ANTHROPIC_CC_CREDENTIALS === "false") {
|
|
464
|
+
config.cc_credential_reuse.enabled = false;
|
|
465
|
+
}
|
|
466
|
+
|
|
432
467
|
return config;
|
|
433
468
|
}
|
|
434
469
|
|
package/lib/storage.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { getConfigDir } from "./config.mjs";
|
|
|
30
30
|
* @property {number | null} lastFailureTime
|
|
31
31
|
* @property {string} [lastSwitchReason]
|
|
32
32
|
* @property {AccountStats} stats
|
|
33
|
+
* @property {import('./accounts.mjs').AccountSource} [source] - Origin of the account ('oauth' | 'cc-keychain' | 'cc-file').
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
36
|
/**
|