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/lib/oauth.mjs CHANGED
@@ -1,34 +1,38 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { CLIENT_ID, loadConfig } from "./config.mjs";
2
+ import { loadConfig } from "./config.mjs";
3
3
  import { parseRetryAfterHeader, parseRetryAfterMsHeader } from "./backoff.mjs";
4
+ import {
5
+ OAUTH_BETA,
6
+ OAUTH_CLAUDE_AI_AUTHORIZE_URL,
7
+ OAUTH_CLIENT_ID,
8
+ OAUTH_CONSOLE_AUTHORIZE_URL,
9
+ OAUTH_REDIRECT_URI,
10
+ OAUTH_SDK_USER_AGENT,
11
+ OAUTH_TOKEN_URL,
12
+ } from "./oauth-constants.mjs";
13
+ import { CLAUDE_AI_BASE_SCOPES, composeScopes, missingBaseScopes, serializeScopes } from "./oauth-scopes.mjs";
14
+ import { resolveCustomOAuthUrl } from "./oauth-env.mjs";
4
15
 
5
16
  // ---------------------------------------------------------------------------
6
17
  // OAuth helpers — shared between plugin (index.mjs) and CLI (cli.mjs)
18
+ //
19
+ // Every *genuine* endpoint, user-agent and beta name used here is imported from
20
+ // ./oauth-constants.mjs, which transcribes docs/oauth-2.1.280-contract.md.
21
+ // This module owns OAuth *behaviour*; it owns no genuine OAuth *values*. The one
22
+ // value it does own is OAUTH_REVOKE_URL below, which is a divergence (D1) and is
23
+ // deliberately kept out of the transcription module.
7
24
  // ---------------------------------------------------------------------------
8
25
 
9
- const OAUTH_CONSOLE_HOST = "platform.claude.com";
10
- const OAUTH_MAX_HOST = "claude.ai";
11
- const OAUTH_REDIRECT_URI = `https://${OAUTH_CONSOLE_HOST}/oauth/code/callback`;
12
- const OAUTH_TOKEN_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/token`;
13
- const OAUTH_REVOKE_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/revoke`;
14
- // Real Claude Code uses axios 1.13.6 for OAuth calls — axios sets User-Agent
15
- // automatically and adds Accept header. We must match this fingerprint exactly.
16
- // CLI version profile: 2.1.84 (for reference; not sent on OAuth calls)
17
- const OAUTH_AXIOS_VERSION = "1.13.6";
18
- const OAUTH_USER_AGENT = `axios/${OAUTH_AXIOS_VERSION}`;
19
- const OAUTH_ACCEPT = "application/json, text/plain, */*";
20
- // Claude Code 2.1.195 migrated the OAuth token client from axios to the
21
- // Anthropic TS SDK's native-fetch `userOAuthProvider`. When the
22
- // `oauth.sdk_token_useragent` config flag is ON, the token POST switches to
23
- // this fingerprint: `User-Agent: anthropic-sdk-typescript/0.94.0
24
- // userOAuthProvider`, `anthropic-beta: oauth-2025-04-20`, JSON body, and NO
25
- // axios `Accept` header (native fetch sends no default Accept). The SDK version
26
- // is the one real Claude Code 2.1.195 shipped ("0.94.0"); it is hardcoded here
27
- // so the OAuth token path stays decoupled from the wire seam
28
- // (lib/mimicry/wire-compat.mjs), which only describes /v1/messages traffic.
29
- const OAUTH_SDK_VERSION = "0.94.0";
30
- const OAUTH_SDK_USER_AGENT = `anthropic-sdk-typescript/${OAUTH_SDK_VERSION} userOAuthProvider`;
31
- const OAUTH_SDK_BETA = "oauth-2025-04-20";
26
+ /**
27
+ * The RFC 7009 revocation endpoint.
28
+ *
29
+ * NOT PART OF GENUINE CLIENT BEHAVIOUR. `/v1/oauth/revoke` appears nowhere in
30
+ * the Claude Code 2.1.280 bundle, so this URL is deliberately absent from
31
+ * ./oauth-constants.mjs — that module is the transcription of the genuine
32
+ * contract, and this is a divergence.
33
+ * @see docs/oauth-2.1.280-contract.md §6 item 1, §7 divergence D1
34
+ */
35
+ const OAUTH_REVOKE_URL = "https://platform.claude.com/v1/oauth/revoke";
32
36
  const OAUTH_MAX_RETRIES = 2;
