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 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 2 messages (recent work)
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
- ...parsedBody.messages.slice(0, 2),
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
- ...parsedBody.messages.slice(-2),
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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [