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/oauth.mjs
CHANGED
|
@@ -1,34 +1,38 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
|
82
|
+
function revokeOnLogoutEnabled(override) {
|
|
67
83
|
if (typeof override === "boolean") return override;
|
|
68
84
|
try {
|
|
69
|
-
return loadConfig()?.oauth?.
|
|
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
|
-
*
|
|
78
|
-
*
|
|
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(
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
*
|
|
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,
|
|
330
|
-
const tokenHeaders = buildTokenHeaders(
|
|
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:
|
|
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
|
-
|
|
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:
|
|
497
|
+
code: authCode,
|
|
361
498
|
redirect_uri: OAUTH_REDIRECT_URI,
|
|
362
|
-
client_id:
|
|
499
|
+
client_id: OAUTH_CLIENT_ID,
|
|
363
500
|
code_verifier: verifier,
|
|
364
|
-
...(
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
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
|
-
* @
|
|
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:
|
|
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`
|
|
467
|
-
*
|
|
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
|
-
|
|
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:
|
|
486
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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": "
|
|
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": "
|
|
58
|
+
"@tormentalabs/claude-code-wire-compat": "0.5.0"
|
|
59
59
|
}
|
|
60
60
|
}
|