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.
- package/README.md +30 -3
- package/cli.mjs +77 -24
- package/index.mjs +297 -200
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +211 -12
- package/lib/betas.mjs +131 -0
- package/lib/config.mjs +90 -12
- package/lib/diagnose.mjs +4 -2
- package/lib/mimicry/adapter-input.mjs +137 -33
- package/lib/mimicry/headers.mjs +143 -15
- package/lib/mimicry/request-body.mjs +51 -14
- package/lib/mimicry/system-prompt.mjs +72 -293
- package/lib/mimicry/wire-compat.mjs +133 -11
- 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 -90
- package/lib/passthrough-headers.mjs +152 -0
- package/lib/token-economy/microcompact.mjs +3 -12
- package/package.json +2 -2
- package/lib/mimicry/models.mjs +0 -146
- package/lib/request-headers.mjs +0 -303
|
@@ -4,9 +4,124 @@
|
|
|
4
4
|
* NOT promised because the returned HeaderPair array is converted to Headers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
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 —
|
|
67
|
-
* — so a host can legitimately hand us
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* is
|
|
73
|
-
*
|
|
74
|
-
* `
|
|
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
|
|
77
|
-
*
|
|
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
|
+
}
|