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.
@@ -1,5 +1,6 @@
1
1
  import { isTruthyEnv } from "../env.mjs";
2
2
  import { isOpus46Model, isOpus47Model, isOpus48Model } from "./wire-compat.mjs";
3
+ import { truncateGraphemes } from "../unicode-text.mjs";
3
4
 
4
5
  /**
5
6
  * The canonical Claude Code identity text.
@@ -146,16 +147,25 @@ export function tailSystemBlock(text, maxChars, turnThreshold) {
146
147
  }
147
148
 
148
149
  export function compactToolDescription(text) {
149
- return text
150
- .replace(/<example[\s\S]*?<\/example>/gi, "")
151
- .replace(/\|[\s|:-]+\|/g, "")
152
- .replace(/^\|.*\|$/gm, "")
153
- .replace(/^(?:\s*[-*]\s+.{200,})$/gm, (m) => m.slice(0, 200) + "...")
154
- .replace(/^(#{1,3}\s+)/gm, "")
155
- .replace(/\*\*([^*]+)\*\*/g, "$1")
156
- .replace(/`([^`]+)`/g, "$1")
157
- .replace(/\n{3,}/g, "\n\n")
158
- .trim();
150
+ return (
151
+ text
152
+ .replace(/<example[\s\S]*?<\/example>/gi, "")
153
+ .replace(/\|[\s|:-]+\|/g, "")
154
+ .replace(/^\|.*\|$/gm, "")
155
+ // Budget is 200 UTF-16 units on the matched line itself; the "..." suffix
156
+ // is NOT counted against that budget (legacy contract: it is appended
157
+ // unconditionally after the cut). truncateGraphemes only ever cuts on a
158
+ // grapheme boundary, so a surrogate pair or combining sequence sitting
159
+ // across unit 200 is excluded whole rather than split — for ASCII text
160
+ // (single-unit graphemes) this is byte-for-byte identical to the old
161
+ // `m.slice(0, 200)`.
162
+ .replace(/^(?:\s*[-*]\s+.{200,})$/gm, (m) => truncateGraphemes(m, 200) + "...")
163
+ .replace(/^(#{1,3}\s+)/gm, "")
164
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
165
+ .replace(/`([^`]+)`/g, "$1")
166
+ .replace(/\n{3,}/g, "\n\n")
167
+ .trim()
168
+ );
159
169
  }
160
170
 