33
37
  const OAUTH_MAX_RETRY_DELAY_MS = 30_000;
34
38
  const OAUTH_RATE_LIMIT_COOLDOWN_MS = 30_000;
@@ -36,14 +40,23 @@ const OAUTH_RETRY_AFTER_SOURCE_HEADER_MS = "retry-after-ms";
36
40
  const OAUTH_RETRY_AFTER_SOURCE_HEADER = "retry-after";
37
41
  const OAUTH_RETRY_AFTER_SOURCE_FALLBACK_429 = "fallback-429";
38
42
 
39
- export const CLAUDE_AI_SCOPES = [
40
- "user:profile",
41
- "user:inference",
42
- "user:sessions:claude_code",
43
- "user:mcp_servers",
44
- "user:file_upload",
45
- ];
46
- const CONSOLE_SCOPES = ["org:create_api_key", "user:profile"];
43
+ /**
44
+ * The claude.ai base scope set, in wire order.
45
+ *
46
+ * The array itself is now owned by `./oauth-scopes.mjs`, which composes the
47
+ * conditional extensions. This is an alias binding onto that same frozen array
48
+ * (not an `export … from` re-export), retained so existing importers of
49
+ * `CLAUDE_AI_SCOPES` keep resolving.
50
+ *
51
+ * PARITY IS CONDITIONAL. §13.8 records two conditional extensions to this set:
52
+ * `user:plugins` is appended when the `PLUGINS_SCOPE_REGISTERED` gate is on, and
53
+ * `user:projects:read` / `user:projects:write` when requested. §13.8 does not
54
+ * record the gate's shipped state. If it ships on, a genuine authorize URL
55
+ * carries six scopes and this five-scope URL is itself a distinguishing signal.
56
+ * That is an open, unverifiable item, not a solved one.
57
+ * @see docs/oauth-2.1.280-contract.md §4.1, §4.2, §7 divergence D9
58
+ */
59
+ export const CLAUDE_AI_SCOPES = CLAUDE_AI_BASE_SCOPES;
47
60
 
