opencode-anthropic-fix 0.6.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.
@@ -4,9 +4,124 @@
4
4
  * NOT promised because the returned HeaderPair array is converted to Headers.
5
5
  */
6
6
 
7
- import { buildClaudeCodeRequest, buildClaudeCodeCountTokensRequest } from "@tormentalabs/claude-code-wire-compat";
7
+ import {
8
+ BETA_REGISTRY_2_1_233,
9
+ buildClaudeCodeRequest,
10
+ buildClaudeCodeCountTokensRequest,
11
+ CLAUDE_CODE_2_1_233_PROFILE,
12
+ hasOneMillionContext,
13
+ isAdaptiveThinkingModel,
14
+ isClaude3Model,
15
+ isEligibleFor1MContext,
16
+ isFable5Model,
17
+ isHaikuModel,
18
+ isMythos5Model,
19
+ isOpus46Model,
20
+ isOpus47Model,
21
+ isOpus48Model,
22
+ supportsStructuredOutputs,
23
+ supportsWebSearch,
24
+ } from "@tormentalabs/claude-code-wire-compat";
8
25
  import { stripStainlessHelperMarkers } from "./headers.mjs";
9
26
 
27
+ /**
28
+ * Model-family predicates, re-exported so the rest of the tree classifies models
29
+ * through the SAME catalogue the request builder classifies them with.
30
+ *
31
+ * These used to be hand-written regexes in `lib/mimicry/models.mjs`, maintained
32
+ * in parallel with the package's model catalogue and therefore free to drift
33
+ * from it: a request could be composed as an adaptive-thinking model by the
34
+ * package while the plugin's own gating said otherwise. Sourcing both sides from
35
+ * one place removes that whole class of divergence. The behavioural differences
36
+ * this migration introduces are pinned, row by row, in
37
+ * `test/conformance/model-predicates-parity.test.mjs`.
38
+ *
39
+ * `isEligibleFor1MContext` is deliberately NOT re-exported raw — see
40
+ * `isEligibleFor1MContextWire` below.
41
+ *
42
+ * The CAPABILITY predicates (`isHaikuModel`, `supportsStructuredOutputs`,
43
+ * `supportsWebSearch`) join them for the same reason. `lib/mimicry/headers.mjs`
44
+ * still declares its own local copies, but those now feed ONLY the frozen legacy
45
+ * header forge; every adapter-path decision reads the package's, which are
46
+ * ported from the genuine client's own capability table rather than guessed from
47
+ * a regex. The one measured behavioural difference —
48
+ * `supportsStructuredOutputs("claude-haiku-4-5")` is `true` upstream and was
49
+ * `false` under the plugin's "haiku never supports it" heuristic — is a mimicry
50
+ * fidelity FIX, and is pinned by the migration harness baseline.
51
+ */
52
+ export {
53
+ hasOneMillionContext,
54
+ isAdaptiveThinkingModel,
55
+ isClaude3Model,
56
+ isFable5Model,
57
+ isHaikuModel,
58
+ isMythos5Model,
59
+ isOpus46Model,
60
+ isOpus47Model,
61
+ isOpus48Model,
62
+ supportsStructuredOutputs,
63
+ supportsWebSearch,
64
+ };
65
+
66
+ /**
67
+ * The genuine client's beta registry for the profile the plugin emulates,
68
+ * re-exported so the host's own beta tables can be RECONCILED against it
69
+ * without a second import point.
70
+ *
71
+ * `lib/betas.mjs` is host policy — what the plugin adds or suppresses on top of
72
+ * what the package composes — and it names beta headers as literal strings. A
73
+ * literal is exactly the thing that rots when upstream bumps a beta's date, and
74
+ * nothing used to notice. `lib/betas.test.mjs` now walks this registry and fails
75
+ * on a header that has drifted from it, so the rot is caught at test time rather
76
+ * than on the wire.
77
+ *
78
+ * Shape: `{ KEY: { featureKey, header } }`, deep-frozen by the package. It is
79
+ * pinned to 2.1.233 rather than derived from `WIRE_PROFILE` because the package
80
+ * exports no profile->registry accessor; if the emulated baseline moves, this
81
+ * binding and `WIRE_PROFILE` move together.
82
+ */
83
+ export { BETA_REGISTRY_2_1_233 };
84
+
85
+ /**
86
+ * 1M-context eligibility under the profile the plugin actually emulates.
87
+ *
88
+ * Unlike its siblings, the package's `isEligibleFor1MContext` is
89
+ * PROFILE-DEPENDENT: its second parameter selects the model catalogue, and it
90
+ * defaults to the package's own baseline (2.1.195), not to ours. The two
91
+ * disagree in practice — `claude-mythos-5` is ineligible under 2.1.195 and
92
+ * eligible under 2.1.233 — so calling it bare would gate the plugin's context
93
+ * decisions on a profile the plugin does not emulate anywhere else.
94
+ *
95
+ * This wrapper binds `WIRE_PROFILE`, so eligibility follows the emulated
96
+ * profile automatically if that baseline is ever bumped.
97
+ *
98
+ * @param {string | undefined} model
99
+ * @returns {boolean}
100
+ */
101
+ export function isEligibleFor1MContextWire(model) {
102
+ return isEligibleFor1MContext(model, WIRE_PROFILE);
103
+ }
104
+
105
+ /**
106
+ * The protocol profile the plugin composes against, re-exported so the rest of
107
+ * the tree can read the baseline CLI version WITHOUT importing the package.
108
+ *
109
+ * This module is the single seam through which
110
+ * `@tormentalabs/claude-code-wire-compat` enters the plugin (pinned by
111
+ * `test/conformance/package-dependency-policy.test.mjs`), so a consumer that
112
+ * needs `cliVersion` — the startup value of `claudeCliVersion` in index.mjs, the
113
+ * `mimicryBaseline` reported by `lib/diagnose.mjs` — reads it from here instead
114
+ * of re-declaring a hand-maintained copy.
115
+ *
116
+ * NOTE: this is a BASELINE, not the profile the request path uses. The
117
+ * composition path deliberately passes no explicit `profile` so it inherits the
118
+ * package's `DEFAULT_PROFILE` (see `lib/mimicry/adapter-input.mjs`); naming a
119
+ * profile here only pins what the plugin reports about itself.
120
+ *
121
+ * @type {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProtocolProfile}
122
+ */
123
+ export const WIRE_PROFILE = CLAUDE_CODE_2_1_233_PROFILE;
124
+
10
125
  const PROFILE_OVERRIDE_ENV = "OPENCODE_ANTHROPIC_PROFILE_OVERRIDE";