161
171
  export function compactSystemText(text, mode) {
@@ -5,10 +5,10 @@
5
5
  */
6
6
 
7
7
  import {
8
- BETA_REGISTRY_2_1_233,
8
+ BETA_REGISTRY_2_1_280,
9
9
  buildClaudeCodeRequest,
10
10
  buildClaudeCodeCountTokensRequest,
11
- CLAUDE_CODE_2_1_233_PROFILE,
11
+ CLAUDE_CODE_2_1_280_PROFILE,
12
12
  hasOneMillionContext,
13
13
  isAdaptiveThinkingModel,
14
14
  isClaude3Model,
@@ -22,8 +22,13 @@ import {
22
22
  supportsStructuredOutputs,
23
23
  supportsWebSearch,
24
24
  } from "@tormentalabs/claude-code-wire-compat";
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
25
28
  import { stripStainlessHelperMarkers } from "./headers.mjs";
26
29
 
30
+ const WIRE_COMPAT_PACKAGE_NAME = "@tormentalabs/claude-code-wire-compat";
31
+
27
32
  /**
28
33
  * Model-family predicates, re-exported so the rest of the tree classifies models
29
34
  * through the SAME catalogue the request builder classifies them with.
@@ -76,11 +81,11 @@ export {
76
81
  * than on the wire.
77
82
  *
78
83
  * 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
84
+ * pinned to 2.1.280 rather than derived from `WIRE_PROFILE` because the package
80
85
  * exports no profile->registry accessor; if the emulated baseline moves, this
81
86
  * binding and `WIRE_PROFILE` move together.
82
87
  */
83
- export { BETA_REGISTRY_2_1_233 };
88
+ export { BETA_REGISTRY_2_1_280 };
84
89
 
85
90
  /**
86
91
  * 1M-context eligibility under the profile the plugin actually emulates.
@@ -89,7 +94,7 @@ export { BETA_REGISTRY_2_1_233 };
89
94
  * PROFILE-DEPENDENT: its second parameter selects the model catalogue, and it
90
95
  * defaults to the package's own baseline (2.1.195), not to ours. The two
91
96
  * 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
97
+ * eligible under 2.1.280 — so calling it bare would gate the plugin's context
93
98
  * decisions on a profile the plugin does not emulate anywhere else.
94
99
  *
95
100
  * This wrapper binds `WIRE_PROFILE`, so eligibility follows the emulated
@@ -113,14 +118,118 @@ export function isEligibleFor1MContextWire(model) {
113
118
  * `mimicryBaseline` reported by `lib/diagnose.mjs` — reads it from here instead
114
119
  * of re-declaring a hand-maintained copy.
115
120
  *
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.
121
+ * THIS IS ALSO THE PROFILE THE REQUEST PATH ACTUALLY USES, not merely a
122
+ * reporting baseline. `buildWireCompatibleRequest` and
123
+ * `buildWireCompatibleCountTokensRequest` both pass this constant as the
124
+ * explicit `profile` argument to `buildClaudeCodeRequest` /
125
+ * `buildClaudeCodeCountTokensRequest` (see the package's own 0.6.0 CHANGELOG:
126
+ * "the pin has to be passed on every call to each of the three entry points
127
+ * that read the default seam -- pinning the builders alone is not enough").
128
+ *
129
+ * WHY AN EXPLICIT PASS, RATHER THAN LETTING THE CALL INHERIT THE PACKAGE'S
130
+ * `DEFAULT_PROFILE`. The dependency is pinned to the `latest` dist-tag
131
+ * (`docs/shared-package-provenance.md`) precisely so package releases --
132
+ * fixes, new opt-in fields -- reach the plugin without a code change. Before
133
+ * this constant was threaded through, an unnamed `profile` argument meant the
134
+ * EMULATED CLIENT VERSION floated on that same tag: 0.6.0 moved
135
+ * `DEFAULT_PROFILE` to 2.1.280 in a minor release and broke 48 plugin tests
136
+ * that were still asserting 2.1.233 bytes. Passing `WIRE_PROFILE` explicitly on
137
+ * every entry point decouples the two axes: `latest` keeps bringing library
138
+ * fixes, and the emulated profile now moves only when this binding is
139
+ * deliberately repointed at a different exported profile singleton -- a
140
+ * one-line change reviewed like any other wire change, not a side effect of
141
+ * `npm update`.
120
142
  *
121
143
  * @type {import('@tormentalabs/claude-code-wire-compat').ClaudeCodeProtocolProfile}
122
144
  */
123
- export const WIRE_PROFILE = CLAUDE_CODE_2_1_233_PROFILE;
145
+ export const WIRE_PROFILE = CLAUDE_CODE_2_1_280_PROFILE;
146
+
147
+ /**
148
+ * Version of `@tormentalabs/claude-code-wire-compat` baked in at BUILD time by
149
+ * `scripts/build.mjs`'s esbuild `define`, read from the package that was
150
+ * actually installed in `node_modules` when the bundle was produced.
151
+ *
152
+ * WHY THIS EXISTS. `getWireCompatPackageVersion` below resolves the version at
153
+ * RUNTIME through `import.meta.resolve`, which requires the package to still
154
+ * be a separate, independently resolvable module -- true for an unbundled
155
+ * import (tests, `node cli.mjs`, a host importing `index.mjs` directly) but
156
+ * false for the bundles `scripts/build.mjs` produces: esbuild INLINES the
157
+ * package's source into the single output file, so there is no
158
+ * `node_modules/@tormentalabs/claude-code-wire-compat` for the bundle to
159
+ * resolve at its own runtime, and the resolution silently degrades to
160
+ * "unknown" -- defeating the diagnostic bundle's whole point of reporting what
161
+ * the running process actually built against.
162
+ *
163
+ * `__WIRE_COMPAT_PACKAGE_VERSION__` is a bare, undeclared global identifier,
164
+ * not an import and not `process.env`, because esbuild's `define` performs a
165
+ * textual substitution of exactly that shape at bundle time. `typeof` never
166
+ * throws on an undeclared identifier, so referencing it this way is exactly as
167
+ * safe unbundled as it is bundled: unbundled, esbuild never touched this file,
168
+ * the identifier stays undeclared, `typeof` reads `"undefined"`, and this
169
+ * constant is `undefined` -- `getWireCompatPackageVersion` then falls through
170
+ * to the runtime resolution exactly as it did before this constant existed.
171
+ *
172
+ * @type {string | undefined}
173
+ */
174
+ /* global __WIRE_COMPAT_PACKAGE_VERSION__:readonly */
175
+ const BUNDLED_WIRE_COMPAT_PACKAGE_VERSION =
176
+ typeof __WIRE_COMPAT_PACKAGE_VERSION__ === "string" ? __WIRE_COMPAT_PACKAGE_VERSION__ : undefined;
177
+
178
+ /**
179
+ * The version of @tormentalabs/claude-code-wire-compat this process actually
180
+ * runs against: the build-time injected version when bundled (see
181
+ * `BUNDLED_WIRE_COMPAT_PACKAGE_VERSION` above), otherwise the version
182
+ * resolved fresh from `node_modules` on every call rather than cached at
183
+ * module load.
184
+ *
185
+ * WHY THE RUNTIME FALLBACK EXISTS. `package.json` pins the dependency to the
186
+ * `latest` dist-tag (see docs/shared-package-provenance.md), so the version an
187
+ * UNBUNDLED run actually runs against is whatever `package-lock.json` resolved
188
+ * at install time -- it is not a literal anywhere in this source tree. A
189
+ * diagnostic bundle that wants to say what wire the running process is
190
+ * actually built against (as opposed to `WIRE_PROFILE.cliVersion`, the
191
+ * BASELINE this file pins) has to read it off disk.
192
+ *
193
+ * Deliberately resolves through `node_modules`, not `package-lock.json`: the
194
+ * lock says what a future `npm ci` WOULD install, `node_modules` says what
195
+ * THIS process actually loaded, and the two can disagree after an `npm
196
+ * install` that has not been re-run (see `scripts/check-wire-compat-drift.mjs`,
197
+ * which checks exactly that disagreement).
198
+ *
199
+ * Resolution failure degrades to "unknown" rather than throwing -- a
200
+ * diagnostic bundle must never fail to build over a missing package.json.
201
+ *
202
+ * Resolves via `import.meta.resolve`, not `createRequire(...).resolve`: the
203
+ * package is ESM-only (its `exports` map declares an `import` condition and
204
+ * no `require` one), so the CJS resolver fails it with "No 'exports' main
205
+ * defined" even though the package is genuinely installed.
206
+ *
207
+ * @returns {string}
208
+ */
209
+ export function getWireCompatPackageVersion() {
210
+ if (BUNDLED_WIRE_COMPAT_PACKAGE_VERSION) return BUNDLED_WIRE_COMPAT_PACKAGE_VERSION;
211
+ try {
212
+ let directory = path.dirname(fileURLToPath(import.meta.resolve(WIRE_COMPAT_PACKAGE_NAME)));
213
+ // Walk up from the package's resolved entry file (e.g. .../dist/index.js)
214
+ // to the package.json alongside its root -- the entry file is not
215
+ // guaranteed to be a direct sibling of it.
216
+ for (let depth = 0; depth < 6; depth += 1) {
217
+ const candidate = path.join(directory, "package.json");
218
+ if (existsSync(candidate)) {
219
+ const manifest = JSON.parse(readFileSync(candidate, "utf8"));
220
+ if (manifest.name === WIRE_COMPAT_PACKAGE_NAME && typeof manifest.version === "string") {
221
+ return manifest.version;
222
+ }
223
+ }
224
+ const parent = path.dirname(directory);
225
+ if (parent === directory) break;
226
+ directory = parent;
227
+ }
228
+ } catch {
229
+ // Fall through to "unknown".
230
+ }
231
+ return "unknown";
232
+ }
124
233
 
125
234
  const PROFILE_OVERRIDE_ENV = "OPENCODE_ANTHROPIC_PROFILE_OVERRIDE";
126
235
 
@@ -408,7 +517,11 @@ export async function buildWireCompatibleRequest(body, transport) {
408
517
  stripStainlessHelperMarkers(parsed?.tools, parsed?.messages);
409
518
  let built;
410
519
  try {
411
- built = await buildClaudeCodeRequest(toClaudeCodeRequestInput(parsed, transport));
520
+ // `WIRE_PROFILE` is passed EXPLICITLY rather than omitted -- see its own
521
+ // doc comment above for why an omitted argument here would let a future
522
+ // package release move the emulated client profile out from under the
523
+ // `latest` dist-tag.
524
+ built = await buildClaudeCodeRequest(toClaudeCodeRequestInput(parsed, transport), WIRE_PROFILE);
412
525
  } catch (error) {
413
526
  throw foldSafeDetailsIntoMessage(error);
414
527
  }
@@ -501,9 +614,10 @@ export function toClaudeCodeCountTokensInput(body, transport) {
501
614
 
502
615
  /**
503
616
  * Build a Claude Code `/v1/messages/count_tokens` request from the JSON body
504
- * received from the host. Mirrors `buildWireCompatibleRequest`, including the
505
- * deliberate absence of an explicit `profile` argument so the count turn
506
- * inherits the package's `DEFAULT_PROFILE` exactly like the main turn does.
617
+ * received from the host. Mirrors `buildWireCompatibleRequest`, including
618
+ * passing `WIRE_PROFILE` as the explicit `profile` argument so the count turn
619
+ * is pinned to the same emulated client profile as the main turn, rather than
620
+ * inheriting whatever the installed package declares as its `DEFAULT_PROFILE`.
507
621
  *
508
622
  * @param {string | undefined} body
509
623
  * @param {Parameters<typeof toClaudeCodeCountTokensInput>[1]} transport
@@ -519,7 +633,10 @@ export async function buildWireCompatibleCountTokensRequest(body, transport) {
519
633
  stripStainlessHelperMarkers(parsed?.tools, parsed?.messages);
520
634
  let built;
521
635
  try {
522
- built = await buildClaudeCodeCountTokensRequest(toClaudeCodeCountTokensInput(parsed, transport));
636
+ // See `buildWireCompatibleRequest`'s equivalent comment: explicit on
637
+ // purpose, so the count-tokens surface cannot silently move to a different
638
+ // profile than the main turn on a package bump.
639
+ built = await buildClaudeCodeCountTokensRequest(toClaudeCodeCountTokensInput(parsed, transport), WIRE_PROFILE);
523
640
  } catch (error) {
524
641
  throw foldSafeDetailsIntoMessage(error);
525
642
  }
@@ -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
+ }