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.
@@ -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
+ }