48
61
  function base64url(input) {
49
62
  return Buffer.from(input).toString("base64url");
@@ -57,40 +70,108 @@ function generatePKCE() {
57
70
  }
58
71
 
59
72
  /**
60
- * Resolve whether the SDK-native-fetch token fingerprint should be used.
61
- * An explicit boolean override (used by callers/tests) wins; otherwise the
62
- * live `oauth.sdk_token_useragent` config flag is read (default true).
73
+ * Resolve whether logout should attempt server-side token revocation.
74
+ *
75
+ * Defaults to `false`: revocation is not genuine-client behaviour, so it is an
76
+ * explicit opt-in rather than something a user has to discover and disable.
77
+ * An explicit boolean override (used by callers/tests) wins.
63
78
  * @param {boolean | undefined} override
64
79
  * @returns {boolean}
80
+ * @see docs/oauth-2.1.280-contract.md §7 divergence D1
65
81
  */
66
- function useSdkTokenFingerprint(override) {
82
+ function revokeOnLogoutEnabled(override) {
67
83
  if (typeof override === "boolean") return override;
68
84
  try {
69
- return loadConfig()?.oauth?.sdk_token_useragent === true;
85
+ return loadConfig()?.oauth?.revoke_on_logout === true;
70
86
  } catch {
71
87
  return false;
72
88
  }
73
89
  }
74
90
 
91
+ /**
92
+ * Resolve the scope gates for an authorize call.
93
+ *
94
+ * An explicit option always wins over config, so a caller can force either
95
+ * state in a test without touching the user's config file. A wrongly-typed
96
+ * explicit option throws rather than silently deferring to config: these are
97
+ * programmer inputs, and a silent fallback would make the forced state a lie.
98
+ *
99
+ * Config is read live (never cached in a closed-over constant) because
100
+ * `/anthropic set` mutates it at runtime, and it is read ONLY when at least one
101
+ * gate is still unresolved — `authorize()` used to be free of any filesystem
102
+ * dependency and should reacquire one no more often than it must.
103
+ *
104
+ * A config read that fails degrades to the documented defaults rather than
105
+ * failing the login, but it warns: silently downgrading a user who configured
106
+ * `plugins_scope: true` to a five-scope token with no diagnostic is exactly the
107
+ * kind of unexplained behaviour this project treats as a defect.
108
+ * @param {{pluginsRegistered?: boolean, projectScopes?: string[]} | null} [options]
109
+ * @returns {{pluginsRegistered: boolean, projectScopes: unknown}}
110
+ * @see docs/oauth-2.1.280-contract.md §4.2, §7 divergence D9
111
+ */
112
+ function resolveScopeOptions(options) {
113
+ const opts = options ?? {};
114
+ const pluginsExplicit = opts.pluginsRegistered !== undefined;
115
+ const projectExplicit = opts.projectScopes !== undefined;
116
+ if (pluginsExplicit && typeof opts.pluginsRegistered !== "boolean") {
117
+ throw new TypeError("authorize(): options.pluginsRegistered must be a boolean when supplied");
118
+ }
119
+ if (projectExplicit && !Array.isArray(opts.projectScopes)) {
120
+ throw new TypeError("authorize(): options.projectScopes must be an array when supplied");
121
+ }
122
+ if (pluginsExplicit && projectExplicit) {
123
+ return { pluginsRegistered: opts.pluginsRegistered === true, projectScopes: opts.projectScopes };
124
+ }
125
+
126
+ let oauth;
127
+ try {
128
+ oauth = loadConfig()?.oauth;
129
+ } catch (err) {
130
+ console.warn(
131
+ `[anthropic-auth] could not read config while composing OAuth scopes; requesting the default set: ${err?.message ?? err}`,
132
+ );
133
+ oauth = undefined;
134
+ }
135
+ return {
136
+ pluginsRegistered: pluginsExplicit ? opts.pluginsRegistered === true : oauth?.plugins_scope === true,
137
+ projectScopes: projectExplicit
138
+ ? opts.projectScopes
139
+ : Array.isArray(oauth?.project_scopes)
140
+ ? oauth.project_scopes
141
+ : [],
142
+ };
143
+ }
144
+
75
145
  /**
76
146
  * Build the headers for an OAuth token POST (exchange/refresh).
77
- * @param {boolean} useSdk - When true, emit the CC 2.1.195 SDK-native-fetch
78
- * fingerprint; when false, emit the legacy axios fingerprint (default).
147
+ *
148
+ * MEASURED FOR REFRESH, ASSUMED FOR EXCHANGE. §13.8 attests this triple only at
149
+ * the refresh call site (byte 4429828). It records no `authorization_code` grant
150
+ * at all, so reusing the triple on exchange is assumption D8, not a measurement.
151
+ *
152
+ * This module emits one triple rather than switching between two: no axios usage
153
+ * appears anywhere in §13.8, so a switch selecting an axios shape could not make
154
+ * a user more anonymous — it would make them the only client sending that shape.
155
+ *
156
+ * SCOPE OF THE CLAIM: these are the three headers this module controls. The host
157
+ * runtime adds its own (`host`, `connection`, `accept-encoding`, `content-length`
158
+ * and others) with its own casing and ordering. §13.8 names no transport and no
159
+ * platform, so the runtime-added set is unverifiable from the evidence and is
160
+ * listed as an open signal in the contract doc. §13.8 also records a second
161
+ * token-endpoint fingerprint (`oidcFederationProvider`); that surface is out of
162
+ * scope here (D12).
163
+ *
164
+ * Header **order** is emitted in the order §13.8 enumerates it for refresh; the
165
+ * source literal's own order is not attested. The literal is built in emission
166
+ * order and must never be sorted or spread through a helper that reorders it.
79
167
  * @returns {Record<string, string>}
168
+ * @see docs/oauth-2.1.280-contract.md §2.3, §2.5, §7 divergences D8 and D12
80
169
  */
81
- function buildTokenHeaders(useSdk) {
82
- if (useSdk) {
83
- // Native fetch sends no default Accept header — omit it deliberately.
84
- return {
85
- "Content-Type": "application/json",
86
- "anthropic-beta": OAUTH_SDK_BETA,
87
- "User-Agent": OAUTH_SDK_USER_AGENT,
88
- };
89
- }
170
+ function buildTokenHeaders() {
90
171
  return {
91
- Accept: OAUTH_ACCEPT,
92
172
  "Content-Type": "application/json",
93
- "User-Agent": OAUTH_USER_AGENT,
173
+ "anthropic-beta": OAUTH_BETA,
174
+ "User-Agent": OAUTH_SDK_USER_AGENT,
94
175
  };
95
176
  }
96
177
 
@@ -206,19 +287,69 @@ function parseOAuthErrorBody(rawText) {
206
287
 
207
288
  /**
208
289
  * Build an OAuth authorization URL with PKCE challenge.
290
+ *
291
+ * ASSUMPTION — THE QUERY STRING IS NOT ATTESTED. §13.8 records the two authorize
292
+ * *URLs* and nothing else about this request: no query parameter set, no
293
+ * parameter order, no PKCE material, and no redirect URI. Everything below the
294
+ * origin and path is therefore pre-existing plugin behaviour preserved verbatim,
295
+ * not a transcription of a measurement. Tests pin it so it cannot drift
296
+ * accidentally; they do not certify it as genuine.
297
+ *
298
+ * The scope list is composed at call time from a base set plus gated additions.
299
+ * Defaults reproduce the previous five-scope / two-scope strings byte for byte.
300
+ * The genuine client's gate state is unattested (§7 divergence D9).
209
301
  * @param {"max" | "console"} mode
302
+ * @param {{pluginsRegistered?: boolean, projectScopes?: string[]}} [options]
210
303
  * @returns {Promise<{url: string, verifier: string, state: string}>}
304
+ * @see docs/oauth-2.1.280-contract.md §2.1, §1.1 (OAUTH_REDIRECT_URI)
211
305
  */
212
- export async function authorize(mode) {
213
- const pkce = generatePKCE();
306
+ export async function authorize(mode, options = {}) {
307
+ if (mode !== "max" && mode !== "console") {
308
+ // Silently treating an unknown mode as consumer login would send a user to
309
+ // the wrong provider with the wrong scope set and no visible failure.
310
+ throw new Error(`Unknown OAuth mode "${mode}" (expected "max" or "console")`);
311
+ }
312
+
313
+ // ATTESTED: §13.8 (byte 4657393) records that CLAUDE_CODE_CUSTOM_OAUTH_URL is
314
+ // validated against a three-entry allowlist and throws otherwise. That throw is
315
+ // the attested behaviour and is enforced here: an unapproved value rejects with
316
+ // the byte-exact genuine message.
317
+ //
318
+ // NOT ATTESTED: what an APPROVED value is then used for. §13.8 records the
319
+ // validation, not the consumption, so redirecting the flow to it would be an
320
+ // invention. But silently ignoring an approved value is worse than either
321
+ // option: an operator who points this plugin at a FedRAMP endpoint would get no
322
+ // error and would still have their authorization code and refresh token sent to
323
+ // platform.claude.com. Validate-then-ignore is a misroute dressed as a feature.
324
+ // So an approved value is refused explicitly until the use site is attested.
325
+ // Divergence D14.
326
+ const customOAuthUrl = resolveCustomOAuthUrl(options?.env ?? process.env);
327
+ if (customOAuthUrl) {
328
+ throw new Error(
329
+ `CLAUDE_CODE_CUSTOM_OAUTH_URL is set to the approved endpoint ${customOAuthUrl}, but this plugin cannot honour it: the evidence records that the value is validated, not what it is used for, so redirecting the flow would be a guess. Unset the variable to authenticate against the default endpoints. See the OAuth wire contract doc, divergence D14.`,
330
+ );
331
+ }
214
332
 
215
- const url = new URL(`https://${mode === "console" ? OAUTH_CONSOLE_HOST : OAUTH_MAX_HOST}/oauth/authorize`);
333
+ const pkce = generatePKCE();
334
+ // Console mode ignores both gates (`composeScopes` drops them), so it must not
335
+ // pay for a config read: `authorize()` acquires a filesystem dependency only
336
+ // on the path that can actually use the answer.
337
+ const gates = mode === "console" ? { pluginsRegistered: false, projectScopes: [] } : resolveScopeOptions(options);
338
+
339
+ // The consumer authorize page carries a `/cai/` path segment, so this is a
340
+ // full URL and not a host interpolated into a shared template.
341
+ const url = new URL(mode === "console" ? OAUTH_CONSOLE_AUTHORIZE_URL : OAUTH_CLAUDE_AI_AUTHORIZE_URL);
342
+ // --- unattested region: parameter set, order, and values (see the docblock) ---
216
343
  url.searchParams.set("code", "true");
217
- url.searchParams.set("client_id", CLIENT_ID);
344
+ url.searchParams.set("client_id", OAUTH_CLIENT_ID);
218
345
  url.searchParams.set("response_type", "code");
219
346
  url.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI);
220
- const scopes = mode === "console" ? CONSOLE_SCOPES : CLAUDE_AI_SCOPES;
221
- url.searchParams.set("scope", scopes.join(" "));
347
+ url.searchParams.set(
348
+ "scope",
349
+ serializeScopes(
350
+ composeScopes({ mode, pluginsRegistered: gates.pluginsRegistered, projectScopes: gates.projectScopes }),
351
+ ),
352
+ );
222
353
  url.searchParams.set("code_challenge", pkce.challenge);
223
354
  url.searchParams.set("code_challenge_method", "S256");
224
355
  url.searchParams.set("state", pkce.state);
@@ -311,8 +442,9 @@ export function parseOAuthCallback(input) {
311
442
  * Exchange an authorization code for tokens.
312
443
  * @param {string} code
313
444
  * @param {string} verifier
314
- * @param {{ sdkTokenUserAgent?: boolean }} [options] - `sdkTokenUserAgent`
315
- * overrides the `oauth.sdk_token_useragent` config flag (mainly for tests).
445
+ * @param {{ sdkTokenUserAgent?: boolean }} [options] - `sdkTokenUserAgent` is
446
+ * accepted and ignored. It is retained so existing call sites keep type-
447
+ * checking; there is only one token fingerprint. See §7 divergence D2.
316
448
  * @returns {Promise<
317
449
  * | { type: "success", refresh: string, access: string, expires: number, email?: string }
318
450
  * | {
@@ -326,8 +458,8 @@ export function parseOAuthCallback(input) {
326
458
  * }
327
459
  * >}
328
460
  */
329
- export async function exchange(code, verifier, options = {}) {
330
- const tokenHeaders = buildTokenHeaders(useSdkTokenFingerprint(options.sdkTokenUserAgent));
461
+ export async function exchange(code, verifier, _options = {}) {
462
+ const tokenHeaders = buildTokenHeaders();
331
463
  const fail = (status, rawText = "", cooldownHint = { retryAfterMs: null }) => {
332
464
  const { errorCode, reason } = parseOAuthErrorBody(rawText);
333
465
  const retryAfterMs =
@@ -351,22 +483,27 @@ export async function exchange(code, verifier, options = {}) {
351
483
  };
352
484
  };
353
485
 
354
- const { code: _authCode, state: _authState } = parseOAuthCallback(code);
486
+ const { code: authCode, state: authState } = parseOAuthCallback(code);
355
487
  let result;
356
488
  for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
357
489
  try {
358
- const _exchangeBody = {
490
+ // SHAPE IS INPUT-DEPENDENT (D8). `state` is present only when the pasted
491
+ // callback carried one, so this body is five keys for a bare code and six
492
+ // for a full callback URL. §13.8 records no authorization_code grant, so
493
+ // neither shape can be checked against the evidence; the conditional is
494
+ // preserved because the server has always accepted both from this client.
495
+ const exchangeBody = {
359
496
  grant_type: "authorization_code",
360
- code: _authCode,
497
+ code: authCode,
361
498
  redirect_uri: OAUTH_REDIRECT_URI,
362
- client_id: CLIENT_ID,
499
+ client_id: OAUTH_CLIENT_ID,
363
500
  code_verifier: verifier,
364
- ...(_authState != null ? { state: _authState } : {}),
501
+ ...(authState != null ? { state: authState } : {}),
365
502
  };
366
503
  result = await fetch(OAUTH_TOKEN_URL, {
367
504
  method: "POST",
368
505
  headers: tokenHeaders,
369
- body: JSON.stringify(_exchangeBody),
506
+ body: JSON.stringify(exchangeBody),
370
507
  signal: AbortSignal.timeout(15_000),
371
508
  });
372
509
  } catch (err) {
@@ -428,53 +565,86 @@ export async function exchange(code, verifier, options = {}) {
428
565
  }
429
566
 
430
567
  /**
431
- * Attempt to revoke a refresh token server-side (best-effort, RFC 7009).
568
+ * THIS REQUEST IS NOT PART OF GENUINE CLIENT BEHAVIOUR. `/v1/oauth/revoke`
569
+ * appears nowhere in the Claude Code 2.1.280 bundle, so every revocation this
570
+ * plugin performs is a request that identifies it. It is therefore gated behind
571
+ * `oauth.revoke_on_logout`, which defaults to `false`.
572
+ *
573
+ * With the gate off the function performs **no network call at all** and
574
+ * reports `{ attempted: false }`. With the gate on an RFC 7009-*style* request
575
+ * is sent — note that RFC 7009 §2.1 mandates form encoding and this body is
576
+ * JSON, matching the rest of this plugin's token layer rather than the RFC.
432
577
  *
433
- * Anthropic may or may not support this endpoint. The function returns
434
- * `true` on a 2xx response and `false` otherwise — callers should always
435
- * proceed with local cleanup regardless of the result.
578
+ * The request carries the same SDK fingerprint as the other token requests
579
+ * rather than the retired axios one. That is a judgement call, not a parity
580
+ * claim: §13.8 has no revoke endpoint, so no user-agent is "correct" here, and
581
+ * a `userOAuthProvider` agent on a path the SDK never calls is arguably its own
582
+ * tell. The alternative was keeping a second, equally unevidenced fingerprint
583
+ * alive purely for this one request, which is worse.
584
+ *
585
+ * THE RETURN VALUE DISTINGUISHES THREE OUTCOMES, because "we chose not to try"
586
+ * and "we tried and the server refused" are different facts and a caller must
587
+ * not report one as the other. Callers must proceed with local cleanup in all
588
+ * three cases.
436
589
  *
437
590
  * @param {string} refreshToken
438
- * @returns {Promise<boolean>}
591
+ * @param {{ revokeOnLogout?: boolean }} [options] - `revokeOnLogout` overrides
592
+ * the config flag (mainly for tests).
593
+ * @returns {Promise<{ attempted: boolean, ok: boolean, status?: number, error?: string }>}
594
+ * @see docs/oauth-2.1.280-contract.md §6 item 1, §7 divergence D1
439
595
  */
440
- export async function revoke(refreshToken) {
596
+ export async function revoke(refreshToken, options = {}) {
597
+ if (!revokeOnLogoutEnabled(options.revokeOnLogout)) return { attempted: false, ok: false };
441
598
  try {
442
599
  const resp = await fetch(OAUTH_REVOKE_URL, {
443
600
  method: "POST",
444
- headers: {
445
- Accept: OAUTH_ACCEPT,
446
- "Content-Type": "application/json",
447
- "User-Agent": OAUTH_USER_AGENT,
448
- },
601
+ headers: buildTokenHeaders(),
449
602
  body: JSON.stringify({
450
603
  token: refreshToken,
451
604
  token_type_hint: "refresh_token",
452
- client_id: CLIENT_ID,
605
+ client_id: OAUTH_CLIENT_ID,
453
606
  }),
454
607
  signal: AbortSignal.timeout(5000),
455
608
  });
456
- return resp.ok;
457
- } catch {
458
- return false;
609
+ return { attempted: true, ok: resp.ok === true, status: resp.status };
610
+ } catch (err) {
611
+ return { attempted: true, ok: false, error: err instanceof Error ? err.message : String(err) };
459
612
  }
460
613
  }
461
614
 
462
615
  /**
463
616
  * Refresh an OAuth access token.
617
+ * The request body is exactly `{ grant_type, refresh_token, client_id }`.
618
+ * Genuine Claude Code 2.1.280 sends **no `scope` key** on refresh; sending one
619
+ * is a positive fingerprint. `options.scopes` is therefore accepted and
620
+ * ignored — it is retained so existing call sites keep working. No call site in
621
+ * this repository passes it; the guard exists so a future one cannot silently
622
+ * reintroduce the key.
623
+ *
464
624
  * @param {string} refreshTokenValue - The refresh token to use
465
- * @param {{ signal?: AbortSignal, scopes?: string[], sdkTokenUserAgent?: boolean }} [options]
466
- * `sdkTokenUserAgent` overrides the `oauth.sdk_token_useragent` config flag
467
- * (mainly for tests).
625
+ * @param {{ signal?: AbortSignal, scopes?: string[], sdkTokenUserAgent?: boolean, clientId?: string }} [options]
626
+ * `scopes` and `sdkTokenUserAgent` are accepted and ignored. A caller-supplied
627
+ * `signal` bounds the whole call including retries; when omitted, each attempt
628
+ * gets its own fresh 30 s timeout.
629
+ * `clientId` overrides the default OAuth client id. It exists for
630
+ * CLAUDE_CODE_OAUTH_CLIENT_ID-seeded accounts (contract §5.2, divergence D10)
631
+ * and does not change the body's key set — it stays exactly
632
+ * { grant_type, refresh_token, client_id } per §2.3.
468
633
  * @returns {Promise<{access_token: string, refresh_token: string, expires_in: number}>}
469
634
  * @throws {Error} On HTTP errors or network failures
635
+ * @see docs/oauth-2.1.280-contract.md §2.3, §6 item 2
470
636
  */
471
637
  export async function refreshToken(refreshTokenValue, options = {}) {
472
- // QA fix M13: default 30s timeout to prevent hanging on unresponsive server
473
- const signal = options.signal ?? AbortSignal.timeout(30_000);
474
- const tokenHeaders = buildTokenHeaders(useSdkTokenFingerprint(options.sdkTokenUserAgent));
638
+ const tokenHeaders = buildTokenHeaders();
475
639
 
476
640
  for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
477
641
  let resp;
642
+ // A single AbortSignal.timeout() created outside the loop starts counting at
643
+ // creation, so a slow first attempt would leave the retries with no budget
644
+ // and turn a 3-attempt policy into a 1-attempt one. A caller that supplies
645
+ // its own signal is explicitly bounding the whole operation, retries
646
+ // included, so that one is shared on purpose.
647
+ const signal = options.signal ?? AbortSignal.timeout(30_000);
478
648
  try {
479
649
  resp = await fetch(OAUTH_TOKEN_URL, {
480
650
  method: "POST",
@@ -482,8 +652,8 @@ export async function refreshToken(refreshTokenValue, options = {}) {
482
652
  body: JSON.stringify({
483
653
  grant_type: "refresh_token",
484
654
  refresh_token: refreshTokenValue,
485
- client_id: CLIENT_ID,
486
- scope: options.scopes ? options.scopes.join(" ") : CLAUDE_AI_SCOPES.join(" "),
655
+ client_id:
656
+ typeof options.clientId === "string" && options.clientId.length > 0 ? options.clientId : OAUTH_CLIENT_ID,
487
657
  }),
488
658
  signal,
489
659
  });
@@ -527,11 +697,20 @@ export async function refreshToken(refreshTokenValue, options = {}) {
527
697
 
528
698
  /**
529
699
  * Check if an account has all required Claude.ai scopes.
700
+ *
701
+ * "Required" means the five-scope base set only. The conditional extensions
702
+ * (`user:plugins`, `user:projects:*`) are deliberately NOT required: they are
703
+ * opt-in, and demanding them would invalidate every already-stored token the
704
+ * moment a user enables a gate. The consequence is that enabling
705
+ * `oauth.plugins_scope` on an existing account does not by itself obtain the
706
+ * scope — the user must re-authorise for the wider grant to be issued. That
707
+ * gap is recorded in §7.3 of the contract doc.
530
708
  * @param {string[] | undefined} scopes
531
709
  * @returns {boolean}
532
710
  */
533
711
  export function hasRequiredScopes(scopes) {
534
712
  if (!Array.isArray(scopes) || scopes.length === 0) return false;
535
- const stored = new Set(scopes);
536
- return CLAUDE_AI_SCOPES.every((s) => stored.has(s));
713
+ // Routed through missingBaseScopes() so the refresh-time check and the
714
+ // headless boot-time check in ./oauth-env.mjs cannot drift apart.
715
+ return missingBaseScopes(scopes).length === 0;
537
716
  }
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Microcompact decision helpers (token-economy).
3
3
  *
4
- * Pure helpers extracted from index.mjs: whether to inject microcompact betas
5
- * based on estimated prompt token usage, and the fixed microcompact beta list.
6
- * The stateful microcompact gating (microcompactState) remains in the
7
- * interceptor shell in index.mjs.
4
+ * Pure helper extracted from index.mjs: whether estimated prompt token usage has
5
+ * crossed the microcompact threshold. The stateful microcompact gating
6
+ * (microcompactState) remains in the interceptor shell in index.mjs.
8
7
  *
9
8
  * @module token-economy/microcompact
10
9
  */
@@ -25,11 +24,3 @@ export function shouldMicrocompact(estimatedTokens, config) {
25
24
  const threshold = contextWindow * (thresholdPct / 100);
26
25
  return estimatedTokens >= threshold;
27
26
  }
28
-
29
- /**
30
- * Build the list of microcompact betas to inject.
31
- * @returns {string[]} Array of beta flag strings
32
- */
33
- export function buildMicrocompactBetas() {
34
- return ["clear_tool_uses_20250919", "clear_thinking_20251015"];
35
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",
@@ -55,6 +55,6 @@
55
55
  "vitest": "^4.0.18"
56
56
  },
57
57
  "dependencies": {
58
- "@tormentalabs/claude-code-wire-compat": "latest"
58
+ "@tormentalabs/claude-code-wire-compat": "0.5.0"
59
59
  }
60
60
  }