opencode-anthropic-fix 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +77 -24
- package/index.mjs +26 -16
- package/lib/account-state.mjs +48 -0
- package/lib/accounts.mjs +211 -12
- package/lib/config.mjs +90 -12
- package/lib/mimicry/adapter-input.mjs +5 -3
- package/lib/oauth-constants.mjs +109 -0
- package/lib/oauth-env.mjs +187 -0
- package/lib/oauth-scopes.mjs +119 -0
- package/lib/oauth.mjs +270 -91
- package/lib/token-economy/microcompact.mjs +3 -12
- package/package.json +2 -2
|
@@ -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
|
+
}
|