opencode-anthropic-fix 1.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +77 -24
- package/index.mjs +228 -41
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +241 -12
- package/lib/betas.mjs +1 -1
- package/lib/config.mjs +90 -12
- package/lib/diagnose.mjs +8 -1
- package/lib/mimicry/adapter-input.mjs +182 -18
- package/lib/mimicry/headers.mjs +1 -1
- package/lib/mimicry/response-stream.mjs +816 -112
- package/lib/mimicry/system-prompt.mjs +20 -10
- package/lib/mimicry/wire-compat.mjs +132 -15
- 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/rolling-summarizer.mjs +20 -5
- package/lib/token-economy/microcompact.mjs +3 -12
- package/lib/unicode-text.mjs +111 -0
- package/package.json +3 -1
|
@@ -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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
8
|
+
BETA_REGISTRY_2_1_280,
|
|
9
9
|
buildClaudeCodeRequest,
|
|
10
10
|
buildClaudeCodeCountTokensRequest,
|
|
11
|
-
|
|
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.
|
|
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 {
|
|
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.
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* profile
|
|
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 =
|
|
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
|
-
|
|
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
|
|
505
|
-
*
|
|
506
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|