11
126
 
12
127
  const BODY_FIELD_NAMES = new Set([
@@ -63,18 +178,25 @@ function normalizeSystem(system) {
63
178
  * `normalizeModelId` (dist/model-identity.js) tests `includes("claude-opus-4-7")`
64
179
  * and never the dotted spelling, because the genuine Claude Code client emits
65
180
  * dashed ids. The plugin, by contrast, deliberately TOLERATES dotted ids
66
- * throughout — `lib/mimicry/models.mjs` matches with the `[._-]` character class
67
- * — so a host can legitimately hand us `claude-opus-4.7`.
181
+ * throughout — the package's own family predicates re-exported above accept
182
+ * `claude-opus-4.7` — so a host can legitimately hand us that spelling.
183
+ *
184
+ * WHY IT SURVIVED THE PACKAGE MIGRATION. The consolidation plan predicted this
185
+ * rewrite could be deleted once the family predicates came from the package,
186
+ * on the premise that the package normalizes the id for us. Measured against
187
+ * the installed package, that premise is WRONG, and the distinction is:
68
188
  *
69
- * Passing a dotted id straight through MISCLASSIFIES it. For
70
- * `claude-opus-4.7` no `includes` branch matches, so the fallback
71
- * `/claude-opus-4(?!-\d(?!\d))/` matches (the character after `claude-opus-4`
72
- * is `.`, not `-`) and the model resolves to `claude-opus-4-0`. That silently
73
- * drops `adaptiveThinking`, `supportsEffort`, `supportsMaxEffort` and
74
- * `supportsXhighEffort`, and wrongly turns `supportsTemperature` ON.
189
+ * - DECISIONS are dotted-tolerant. The package's own predicates classify
190
+ * `claude-opus-4.7` correctly (pinned as data in
191
+ * `test/conformance/model-predicates-parity.test.mjs`).
192
+ * - The BODY is not normalized. The package copies the caller's model
193
+ * through VERBATIM apart from stripping `[1m]`-style markers, so feeding
194
+ * it `claude-opus-4.7` bare yields `body.model === "claude-opus-4.7"`.
75
195
  *
76
- * The tolerance belongs to the plugin, so it is spent here and the package
77
- * receives the dashed id it is built for. The rewrite is deliberately narrow:
196
+ * The real API only accepts the dashed spelling, so dropping this rewrite
197
+ * would put an id on the wire that Anthropic rejects. The tolerance therefore
198
+ * belongs to the plugin, is spent here, and the package receives the dashed id
199
+ * it is built for. The rewrite is deliberately narrow:
78
200
  * a dot is only replaced when it FOLLOWS a letter or digit and PRECEDES a
79
201
  * digit, i.e. when it is a version separator (`claude-opus-4.7`,
80
202
  * `claude-3.5-sonnet`). A dot before a letter is left alone, so vendor
@@ -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.";
@@ -0,0 +1,187 @@
1
+ /**
2
+ * OAuth environment surface from docs/oauth-2.1.280-contract.md §5: the
3
+ * allowlist and two local bases (evidence bytes 4657393 and 4656690).
4
+ * Comparison semantics are [unattested]; exact equality is the narrowest
5
+ * defensible reading. The rejection message is byte-exact because it is
6
+ * attested verbatim.
7
+ */
8
+ import {
9
+ OAUTH_CLIENT_ID,
10
+ OAUTH_CUSTOM_URL_ALLOWLIST,
11
+ OAUTH_CUSTOM_URL_REJECTION_MESSAGE,
12
+ OAUTH_LOCAL_APPS_BASE_DEFAULT,
13
+ OAUTH_LOCAL_CONSOLE_BASE_DEFAULT,
14
+ } from "./oauth-constants.mjs";
15
+ import { CLAUDE_AI_BASE_SCOPES, missingBaseScopes } from "./oauth-scopes.mjs";
16
+
17
+ /**
18
+ * Read one environment entry as a trimmed string.
19
+ *
20
+ * A non-string value is treated as absent rather than thrown on: `env` is an
21
+ * arbitrary caller-supplied object in tests and a `ProcessEnv` in production,
22
+ * and the two disagree about what a "set" variable can hold.
23
+ *
24
+ * @param {Record<string, string | undefined> | undefined} env
25
+ * @param {string} key
26
+ * @returns {string} the trimmed value, or `""` when absent or not a string
27
+ */
28
+ function readEnv(env, key) {
29
+ const raw = env?.[key];
30
+ return typeof raw === "string" ? raw.trim() : "";
31
+ }
32
+
33
+ /**
34
+ * Reject anything outside the exact, unnormalised allowlist.
35
+ * @param {string} url
36
+ * @returns {string}
37
+ */
38
+ export function assertApprovedOAuthUrl(url) {
39
+ if (!OAUTH_CUSTOM_URL_ALLOWLIST.includes(url)) {
40
+ throw new Error(OAUTH_CUSTOM_URL_REJECTION_MESSAGE);
41
+ }
42
+ return url;
43
+ }
44
+
45
+ /**
46
+ * An absent or blank variable is not configured; otherwise validate the raw value.
47
+ * @param {Record<string, string | undefined>} env
48
+ * @returns {string | undefined}
49
+ */
50
+ export function resolveCustomOAuthUrl(env) {
51
+ if (readEnv(env, "CLAUDE_CODE_CUSTOM_OAUTH_URL") === "") return undefined;
52
+ // The RAW value is compared, never the trimmed one: normalising here would
53
+ // widen the allowlist beyond what the genuine client accepts.
54
+ return assertApprovedOAuthUrl(env.CLAUDE_CODE_CUSTOM_OAUTH_URL);
55
+ }
56
+
57
+ /**
58
+ * This activation predicate is [unattested]: §13.8 records the two variables
59
+ * and their defaults but not what activates them. Setting either variable to
60
+ * a non-blank value is itself the opt-in, so an entirely unset environment
61
+ * can never silently redirect a production login to localhost. The documented
62
+ * defaults are only reachable once the mode is already active, which is why
63
+ * they cannot leak on their own.
64
+ * @param {Record<string, string | undefined>} env
65
+ * @returns {boolean}
66
+ */
67
+ export function isLocalOAuthDevMode(env) {
68
+ return Boolean(readEnv(env, "CLAUDE_LOCAL_OAUTH_APPS_BASE") || readEnv(env, "CLAUDE_LOCAL_OAUTH_CONSOLE_BASE"));
69
+ }
70
+
71
+ /** Origins a CLAUDE_LOCAL_OAUTH_*_BASE may name. @see docs/oauth-2.1.280-contract.md §5 */
72
+ const LOOPBACK_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?$/;
73
+
74
+ /**
75
+ * Refuse a local-dev base that is not a loopback origin.
76
+ *
77
+ * §13.8 records the two variables and their localhost defaults but nothing about
78
+ * validation, so this guard is [unattested]. It exists because the alternative is
79
+ * strictly worse: an unvalidated base lets any environment that can set one
80
+ * variable redirect a production OAuth flow to an arbitrary host, which is the
81
+ * exact class of defect the CLAUDE_CODE_CUSTOM_OAUTH_URL allowlist exists to
82
+ * prevent. Refusing a non-loopback value is the narrowest reading of "LOCAL".
83
+ *
84
+ * @param {string} base
85
+ * @param {string} varName
86
+ * @returns {string}
87
+ */
88
+ function assertLoopbackBase(base, varName) {
89
+ if (!LOOPBACK_ORIGIN.test(base)) {
90
+ throw new Error(
91
+ `${varName} must be a loopback origin such as http://localhost:4000, http://127.0.0.1:4000 or http://[::1]:4000. Received: ${base}`,
92
+ );
93
+ }
94
+ return base;
95
+ }
96
+
97
+ /**
98
+ * @param {Record<string, string | undefined>} env
99
+ * @returns {string | undefined}
100
+ */
101
+ export function resolveAppsBase(env) {
102
+ if (!isLocalOAuthDevMode(env)) return undefined;
103
+ // Console-only opt-in deliberately selects the documented apps default, not production.
104
+ return assertLoopbackBase(
105
+ readEnv(env, "CLAUDE_LOCAL_OAUTH_APPS_BASE") || OAUTH_LOCAL_APPS_BASE_DEFAULT,
106
+ "CLAUDE_LOCAL_OAUTH_APPS_BASE",
107
+ );
108
+ }
109
+
110
+ /**
111
+ * @param {Record<string, string | undefined>} env
112
+ * @returns {string | undefined}
113
+ */
114
+ export function resolveConsoleBase(env) {
115
+ if (!isLocalOAuthDevMode(env)) return undefined;
116
+ // Apps-only opt-in likewise selects the documented console default, not production.
117
+ return assertLoopbackBase(
118
+ readEnv(env, "CLAUDE_LOCAL_OAUTH_CONSOLE_BASE") || OAUTH_LOCAL_CONSOLE_BASE_DEFAULT,
119
+ "CLAUDE_LOCAL_OAUTH_CONSOLE_BASE",
120
+ );
121
+ }
122
+
123
+ /** Environment variable carrying a pre-issued refresh token. @see docs/oauth-2.1.280-contract.md §5.2 */
124
+ export const HEADLESS_REFRESH_TOKEN_VAR = "CLAUDE_CODE_OAUTH_REFRESH_TOKEN";
125
+
126
+ /** Environment variable declaring the scopes that token was granted. @see docs/oauth-2.1.280-contract.md §5.2 */
127
+ export const HEADLESS_SCOPES_VAR = "CLAUDE_CODE_OAUTH_SCOPES";
128
+
129
+ /** Environment variable overriding the OAuth client id. @see docs/oauth-2.1.280-contract.md §5.2 */
130
+ export const HEADLESS_CLIENT_ID_VAR = "CLAUDE_CODE_OAUTH_CLIENT_ID";
131
+
132
+ /** One-shot latch: an incomplete headless scope declaration warns once per process, not once per load. */
133
+ let headlessScopeWarningEmitted = false;
134
+
135
+ /** Reset the one-shot scope warning. Exported for tests only. */
136
+ export function resetHeadlessScopeWarning() {
137
+ headlessScopeWarningEmitted = false;
138
+ }
139
+
140
+ /**
141
+ * Resolve a headless (non-interactive) login declared entirely by environment.
142
+ *
143
+ * ATTESTATION. §13.8 records the three variable NAMES (contract §5.2). It does
144
+ * NOT record the parsing rules, the required-ness of the scope list, or any
145
+ * error text. Those are this plugin's choices, recorded as divergence D10.
146
+ *
147
+ * WHY THE SCOPE LIST IS REQUIRED. The operator, not this plugin, knows which
148
+ * scopes the injected token actually carries. Defaulting it would be an
149
+ * invention, and a token silently missing `user:inference` would fail at the
150
+ * first request instead of at boot.
151
+ *
152
+ * WHERE THE SCOPES ARE USED. Verbatim, and only as a boot-time declaration
153
+ * check. They are never re-composed through `composeScopes()` and they MUST
154
+ * NEVER reach a refresh body: the attested refresh body (byte 4429828) has
155
+ * exactly three keys and no `scope` — contract §2.3 and divergence D5.
156
+ *
157
+ * @param {Record<string, string | undefined> | undefined} env
158
+ * @returns {{refreshToken: string, scopes: string[], clientId: string} | undefined}
159
+ * `undefined` when no refresh token is declared.
160
+ * @throws {Error} when a refresh token is declared without a usable scope list.
161
+ */
162
+ export function resolveHeadlessLogin(env) {
163
+ const refreshToken = readEnv(env, HEADLESS_REFRESH_TOKEN_VAR);
164
+ if (!refreshToken) return undefined;
165
+
166
+ const scopes = readEnv(env, HEADLESS_SCOPES_VAR).split(/\s+/).filter(Boolean);
167
+ if (scopes.length === 0) {
168
+ throw new Error(
169
+ `${HEADLESS_REFRESH_TOKEN_VAR} is set but ${HEADLESS_SCOPES_VAR} is missing. Set ${HEADLESS_SCOPES_VAR} to the space-separated scope list that token was granted, for example: ${CLAUDE_AI_BASE_SCOPES.join(" ")}`,
170
+ );
171
+ }
172
+
173
+ // An incomplete declaration warns rather than throwing. The scope list is a
174
+ // declaration about a token this plugin did not issue: it may legitimately be
175
+ // a console-issued token with a different set, and refusing to start would
176
+ // take every unrelated stored account down with it.
177
+ const missing = missingBaseScopes(scopes);
178
+ if (missing.length > 0 && !headlessScopeWarningEmitted) {
179
+ headlessScopeWarningEmitted = true;
180
+ console.warn(
181
+ `[anthropic-auth] ${HEADLESS_SCOPES_VAR} does not declare every scope this plugin normally requires. Missing: ${missing.join(", ")}. Requests needing those scopes will fail at the server.`,
182
+ );
183
+ }
184
+
185
+ const trimmedClientId = readEnv(env, HEADLESS_CLIENT_ID_VAR);
186
+ return { refreshToken, scopes, clientId: trimmedClientId || OAUTH_CLIENT_ID };
187
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * OAuth scope sets and conditional extensions transcribed from
3
+ * docs/oauth-2.1.280-contract.md §4 (evidence: byte 4654950 of the 2.1.280 analysis).
4
+ *
5
+ * WHAT IS ATTESTED: the two base arrays and their element order, the *names* of
6
+ * the three conditional extensions, and the condition under which each is added.
7
+ *
8
+ * WHAT IS NOT ATTESTED, and is this plugin's own choice:
9
+ * - the position of `user:plugins` relative to the project scopes when both
10
+ * gates fire;
11
+ * - the order of `user:projects:read` against `user:projects:write`;
12
+ * - the space-join into the `scope` query parameter.
13
+ * All three are wire-visible. They are recorded as divergence D13 in §7 of the
14
+ * contract doc so that a future maintainer does not mistake them for evidence.
15
+ *
16
+ * Base-set order is offset-attested and must never be sorted.
17
+ * @module oauth-scopes
18
+ */
19
+
20
+ /** Offset-attested console base scopes, in wire order (byte 4654950). */
21
+ export const CONSOLE_BASE_SCOPES = Object.freeze(["org:create_api_key", "user:profile"]);
22
+
23
+ /** Offset-attested claude.ai base scopes, in wire order (byte 4654950). */
24
+ export const CLAUDE_AI_BASE_SCOPES = Object.freeze([
25
+ "user:profile",
26
+ "user:inference",
27
+ "user:sessions:claude_code",
28
+ "user:mcp_servers",
29
+ "user:file_upload",
30
+ ]);
31
+
32
+ /**
33
+ * Conditional plugin scope. §13.8 attests the name and the gate; it does not
34
+ * attest the append position. Appending last is this plugin's choice (D13).
35
+ */
36
+ export const PLUGINS_SCOPE = "user:plugins";
37
+
38
+ /**
39
+ * Conditional project scopes. §13.8 attests both names and that they are added
40
+ * only when requested and allowed; the read-before-write order is this plugin's
41
+ * choice (D13), made so that the emitted string is deterministic.
42
+ */
43
+ export const PROJECT_SCOPES = Object.freeze(["user:projects:read", "user:projects:write"]);
44
+
45
+ /**
46
+ * Return a fresh claude.ai scope list. The genuine client's default state of
47
+ * PLUGINS_SCOPE_REGISTERED is unattested, so this plugin defaults it off;
48
+ * this choice is divergence D9 in the contract doc.
49
+ * @param {{ pluginsRegistered?: unknown }} [options]
50
+ * @returns {string[]}
51
+ */
52
+ export function claudeAiScopes({ pluginsRegistered } = {}) {
53
+ return pluginsRegistered === true ? [...CLAUDE_AI_BASE_SCOPES, PLUGINS_SCOPE] : [...CLAUDE_AI_BASE_SCOPES];
54
+ }
55
+
56
+ /**
57
+ * Select supported project scopes in the canonical order, ignoring unknown input.
58
+ *
59
+ * Iteration is over PROJECT_SCOPES, never over `requested`, so caller order and
60
+ * caller duplicates cannot reach the wire.
61
+ * @param {unknown} [requested]
62
+ * @returns {string[]}
63
+ */
64
+ export function requestedProjectScopes(requested) {
65
+ if (!Array.isArray(requested)) return [];
66
+ return PROJECT_SCOPES.filter((scope) => requested.includes(scope));
67
+ }
68
+
69
+ /**
70
+ * Single composition seam. The default (no options) output, when serialized,
71
+ * must be byte-identical to the pre-Wave-2 five-scope string: the no-regression
72
+ * guarantee.
73
+ *
74
+ * An unrecognised `mode` throws rather than falling through to the claude.ai
75
+ * set: silently emitting consumer scopes for a mode the caller mistyped would
76
+ * send a wrong scope string to a real authorize endpoint.
77
+ * @param {{ mode?: string, pluginsRegistered?: unknown, projectScopes?: unknown } | null} [options]
78
+ * @returns {string[]}
79
+ */
80
+ export function composeScopes(options) {
81
+ const { mode, pluginsRegistered, projectScopes } = options ?? {};
82
+ if (mode !== undefined && mode !== "max" && mode !== "console") {
83
+ throw new Error(`Unknown OAuth mode "${mode}" (expected "max" or "console")`);
84
+ }
85
+ // Project scopes must never leak into a console authorize URL; ignore both extensions.
86
+ if (mode === "console") return [...CONSOLE_BASE_SCOPES];
87
+ const scopes = [...claudeAiScopes({ pluginsRegistered }), ...requestedProjectScopes(projectScopes)];
88
+ // The three scope pools are disjoint today, so this dedupe is a no-op — it is
89
+ // kept as a structural guarantee that the seam can never emit a repeated
90
+ // scope if a future extension overlaps. `Set` iterates in insertion order, so
91
+ // the first occurrence keeps its position. A test pins the disjointness.
92
+ return [...new Set(scopes)];
93
+ }
94
+
95
+ /**
96
+ * The only place the authorize `scope` parameter is serialised: one seam means
97
+ * the wire format has one owner. Space-join serialisation is [unattested] (§4.1).
98
+ * @param {readonly string[] | null} [list]
99
+ * @returns {string}
100
+ */
101
+ export function serializeScopes(list) {
102
+ return list == null ? "" : list.join(" ");
103
+ }
104
+
105
+ /**
106
+ * Which of the five attested claude.ai base scopes are absent from `scopes`.
107
+ *
108
+ * Single seam so the refresh-time check (`hasRequiredScopes`) and the headless
109
+ * boot-time check cannot drift apart. Returns entries in `CLAUDE_AI_BASE_SCOPES`
110
+ * order, never caller order, so the resulting message is deterministic.
111
+ *
112
+ * @param {string[] | undefined | null} scopes
113
+ * @returns {string[]}
114
+ * @see docs/oauth-2.1.280-contract.md §4.1
115
+ */
116
+ export function missingBaseScopes(scopes) {
117
+ const granted = new Set(Array.isArray(scopes) ? scopes : []);
118
+ return CLAUDE_AI_BASE_SCOPES.filter((scope) => !granted.has(scope));
119
+ }