opencode-anthropic-fix 1.0.0 → 2.1.0
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/cli.mjs +77 -24
- package/index.mjs +228 -41
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +241 -12
- package/lib/betas.mjs +1 -1
- package/lib/config.mjs +90 -12
- package/lib/diagnose.mjs +8 -1
- package/lib/mimicry/adapter-input.mjs +182 -18
- package/lib/mimicry/headers.mjs +1 -1
- package/lib/mimicry/response-stream.mjs +816 -112
- package/lib/mimicry/system-prompt.mjs +20 -10
- package/lib/mimicry/wire-compat.mjs +132 -15
- package/lib/oauth-constants.mjs +109 -0
- package/lib/oauth-env.mjs +187 -0
- package/lib/oauth-scopes.mjs +119 -0
- package/lib/oauth.mjs +270 -91
- package/lib/rolling-summarizer.mjs +20 -5
- package/lib/token-economy/microcompact.mjs +3 -12
- package/lib/unicode-text.mjs +111 -0
- package/package.json +3 -1
package/lib/accounts.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { isTokenUsable } from "./account-state.mjs";
|
|
2
3
|
import { loadAccounts, saveAccounts, createDefaultStats } from "./storage.mjs";
|
|
3
4
|
import { HealthScoreTracker, TokenBucketTracker, selectAccount } from "./rotation.mjs";
|
|
4
5
|
import { calculateBackoffMs } from "./backoff.mjs";
|
|
5
6
|
import { readCCCredentials } from "./cc-credentials.mjs";
|
|
7
|
+
import { HEADLESS_REFRESH_TOKEN_VAR, resolveHeadlessLogin } from "./oauth-env.mjs";
|
|
6
8
|
|
|
7
9
|
/** QA fix M12: hash token fragment instead of leaking raw prefix */
|
|
8
10
|
function hashTokenFragment(token) {
|
|
@@ -22,7 +24,7 @@ function hashTokenFragment(token) {
|
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
* @typedef {'oauth' | 'cc-keychain' | 'cc-file'} AccountSource
|
|
27
|
+
* @typedef {'oauth' | 'cc-keychain' | 'cc-file' | 'env'} AccountSource
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -42,7 +44,9 @@ function hashTokenFragment(token) {
|
|
|
42
44
|
* @property {number | null} lastFailureTime
|
|
43
45
|
* @property {string} [lastSwitchReason]
|
|
44
46
|
* @property {AccountStats} stats
|
|
45
|
-
* @property {AccountSource} [source] - Origin of the account. CC accounts must not enter OAuth HTTP refresh.
|
|
47
|
+
* @property {AccountSource} [source] - Origin of the account. CC accounts must not enter OAuth HTTP refresh. env accounts are process-lifetime only and are never persisted.
|
|
48
|
+
* @property {string} [clientId] - OAuth client id to refresh with. Only set for `env` accounts.
|
|
49
|
+
* @property {string[]} [scopes] - Scopes the operator declared for an `env` account. In-memory only.
|
|
46
50
|
*/
|
|
47
51
|
|
|
48
52
|
const MAX_ACCOUNTS = 10;
|
|
@@ -58,6 +62,38 @@ const RATE_LIMIT_KEY_FAST = "anthropic:fast";
|
|
|
58
62
|
|
|
59
63
|
export { RATE_LIMIT_KEY, RATE_LIMIT_KEY_FAST };
|
|
60
64
|
|
|
65
|
+
/** One-shot latch so a rotating env credential warns once per process, not once per refresh. */
|
|
66
|
+
let ephemeralRotationWarned = false;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Warn when the server rotates the refresh token of an `env`-sourced account.
|
|
70
|
+
*
|
|
71
|
+
* An env credential is process-lifetime only by design, so its successor is
|
|
72
|
+
* written nowhere. Left silent, the next process start would re-seed the
|
|
73
|
+
* retired token from the environment and fail with no explanation. This is the
|
|
74
|
+
* one place that can see both the account's origin and the server's response.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ source?: AccountSource, refreshToken?: string }} account
|
|
77
|
+
* @param {string | undefined} rotatedToken - `refresh_token` from the token response
|
|
78
|
+
* @returns {boolean} whether a warning was emitted
|
|
79
|
+
* @see docs/oauth-2.1.280-contract.md §5.2
|
|
80
|
+
*/
|
|
81
|
+
export function warnOnEphemeralTokenRotation(account, rotatedToken) {
|
|
82
|
+
if (account?.source !== "env") return false;
|
|
83
|
+
if (!rotatedToken || rotatedToken === account.refreshToken) return false;
|
|
84
|
+
if (ephemeralRotationWarned) return false;
|
|
85
|
+
ephemeralRotationWarned = true;
|
|
86
|
+
console.warn(
|
|
87
|
+
`[anthropic-auth] the server rotated the refresh token for the ${HEADLESS_REFRESH_TOKEN_VAR} account. The new token is held in memory only and will be lost when this process exits; update the environment variable to keep it.`,
|
|
88
|
+
);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Reset the one-shot rotation warning. Exported for tests only. */
|
|
93
|
+
export function resetEphemeralRotationWarning() {
|
|
94
|
+
ephemeralRotationWarned = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
61
97
|
/**
|
|
62
98
|
* @typedef {object} StatsDelta
|
|
63
99
|
* @property {number} requests
|
|
@@ -103,9 +139,10 @@ export class AccountManager {
|
|
|
103
139
|
* Load accounts from disk, optionally merging with an OpenCode auth fallback.
|
|
104
140
|
* @param {AnthropicAuthConfig} config
|
|
105
141
|
* @param {{refresh: string, access?: string, expires?: number} | null} [authFallback]
|
|
142
|
+
* @param {Record<string, string | undefined>} [env]
|
|
106
143
|
* @returns {Promise<AccountManager>}
|
|
107
144
|
*/
|
|
108
|
-
static async load(config, authFallback) {
|
|
145
|
+
static async load(config, authFallback, env = process.env) {
|
|
109
146
|
const manager = new AccountManager(config);
|
|
110
147
|
const stored = await loadAccounts();
|
|
111
148
|
|
|
@@ -144,7 +181,7 @@ export class AccountManager {
|
|
|
144
181
|
const fallbackHasAccess = typeof authFallback.access === "string" && authFallback.access.length > 0;
|
|
145
182
|
const fallbackExpires = typeof authFallback.expires === "number" ? authFallback.expires : 0;
|
|
146
183
|
const matchExpires = typeof match.expires === "number" ? match.expires : 0;
|
|
147
|
-
const fallbackLooksFresh = fallbackHasAccess && fallbackExpires
|
|
184
|
+
const fallbackLooksFresh = fallbackHasAccess && isTokenUsable(fallbackExpires);
|
|
148
185
|
const shouldAdoptFallback =
|
|
149
186
|
fallbackLooksFresh && (!match.access || !match.expires || fallbackExpires > matchExpires);
|
|
150
187
|
if (shouldAdoptFallback) {
|
|
@@ -158,6 +195,7 @@ export class AccountManager {
|
|
|
158
195
|
// Merge CC credentials if enabled
|
|
159
196
|
manager.#mergeCC(config);
|
|
160
197
|
|
|
198
|
+
manager.#seedEnvAccount(env);
|
|
161
199
|
return manager;
|
|
162
200
|
}
|
|
163
201
|
|
|
@@ -190,6 +228,7 @@ export class AccountManager {
|
|
|
190
228
|
// Merge CC credentials if enabled
|
|
191
229
|
manager.#mergeCC(config);
|
|
192
230
|
|
|
231
|
+
manager.#seedEnvAccount(env);
|
|
193
232
|
return manager;
|
|
194
233
|
}
|
|
195
234
|
|
|
@@ -270,6 +309,98 @@ export class AccountManager {
|
|
|
270
309
|
}
|
|
271
310
|
}
|
|
272
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Seed a process-lifetime account from CLAUDE_CODE_OAUTH_REFRESH_TOKEN.
|
|
314
|
+
*
|
|
315
|
+
* The seeded account is marked `source: "env"` and is filtered out of every
|
|
316
|
+
* write to `anthropic-accounts.json`: persisting a credential the operator
|
|
317
|
+
* injected transiently would turn an environment variable into a stored
|
|
318
|
+
* secret the user never asked for.
|
|
319
|
+
*
|
|
320
|
+
* It is appended LAST so the positional mapping between `stored.accounts[i]`
|
|
321
|
+
* and `this.#accounts[i]` that `syncActiveIndexFromDisk` relies on still holds
|
|
322
|
+
* for every disk-backed account.
|
|
323
|
+
*
|
|
324
|
+
* A malformed declaration throws rather than degrading to disk accounts: an
|
|
325
|
+
* operator who set the variable meant to use it, and silently ignoring it
|
|
326
|
+
* would run the wrong credential.
|
|
327
|
+
*
|
|
328
|
+
* @param {Record<string, string | undefined> | undefined} env
|
|
329
|
+
*/
|
|
330
|
+
#seedEnvAccount(env) {
|
|
331
|
+
const headless = resolveHeadlessLogin(env);
|
|
332
|
+
if (!headless) return;
|
|
333
|
+
|
|
334
|
+
// Already managed on disk. Leave the stored account untouched — re-tagging
|
|
335
|
+
// it `source: "env"` would make the next save silently delete it.
|
|
336
|
+
if (this.#accounts.some((acc) => acc.refreshToken === headless.refreshToken)) return;
|
|
337
|
+
|
|
338
|
+
if (this.#accounts.length >= MAX_ACCOUNTS) {
|
|
339
|
+
console.warn(
|
|
340
|
+
`[anthropic-auth] ${HEADLESS_REFRESH_TOKEN_VAR} was ignored: the account pool is already at its maximum of ${MAX_ACCOUNTS}.`,
|
|
341
|
+
);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The id is derived from the token hash alone and is therefore stable across
|
|
346
|
+
// restarts: a timestamp would add nothing the hash does not and would break
|
|
347
|
+
// id-keyed correlation between runs.
|
|
348
|
+
const now = Date.now();
|
|
349
|
+
this.#accounts.push({
|
|
350
|
+
id: `env:${hashTokenFragment(headless.refreshToken)}`,
|
|
351
|
+
index: this.#accounts.length,
|
|
352
|
+
email: undefined,
|
|
353
|
+
refreshToken: headless.refreshToken,
|
|
354
|
+
access: undefined,
|
|
355
|
+
expires: undefined,
|
|
356
|
+
tokenUpdatedAt: now,
|
|
357
|
+
addedAt: now,
|
|
358
|
+
lastUsed: 0,
|
|
359
|
+
enabled: true,
|
|
360
|
+
rateLimitResetTimes: {},
|
|
361
|
+
consecutiveFailures: 0,
|
|
362
|
+
lastFailureTime: null,
|
|
363
|
+
lastSwitchReason: "env",
|
|
364
|
+
stats: createDefaultStats(now),
|
|
365
|
+
source: "env",
|
|
366
|
+
clientId: headless.clientId,
|
|
367
|
+
scopes: headless.scopes,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (this.#currentIndex < 0) this.#currentIndex = this.#accounts.length - 1;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Accounts that may be written to disk. `env` accounts are excluded by design.
|
|
375
|
+
* @returns {ManagedAccount[]}
|
|
376
|
+
*/
|
|
377
|
+
#persistableAccounts() {
|
|
378
|
+
return this.#accounts.filter((acc) => acc.source !== "env");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Discard pending stats deltas belonging to `env` accounts.
|
|
383
|
+
*
|
|
384
|
+
* A delta is the amount to add to the value ON DISK; `recordUsage` has already
|
|
385
|
+
* applied the same increment to the in-memory record, which is why the normal
|
|
386
|
+
* save path rebuilds `mergedStats` from disk plus delta rather than from
|
|
387
|
+
* `acc.stats`. An env account has no disk row, so its in-memory stats are
|
|
388
|
+
* already the whole truth and re-applying the delta would double-count.
|
|
389
|
+
*
|
|
390
|
+
* The deltas must still be dropped: `saveToDisk` can return before the capture
|
|
391
|
+
* point, and a key that is never captured is never cleared, so env deltas would
|
|
392
|
+
* otherwise accumulate for the life of the process.
|
|
393
|
+
*
|
|
394
|
+
* @param {Set<string>} [keys] - Restrict to these delta keys; all when omitted.
|
|
395
|
+
*/
|
|
396
|
+
#drainEnvStatsDeltas(keys) {
|
|
397
|
+
for (const acc of this.#accounts) {
|
|
398
|
+
if (acc.source !== "env") continue;
|
|
399
|
+
if (keys && !keys.has(acc.id)) continue;
|
|
400
|
+
this.#statsDeltas.delete(acc.id);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
273
404
|
/**
|
|
274
405
|
* Get the number of enabled accounts.
|
|
275
406
|
* @returns {number}
|
|
@@ -717,10 +848,26 @@ export class AccountManager {
|
|
|
717
848
|
return null;
|
|
718
849
|
};
|
|
719
850
|
|
|
851
|
+
// `env` accounts never reach disk. The persisted activeIndex is resolved by
|
|
852
|
+
// account id rather than by position, so it stays correct no matter where an
|
|
853
|
+
// env account sits in the in-memory list.
|
|
854
|
+
const persistable = this.#persistableAccounts();
|
|
855
|
+
const currentAccount = this.#currentIndex >= 0 ? this.#accounts[this.#currentIndex] : undefined;
|
|
856
|
+
const persistedActiveIndex = currentAccount ? persistable.findIndex((acc) => acc.id === currentAccount.id) : -1;
|
|
857
|
+
|
|
858
|
+
// Nothing to persist and nothing on disk: do not create an empty store.
|
|
859
|
+
// Writing `accounts: []` here would make the next load take the
|
|
860
|
+
// "disk is authoritative" branch and permanently refuse to bootstrap from an
|
|
861
|
+
// auth fallback — a process-lifetime env credential must not have that effect.
|
|
862
|
+
if (persistable.length === 0 && diskAccountsById === null) {
|
|
863
|
+
this.#drainEnvStatsDeltas();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
720
867
|
/** @type {AccountStorage} */
|
|
721
868
|
const storage = {
|
|
722
869
|
version: 1,
|
|
723
|
-
accounts:
|
|
870
|
+
accounts: persistable.map((acc) => {
|
|
724
871
|
const delta = this.#statsDeltas.get(acc.id);
|
|
725
872
|
let mergedStats = acc.stats;
|
|
726
873
|
const diskAcc = findDiskAccount(acc);
|
|
@@ -794,7 +941,7 @@ export class AccountManager {
|
|
|
794
941
|
source: acc.source,
|
|
795
942
|
};
|
|
796
943
|
}),
|
|
797
|
-
activeIndex: Math.max(0,
|
|
944
|
+
activeIndex: Math.max(0, persistedActiveIndex),
|
|
798
945
|
};
|
|
799
946
|
|
|
800
947
|
// QA fix H8: snapshot delta keys before the async write to avoid losing
|
|
@@ -809,6 +956,11 @@ export class AccountManager {
|
|
|
809
956
|
const preloadedDiskData = diskAccountsById ? { version: 1, accounts: [...diskAccountsById.values()] } : null;
|
|
810
957
|
await saveAccounts(storage, preloadedDiskData);
|
|
811
958
|
|
|
959
|
+
// Env accounts are absent from `storage`, so their deltas were never merged.
|
|
960
|
+
// Fold them into the in-memory record before the captured keys are dropped,
|
|
961
|
+
// otherwise every save silently discards an env account's usage.
|
|
962
|
+
this.#drainEnvStatsDeltas(capturedDeltaKeys);
|
|
963
|
+
|
|
812
964
|
// Clear only the captured deltas; new ones added during await are preserved
|
|
813
965
|
for (const key of capturedDeltaKeys) {
|
|
814
966
|
this.#statsDeltas.delete(key);
|
|
@@ -833,9 +985,18 @@ export class AccountManager {
|
|
|
833
985
|
const stored = await loadAccounts();
|
|
834
986
|
if (!stored) return;
|
|
835
987
|
|
|
836
|
-
// Reconcile account list/enabled states with disk (CLI may add/remove/enable/disable)
|
|
837
|
-
|
|
838
|
-
|
|
988
|
+
// Reconcile account list/enabled states with disk (CLI may add/remove/enable/disable).
|
|
989
|
+
// `env` accounts have no disk representation, so they are held aside and
|
|
990
|
+
// re-appended; comparing them against the disk snapshot would report a
|
|
991
|
+
// difference on every tick and rebuild the list forever.
|
|
992
|
+
// Captured before any rebuild: after the list is rebuilt, position is
|
|
993
|
+
// meaningless and only identity survives.
|
|
994
|
+
const currentBeforeSync = this.#currentIndex >= 0 ? this.#accounts[this.#currentIndex] : undefined;
|
|
995
|
+
|
|
996
|
+
const envAccounts = this.#accounts.filter((acc) => acc.source === "env");
|
|
997
|
+
const persistedMem = this.#persistableAccounts();
|
|
998
|
+
const existingByTokenForSnapshot = new Map(persistedMem.map((acc) => [acc.refreshToken, acc]));
|
|
999
|
+
const memSnapshot = persistedMem.map((acc) => `${acc.id}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
|
|
839
1000
|
|
|
840
1001
|
const diskSnapshot = stored.accounts
|
|
841
1002
|
.map((acc) => {
|
|
@@ -845,10 +1006,10 @@ export class AccountManager {
|
|
|
845
1006
|
.join("|");
|
|
846
1007
|
|
|
847
1008
|
if (diskSnapshot !== memSnapshot) {
|
|
848
|
-
const existingById = new Map(
|
|
849
|
-
const existingByToken = new Map(
|
|
1009
|
+
const existingById = new Map(persistedMem.map((acc) => [acc.id, acc]));
|
|
1010
|
+
const existingByToken = new Map(persistedMem.map((acc) => [acc.refreshToken, acc]));
|
|
850
1011
|
|
|
851
|
-
|
|
1012
|
+
const rebuilt = stored.accounts.map((acc, index) => {
|
|
852
1013
|
const existing =
|
|
853
1014
|
(acc.id && existingById.get(acc.id)) || (!acc.id ? existingByToken.get(acc.refreshToken) : null);
|
|
854
1015
|
return {
|
|
@@ -871,6 +1032,17 @@ export class AccountManager {
|
|
|
871
1032
|
};
|
|
872
1033
|
});
|
|
873
1034
|
|
|
1035
|
+
// An env token that has since appeared on disk is no longer ephemeral:
|
|
1036
|
+
// keeping both copies would give one credential two account entries that
|
|
1037
|
+
// invalidate each other on the first refresh.
|
|
1038
|
+
const rebuiltTokens = new Set(rebuilt.map((acc) => acc.refreshToken));
|
|
1039
|
+
const survivingEnvAccounts = envAccounts.filter((acc) => !rebuiltTokens.has(acc.refreshToken));
|
|
1040
|
+
|
|
1041
|
+
this.#accounts = [...rebuilt, ...survivingEnvAccounts];
|
|
1042
|
+
this.#accounts.forEach((acc, i) => {
|
|
1043
|
+
acc.index = i;
|
|
1044
|
+
});
|
|
1045
|
+
|
|
874
1046
|
// Trackers are index-based; reset when account set/order changes.
|
|
875
1047
|
this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
|
|
876
1048
|
this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
|
|
@@ -886,8 +1058,35 @@ export class AccountManager {
|
|
|
886
1058
|
this.#cursor = 0;
|
|
887
1059
|
return;
|
|
888
1060
|
}
|
|
1061
|
+
|
|
1062
|
+
// Re-anchor the selection by identity. Positions shift whenever disk gains
|
|
1063
|
+
// or loses an account, so carrying the old index over silently switches the
|
|
1064
|
+
// caller to a different credential.
|
|
1065
|
+
const reanchored = currentBeforeSync ? this.#accounts.findIndex((acc) => acc.id === currentBeforeSync.id) : -1;
|
|
1066
|
+
if (reanchored >= 0) {
|
|
1067
|
+
this.#currentIndex = reanchored;
|
|
1068
|
+
this.#cursor = reanchored;
|
|
1069
|
+
// This is a BASELINE, not an override. The disk `activeIndex` remains
|
|
1070
|
+
// authoritative for selection — that is the whole point of this method —
|
|
1071
|
+
// so the handshake below still runs. Re-anchoring only decides where we
|
|
1072
|
+
// stand if the handshake declines to move us (its target is disabled or
|
|
1073
|
+
// absent); without it we would be left on whatever account happened to
|
|
1074
|
+
// inherit our old numeric position.
|
|
1075
|
+
} else if (this.#currentIndex >= this.#accounts.length) {
|
|
1076
|
+
// The selected account is gone (a CLI logout-all can empty the disk list
|
|
1077
|
+
// while an env account survives). Land on the last remaining account.
|
|
1078
|
+
this.#currentIndex = this.#accounts.length - 1;
|
|
1079
|
+
this.#cursor = this.#currentIndex;
|
|
1080
|
+
}
|
|
889
1081
|
}
|
|
890
1082
|
|
|
1083
|
+
// `activeIndex` addresses the persisted list, which has no slot for an env
|
|
1084
|
+
// account. When an env account is selected, `saveToDisk` necessarily wrote
|
|
1085
|
+
// some other index, so honouring it here would deselect the env credential on
|
|
1086
|
+
// the very next tick after every save.
|
|
1087
|
+
const current = this.#currentIndex >= 0 ? this.#accounts[this.#currentIndex] : undefined;
|
|
1088
|
+
if (current?.source === "env") return;
|
|
1089
|
+
|
|
891
1090
|
const diskIndex = Math.min(stored.activeIndex, this.#accounts.length - 1);
|
|
892
1091
|
if (diskIndex >= 0 && diskIndex !== this.#currentIndex) {
|
|
893
1092
|
const diskAccount = stored.accounts[diskIndex];
|
|
@@ -994,3 +1193,33 @@ export class AccountManager {
|
|
|
994
1193
|
};
|
|
995
1194
|
}
|
|
996
1195
|
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Whether a one-shot beta-rejection retry pin (`betaRetryAccount` in the
|
|
1199
|
+
* fetch interceptor's retry loop) should still be honored for the retry
|
|
1200
|
+
* attempt it was set for (QA fix L5). Extracted from the loop closure so it
|
|
1201
|
+
* can be unit-tested without bootstrapping the full plugin.
|
|
1202
|
+
*
|
|
1203
|
+
* The pin is a live object reference captured when the *previous* attempt's
|
|
1204
|
+
* response named a rejected beta; by the time the retry lands, that same
|
|
1205
|
+
* account could have been disabled (CLI toggle, a terminal token-refresh
|
|
1206
|
+
* failure) or removed outright (account manager reload/edit) — either way it
|
|
1207
|
+
* must not be force-selected, or the retry could land on an account the rest
|
|
1208
|
+
* of the plugin no longer considers usable. `enabled` is read straight off
|
|
1209
|
+
* `betaRetryAccount` (accounts are mutated in place, so a live reference
|
|
1210
|
+
* reflects the current state), and "present in the account manager" is
|
|
1211
|
+
* checked by identity — not just index — since `removeAccount` re-indexes
|
|
1212
|
+
* the accounts that remain, so a stale index could otherwise coincidentally
|
|
1213
|
+
* match a different, unrelated account.
|
|
1214
|
+
*
|
|
1215
|
+
* @param {{index: number, enabled: boolean} | null} betaRetryAccount
|
|
1216
|
+
* @param {AccountManager} accountManager
|
|
1217
|
+
* @param {Set<number>} transientRefreshSkips - Indices excluded for the rest of this request.
|
|
1218
|
+
* @returns {boolean}
|
|
1219
|
+
*/
|
|
1220
|
+
export function isBetaRetryAccountUsable(betaRetryAccount, accountManager, transientRefreshSkips) {
|
|
1221
|
+
if (!betaRetryAccount) return false;
|
|
1222
|
+
if (transientRefreshSkips.has(betaRetryAccount.index)) return false;
|
|
1223
|
+
if (!betaRetryAccount.enabled) return false;
|
|
1224
|
+
return accountManager.getEnabledAccounts().includes(betaRetryAccount);
|
|
1225
|
+
}
|
package/lib/betas.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* ADDS or SUPPRESSES on top of them.
|
|
14
14
|
*
|
|
15
15
|
* RECONCILIATION IS ENFORCED BY TEST, not by convention. Every literal header
|
|
16
|
-
* below is checked against the package's `
|
|
16
|
+
* below is checked against the package's `BETA_REGISTRY_2_1_280` — the genuine
|
|
17
17
|
* client's own registry — by `lib/betas.test.mjs`, which fails on a stale date
|
|
18
18
|
* stamp and requires the host-only entries (the ones upstream has no registry
|
|
19
19
|
* slot for) to be enumerated and justified rather than merely accumulated. Add
|
package/lib/config.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "
|
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { OAUTH_CLIENT_ID } from "./oauth-constants.mjs";
|
|
6
|
+
import { PROJECT_SCOPES } from "./oauth-scopes.mjs";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* @typedef {'sticky' | 'round-robin' | 'hybrid' | 'single'} AccountSelectionStrategy
|
|
@@ -104,7 +106,7 @@ import { randomBytes } from "node:crypto";
|
|
|
104
106
|
* @property {{ enabled: boolean, default_cooldown_ms: number, poll_quota_on_overload: boolean }} overload_recovery
|
|
105
107
|
* @property {{ proactive_disabled: boolean }} account_management
|
|
106
108
|
* @property {{ enabled: boolean }} anti_verbosity
|
|
107
|
-
* @property {{ sdk_token_useragent: boolean }} oauth
|
|
109
|
+
* @property {{ sdk_token_useragent: boolean, revoke_on_logout: boolean, plugins_scope: boolean, project_scopes: string[] }} oauth
|
|
108
110
|
*/
|
|
109
111
|
|
|
110
112
|
/** @type {AnthropicAuthConfig} */
|
|
@@ -438,22 +440,51 @@ export const DEFAULT_CONFIG = {
|
|
|
438
440
|
},
|
|
439
441
|
/** OAuth token-endpoint request fingerprint. */
|
|
440
442
|
oauth: {
|
|
441
|
-
/**
|
|
442
|
-
* Code 2.1.
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
443
|
+
/** [DEPRECATED — has no effect] Formerly selected between the SDK
|
|
444
|
+
* fingerprint and a legacy `axios/1.13.6` one. Claude Code 2.1.280 has no
|
|
445
|
+
* axios OAuth path, so setting this to `false` did not make a user more
|
|
446
|
+
* anonymous — it made them the only client on the network sending that
|
|
447
|
+
* shape. The axios path is removed; every token request now carries
|
|
448
|
+
* `anthropic-sdk-typescript/0.112.1 userOAuthProvider`.
|
|
449
|
+
* The key stays recognised so existing config files keep loading, and an
|
|
450
|
+
* explicit value warns once per process.
|
|
451
|
+
* @see docs/oauth-2.1.280-contract.md §7 divergence D2 */
|
|
449
452
|
sdk_token_useragent: true,
|
|
453
|
+
/** Attempt RFC 7009 server-side revocation on logout. Default OFF.
|
|
454
|
+
* `/v1/oauth/revoke` appears nowhere in the Claude Code 2.1.280 bundle, so
|
|
455
|
+
* every revocation is a request that identifies this client. It is kept as
|
|
456
|
+
* an opt-in rather than deleted, because users who want the affordance
|
|
457
|
+
* should be able to choose it knowingly.
|
|
458
|
+
* @see docs/oauth-2.1.280-contract.md §7 divergence D1 */
|
|
459
|
+
revoke_on_logout: false,
|
|
460
|
+
/** Request the `user:plugins` scope at authorize time. Default OFF.
|
|
461
|
+
* §13.8 records that the genuine client appends `user:plugins` when its
|
|
462
|
+
* `PLUGINS_SCOPE_REGISTERED` gate is on, but does not record the gate's
|
|
463
|
+
* shipped state. Defaulting to off preserves the previously-emitted
|
|
464
|
+
* five-scope string byte for byte; it is not a claim that the genuine
|
|
465
|
+
* default is off.
|
|
466
|
+
* @see docs/oauth-2.1.280-contract.md §4.2, §7 divergence D9 */
|
|
467
|
+
plugins_scope: false,
|
|
468
|
+
/** Extra project scopes to request at authorize time. Default none.
|
|
469
|
+
* Only `user:projects:read` and `user:projects:write` are recognised;
|
|
470
|
+
* anything else is dropped by the scope composer rather than forwarded,
|
|
471
|
+
* because an unrecognised scope in the authorize URL is a fingerprint and
|
|
472
|
+
* is likely to be rejected by the server. (§13.8 does not record how the
|
|
473
|
+
* authorize endpoint treats an unknown scope, so "likely" is the strongest
|
|
474
|
+
* claim the evidence supports.) A dropped entry is warned about once at
|
|
475
|
+
* config-load time so the user is not left wondering.
|
|
476
|
+
* @see docs/oauth-2.1.280-contract.md §4.2, §7 divergence D9 */
|
|
477
|
+
project_scopes: Object.freeze([]),
|
|
450
478
|
},
|
|
451
479
|
};
|
|
452
480
|
|
|
453
481
|
export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid", "single"];
|
|
454
482
|
|
|
455
|
-
/**
|
|
456
|
-
|
|
483
|
+
/** The OAuth client ID for Anthropic auth flows.
|
|
484
|
+
* Re-exported from ./oauth-constants.mjs so there is exactly one literal in
|
|
485
|
+
* the repository; this alias exists for the call sites that already import it
|
|
486
|
+
* from here. */
|
|
487
|
+
export const CLIENT_ID = OAUTH_CLIENT_ID;
|
|
457
488
|
|
|
458
489
|
/**
|
|
459
490
|
* Build a deep-cloned config object from defaults.
|
|
@@ -487,7 +518,7 @@ function createDefaultConfig() {
|
|
|
487
518
|
microcompact: { ...DEFAULT_CONFIG.microcompact },
|
|
488
519
|
overload_recovery: { ...DEFAULT_CONFIG.overload_recovery },
|
|
489
520
|
account_management: { ...DEFAULT_CONFIG.account_management },
|
|
490
|
-
oauth: { ...DEFAULT_CONFIG.oauth },
|
|
521
|
+
oauth: { ...DEFAULT_CONFIG.oauth, project_scopes: [...DEFAULT_CONFIG.oauth.project_scopes] },
|
|
491
522
|
};
|
|
492
523
|
}
|
|
493
524
|
|
|
@@ -537,6 +568,12 @@ function clampNumber(value, min, max, fallback) {
|
|
|
537
568
|
*/
|
|
538
569
|
let contextHintDeprecationWarned = false;
|
|
539
570
|
|
|
571
|
+
/** One-time latch for the `oauth.sdk_token_useragent` deprecation warning. */
|
|
572
|
+
let sdkTokenUserAgentDeprecationWarned = false;
|
|
573
|
+
|
|
574
|
+
/** One-time latch for the `oauth.project_scopes` unrecognised-entry warning. */
|
|
575
|
+
let projectScopesDropWarned = false;
|
|
576
|
+
|
|
540
577
|
function validateConfig(raw) {
|
|
541
578
|
const config = createDefaultConfig();
|
|
542
579
|
|
|
@@ -1045,9 +1082,50 @@ function validateConfig(raw) {
|
|
|
1045
1082
|
// OAuth token-endpoint fingerprint sub-config
|
|
1046
1083
|
if (raw.oauth && typeof raw.oauth === "object") {
|
|
1047
1084
|
const o = /** @type {Record<string, unknown>} */ (raw.oauth);
|
|
1085
|
+
|
|
1086
|
+
// Non-silent deprecation. Any explicit value warns, because both values now
|
|
1087
|
+
// produce the same request: a user who set `false` deliberately is relying
|
|
1088
|
+
// on behaviour that no longer exists and deserves to be told, and a user who
|
|
1089
|
+
// set `true` is carrying a setting that no longer means anything.
|
|
1090
|
+
if (typeof o.sdk_token_useragent === "boolean" && !sdkTokenUserAgentDeprecationWarned) {
|
|
1091
|
+
sdkTokenUserAgentDeprecationWarned = true;
|
|
1092
|
+
|
|
1093
|
+
// The message deliberately carries no `2.1.<NN>` literal. A version string
|
|
1094
|
+
// in production source is exactly what the version-literals conformance
|
|
1095
|
+
// guard exists to forbid, and weakening that guard for a log line would
|
|
1096
|
+
// trade a real invariant for a cosmetic one.
|
|
1097
|
+
console.warn(
|
|
1098
|
+
"[anthropic-auth] oauth.sdk_token_useragent is deprecated and has no effect: " +
|
|
1099
|
+
"no axios OAuth path exists in the genuine client, so token requests always send " +
|
|
1100
|
+
"the SDK user-agent. See the OAuth wire contract doc, divergence D2. " +
|
|
1101
|
+
"Remove the setting from your config to silence this warning.",
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Unrecognised project scopes are dropped here AND downstream by the scope
|
|
1106
|
+
// composer. Dropping silently in two places is how a user ends up staring
|
|
1107
|
+
// at a typo that produces no error and no scope, so say it once.
|
|
1108
|
+
let projectScopes = [...DEFAULT_CONFIG.oauth.project_scopes];
|
|
1109
|
+
if (Array.isArray(o.project_scopes)) {
|
|
1110
|
+
const requested = o.project_scopes.filter((s) => typeof s === "string");
|
|
1111
|
+
projectScopes = requested.filter((s) => PROJECT_SCOPES.includes(s));
|
|
1112
|
+
const dropped = requested.filter((s) => !PROJECT_SCOPES.includes(s));
|
|
1113
|
+
if (dropped.length > 0 && !projectScopesDropWarned) {
|
|
1114
|
+
projectScopesDropWarned = true;
|
|
1115
|
+
console.warn(
|
|
1116
|
+
`[anthropic-auth] oauth.project_scopes contains unrecognised entries that were dropped: ` +
|
|
1117
|
+
`${dropped.join(", ")}. Recognised values are: ${PROJECT_SCOPES.join(", ")}.`,
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1048
1122
|
config.oauth = {
|
|
1049
1123
|
sdk_token_useragent:
|
|
1050
1124
|
typeof o.sdk_token_useragent === "boolean" ? o.sdk_token_useragent : DEFAULT_CONFIG.oauth.sdk_token_useragent,
|
|
1125
|
+
revoke_on_logout:
|
|
1126
|
+
typeof o.revoke_on_logout === "boolean" ? o.revoke_on_logout : DEFAULT_CONFIG.oauth.revoke_on_logout,
|
|
1127
|
+
plugins_scope: typeof o.plugins_scope === "boolean" ? o.plugins_scope : DEFAULT_CONFIG.oauth.plugins_scope,
|
|
1128
|
+
project_scopes: projectScopes,
|
|
1051
1129
|
};
|
|
1052
1130
|
}
|
|
1053
1131
|
|
package/lib/diagnose.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
|
|
6
6
|
import { getConfigDir, loadConfig } from "./config.mjs";
|
|
7
7
|
import { redactSecrets } from "./redact.mjs";
|
|
8
|
-
import { WIRE_PROFILE } from "./mimicry/wire-compat.mjs";
|
|
8
|
+
import { getWireCompatPackageVersion, WIRE_PROFILE } from "./mimicry/wire-compat.mjs";
|
|
9
9
|
import { loadAccounts } from "./storage.mjs";
|
|
10
10
|
|
|
11
11
|
const ENV_KEYS = [
|
|
@@ -175,6 +175,13 @@ export async function buildDiagnosticBundle() {
|
|
|
175
175
|
// Static baseline on purpose: the CLI version the wire package's profile
|
|
176
176
|
// declares, NOT the (possibly npm-updated) version of the running session.
|
|
177
177
|
mimicryBaseline: WIRE_PROFILE.cliVersion,
|
|
178
|
+
// The full profile id (e.g. "claude-code-2.1.280-sdk-0.112.1"), same
|
|
179
|
+
// baseline caveat as mimicryBaseline above.
|
|
180
|
+
mimicryProfileId: WIRE_PROFILE.id,
|
|
181
|
+
// What's ACTUALLY loaded in node_modules right now -- see
|
|
182
|
+
// getWireCompatPackageVersion's own doc comment for why this can differ
|
|
183
|
+
// from what a future `npm ci` would install.
|
|
184
|
+
wireCompatPackageVersion: getWireCompatPackageVersion(),
|
|
178
185
|
},
|
|
179
186
|
config: redactSecrets(loadConfig()),
|
|
180
187
|
env: getRuntimeEnv(),
|