opencode-anthropic-fix 1.0.0 → 2.0.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 +26 -16
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +211 -12
- package/lib/config.mjs +90 -12
- package/lib/mimicry/adapter-input.mjs +5 -3
- 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/token-economy/microcompact.mjs +3 -12
- package/package.json +2 -2
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];
|
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
|
|
|
@@ -240,9 +240,11 @@ export const ADAPTER_ENV_KEYS = Object.freeze([
|
|
|
240
240
|
* PROFILE INHERITANCE POLICY. The adapter never passes an explicit `profile`
|
|
241
241
|
* argument to the package's entry points; omitting it IS the mechanism by which
|
|
242
242
|
* the plugin inherits whatever `DEFAULT_PROFILE` the installed
|
|
243
|
-
* `@tormentalabs/claude-code-wire-compat` declares.
|
|
244
|
-
*
|
|
245
|
-
*
|
|
243
|
+
* `@tormentalabs/claude-code-wire-compat` declares. Under the `latest` dist-tag
|
|
244
|
+
* each package release can advance the default profile and the plugin follows
|
|
245
|
+
* without a code change to the composition path. The dependency is currently
|
|
246
|
+
* pinned to an exact version instead, holding the default profile back until the
|
|
247
|
+
* plugin is ported to the next one (docs/shared-package-provenance.md).
|
|
246
248
|
*
|
|
247
249
|
* These two constants are the baseline `resolveProfileOverride` compares
|
|
248
250
|
* against, and they are READ FROM the profile through the seam
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every value here is transcribed from docs/oauth-2.1.280-contract.md, the local
|
|
3
|
+
* contract for the genuine Claude Code 2.1.280 OAuth surface, recorded from
|
|
4
|
+
* section 13.8 of the bundle analysis. No value may be guessed or interpolated
|
|
5
|
+
* from another release.
|
|
6
|
+
*
|
|
7
|
+
* Scope: this module holds only the constants this plugin actually emits. The
|
|
8
|
+
* contract also records the OIDC federation surface (contract §2.4, §3), which
|
|
9
|
+
* this plugin deliberately does not implement — see divergence D12. Its
|
|
10
|
+
* constants are intentionally absent here rather than exported unused, so that
|
|
11
|
+
* every export in this file has a live consumer and a conformance assertion.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Genuine OAuth client identifier.
|
|
16
|
+
* @see docs/oauth-2.1.280-contract.md §1.1
|
|
17
|
+
*/
|
|
18
|
+
export const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Console authorization endpoint.
|
|
22
|
+
* @see docs/oauth-2.1.280-contract.md §1
|
|
23
|
+
*/
|
|
24
|
+
export const OAUTH_CONSOLE_AUTHORIZE_URL = "https://platform.claude.com/oauth/authorize";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Genuine consumer authorize page on claude.com/cai/, not claude.ai.
|
|
28
|
+
* This URL and OAUTH_CLAUDE_AI_ORIGIN are different and must never be collapsed.
|
|
29
|
+
* @see docs/oauth-2.1.280-contract.md §1
|
|
30
|
+
*/
|
|
31
|
+
export const OAUTH_CLAUDE_AI_AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Consumer origin, distinct from the consumer authorize page on claude.com/cai/.
|
|
35
|
+
* This origin and OAUTH_CLAUDE_AI_AUTHORIZE_URL must never be collapsed.
|
|
36
|
+
* @see docs/oauth-2.1.280-contract.md §1
|
|
37
|
+
*/
|
|
38
|
+
export const OAUTH_CLAUDE_AI_ORIGIN = "https://claude.ai";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Attested absolute token URL. The contract marks the host binding
|
|
42
|
+
* [unattested-bind]: which request uses this URL is not attested. Do not fix the host.
|
|
43
|
+
* @see docs/oauth-2.1.280-contract.md §1, §2.6
|
|
44
|
+
*/
|
|
45
|
+
export const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* OAuth code callback URI.
|
|
49
|
+
* @see docs/oauth-2.1.280-contract.md §1.1
|
|
50
|
+
*/
|
|
51
|
+
export const OAUTH_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* OAuth API key creation endpoint.
|
|
55
|
+
* @see docs/oauth-2.1.280-contract.md §1
|
|
56
|
+
*/
|
|
57
|
+
export const OAUTH_API_KEY_URL = "https://api.anthropic.com/api/oauth/claude_cli/create_api_key";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* SDK version embedded in the genuine release.
|
|
61
|
+
* @see docs/oauth-2.1.280-contract.md §3
|
|
62
|
+
*/
|
|
63
|
+
export const OAUTH_SDK_VERSION = "0.112.1";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* User OAuth provider SDK user agent.
|
|
67
|
+
* @see docs/oauth-2.1.280-contract.md §3
|
|
68
|
+
*/
|
|
69
|
+
export const OAUTH_SDK_USER_AGENT = `anthropic-sdk-typescript/${OAUTH_SDK_VERSION} userOAuthProvider`;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* OAuth beta identifier. Sent on every token request.
|
|
73
|
+
* @see docs/oauth-2.1.280-contract.md §3
|
|
74
|
+
*/
|
|
75
|
+
export const OAUTH_BETA = "oauth-2025-04-20";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* OAuth expiry skew in seconds.
|
|
79
|
+
* @see docs/oauth-2.1.280-contract.md §3
|
|
80
|
+
*/
|
|
81
|
+
export const OAUTH_EXPIRY_SKEW_SECONDS = 30;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Approved custom OAuth endpoints, frozen to preserve the attested allowlist.
|
|
85
|
+
* @see docs/oauth-2.1.280-contract.md §5.1
|
|
86
|
+
*/
|
|
87
|
+
export const OAUTH_CUSTOM_URL_ALLOWLIST = Object.freeze([
|
|
88
|
+
"https://beacon.claude-ai.staging.ant.dev",
|
|
89
|
+
"https://claude.fedstart.com",
|
|
90
|
+
"https://claude-staging.fedstart.com",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Default local apps base URL.
|
|
95
|
+
* @see docs/oauth-2.1.280-contract.md §5
|
|
96
|
+
*/
|
|
97
|
+
export const OAUTH_LOCAL_APPS_BASE_DEFAULT = "http://localhost:4000";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Default local console base URL.
|
|
101
|
+
* @see docs/oauth-2.1.280-contract.md §5
|
|
102
|
+
*/
|
|
103
|
+
export const OAUTH_LOCAL_CONSOLE_BASE_DEFAULT = "http://localhost:3000";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Must match the genuine rejection string byte-for-byte, including its trailing full stop.
|
|
107
|
+
* @see docs/oauth-2.1.280-contract.md §5.1
|
|
108
|
+
*/
|
|
109
|
+
export const OAUTH_CUSTOM_URL_REJECTION_MESSAGE = "CLAUDE_CODE_CUSTOM_OAUTH_URL is not an approved endpoint.";
|