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/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 > Date.now();
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: this.#accounts.map((acc) => {
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, this.#currentIndex),
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
- const existingByTokenForSnapshot = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
838
- const memSnapshot = this.#accounts.map((acc) => `${acc.id}:${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
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(this.#accounts.map((acc) => [acc.id, acc]));
849
- const existingByToken = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
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
- this.#accounts = stored.accounts.map((acc, index) => {
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 `BETA_REGISTRY_2_1_233` — the genuine
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
- /** When true (DEFAULT), the OAuth token EXCHANGE/REFRESH POSTs use Claude
442
- * Code 2.1.195's SDK-native-fetch fingerprint (`User-Agent:
443
- * anthropic-sdk-typescript/0.94.0 userOAuthProvider`, `anthropic-beta:
444
- * oauth-2025-04-20`, no axios `Accept`). When false, the legacy axios
445
- * fingerprint (`User-Agent: axios/1.13.6`, axios `Accept` header) is sent
446
- * byte-for-byte as before.
447
- * Default ON to match upstream CC 2.1.195's userOAuthProvider. Set to
448
- * `false` to revert to the legacy axios/1.13.6 + `Accept` fingerprint. */
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
- /** OpenCode's OAuth client ID for Anthropic console auth flows. */
456
- export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
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(),