@ex-machina/opencode-anthropic-auth 2.0.0-next.0 → 2.0.0-next.2

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 CHANGED
@@ -1,80 +1,137 @@
1
1
  # OpenCode Anthropic Auth Plugin
2
2
 
3
+ [![OpenCode v1 — npm latest tag](https://img.shields.io/npm/v/%40ex-machina%2Fopencode-anthropic-auth/latest?label=OpenCode%20v1%20(latest))](https://www.npmjs.com/package/@ex-machina/opencode-anthropic-auth?activeTab=versions)
4
+ [![OpenCode v2 — npm next tag](https://img.shields.io/npm/v/%40ex-machina%2Fopencode-anthropic-auth/next?label=OpenCode%20v2%20(next))](https://www.npmjs.com/package/@ex-machina/opencode-anthropic-auth?activeTab=versions)
5
+
3
6
  > [!WARNING]
4
7
  > This plugin comes with no guarantees. You might be banned for breaking the TOS, you might not be. I don't work at Anthropic, nor am I an attorney.
5
8
  >
6
9
  > Use your best judgment and don't try to abuse the subscriptions. Plugins like oh-my-openagent are _known_ to trigger bans. Please be careful when using Ralph loops or insanely heavy usage patterns.
7
10
 
8
11
  > [!IMPORTANT]
9
- > If you are seeing issues, please try to `rm -rf ~/.cache/opencode/packages/@ex-machina` and check your `opencode.json` config to make sure you're on the latest version.
12
+ > If you are seeing issues, try `rm -rf ~/.cache/opencode/packages/@ex-machina` and confirm that your `opencode.json` uses the plugin release line for your OpenCode version.
10
13
  >
11
14
  > Try this FIRST before making an Issue. Thanks!
12
15
 
13
16
  An [OpenCode](https://github.com/anomalyco/opencode) plugin that provides Anthropic OAuth authentication, enabling Claude Pro/Max users to use their subscription directly with OpenCode.
14
17
 
15
- ## Version compatibility
18
+ > [!NOTE]
19
+ > This repository uses [Agent Facets](https://agentfacets.io) to manage agent capabilities. See the [quickstart](https://docs.agentfacets.io/quickstart) to install the CLI and get started.
20
+
21
+ ## Version support
16
22
 
17
- | Plugin version | OpenCode version | Package |
18
- |-----------------|-------------------|---------------------------------------------|
19
- | 2.x (this readme) | OpenCode v2 (beta plugin API) | `@ex-machina/opencode-anthropic-auth` |
20
- | 1.x | OpenCode v1 | `@ex-machina/opencode-anthropic-auth@1` |
23
+ | OpenCode version | Plugin release | Support branch | npm dist-tag | Configuration key |
24
+ |------------------|----------------|----------------|--------------|-------------------|
25
+ | OpenCode v1 | 1.x | [`main`](https://github.com/ex-machina-co/opencode-anthropic-auth/tree/main) | `latest` | `plugin` |
26
+ | OpenCode v2 | 2.x prereleases | [`v2/main`](https://github.com/ex-machina-co/opencode-anthropic-auth/tree/v2/main) | `next` | `plugins` |
21
27
 
22
- OpenCode v2's plugin API is still beta, and this plugin currently targets the `@opencode-ai/plugin@0.0.0-next-17444` prerelease of it — pin the plugin version and keep an eye on the changelog when bumping either side. If you're still on OpenCode v1, keep using a `1.x` release; v1 plugins are **not** loadable by OpenCode v2, and this v2 port is not loadable by OpenCode v1.
28
+ Both release lines use the same npm package. They are not cross-compatible: the v1 plugin does not load in OpenCode v2, and the v2 plugin does not load in OpenCode v1. OpenCode v2's plugin API is still beta, so review the changelog before upgrading either side.
23
29
 
24
30
  ## Usage
25
31
 
26
- Add the plugin to your OpenCode configuration:
32
+ > [!TIP]
33
+ > Pin an exact plugin version for a stable setup that changes only when you choose to upgrade. If you intentionally want automatic updates, use the moving npm tag for your OpenCode release line.
34
+ >
35
+ > This applies to every OpenCode plugin: an unpinned or moving-tag dependency can install new code on startup, so only track automatic updates from publishers you trust.
36
+
37
+ ### OpenCode v1 (`latest`)
38
+
39
+ OpenCode v1 uses the singular `plugin` configuration key. A bare package spec tracks npm's `latest` tag:
27
40
 
28
41
  ```json
29
42
  {
30
- "plugins": ["@ex-machina/opencode-anthropic-auth"]
43
+ "plugin": ["@ex-machina/opencode-anthropic-auth"]
31
44
  }
32
45
  ```
33
46
 
34
- > [!TIP]
35
- > It is STRONGLY advised that you pin the plugin to a version. This will keep you from getting automatic updates; however, this will protect you from nefarious updates.
36
- >
37
- > This holds true for ANY OpenCode plugin. If you do not pin them, OpenCode will automatically update them on startup. It's a massive vulnerability waiting to happen.
47
+ You can also write the moving tag explicitly as `@ex-machina/opencode-anthropic-auth@latest`.
38
48
 
39
- #### Example of pinned version
49
+ For a stable setup, look up the exact version currently published on `latest`:
50
+
51
+ ```bash
52
+ npm view @ex-machina/opencode-anthropic-auth dist-tags.latest
53
+ ```
54
+
55
+ Substitute the command output for `<version>`:
56
+
57
+ ```json
58
+ {
59
+ "plugin": ["@ex-machina/opencode-anthropic-auth@<version>"]
60
+ }
61
+ ```
62
+
63
+ ### OpenCode v2 (`next`)
64
+
65
+ OpenCode v2 uses the plural `plugins` configuration key. Because npm's default tag points to the v1 line, specify `@next` if you want to track the newest v2 prerelease:
40
66
 
41
67
  ```json
42
68
  {
43
- "plugins": ["@ex-machina/opencode-anthropic-auth@<2.x.y-next.N>"]
69
+ "plugins": ["@ex-machina/opencode-anthropic-auth@next"]
44
70
  }
45
71
  ```
46
72
 
47
- The v2 line ships as prereleases on npm's `next` tag, so substitute a version that actually exists — `npm view @ex-machina/opencode-anthropic-auth dist-tags` shows the current one. Pin that exact version rather than tracking `@next`, which moves on every prerelease publish.
73
+ For a stable setup, look up the exact version currently published on `next`:
74
+
75
+ ```bash
76
+ npm view @ex-machina/opencode-anthropic-auth dist-tags.next
77
+ ```
78
+
79
+ Substitute the command output for `<version>`:
80
+
81
+ ```json
82
+ {
83
+ "plugins": ["@ex-machina/opencode-anthropic-auth@<version>"]
84
+ }
85
+ ```
48
86
 
49
87
  ## Authentication Methods
50
88
 
51
- - **Claude Pro/Max** - OAuth flow via `claude.ai` for Pro/Max subscribers. Uses your existing subscription at no additional API cost.
52
- - run the `/connect` command, select `Anthropic` -> `Claude Pro/Max` and do OAuth
53
- - **Manually enter API Key / `ANTHROPIC_API_KEY`** - Handled by OpenCode's built-in Anthropic integration, not by this plugin.
89
+ ### OpenCode v1
90
+
91
+ OpenCode v1 provides three authentication options:
92
+
93
+ - **Claude Pro/Max** — OAuth flow via `claude.ai` for Pro/Max subscribers. Uses your existing subscription at no additional API cost.
94
+ - Run `/connect`, select `Anthropic (API key)` -> `Claude Pro/Max`, and complete OAuth.
95
+ - **Create an API Key** — OAuth flow via `console.anthropic.com` that creates an API key on your behalf.
96
+ - **Manually enter API Key** — Standard API-key entry for users who already have one.
97
+
98
+ ### OpenCode v2
99
+
100
+ OpenCode v2 provides:
101
+
102
+ - **Claude Pro/Max** — OAuth flow via `claude.ai` for Pro/Max subscribers. Uses your existing subscription at no additional API cost.
103
+ - Run `/connect`, select `Anthropic` -> `Claude Pro/Max`, and complete OAuth.
104
+ - **Manually enter API Key / `ANTHROPIC_API_KEY`** — Handled by OpenCode's built-in Anthropic integration, not by this plugin.
54
105
 
55
106
  > [!NOTE]
56
- > The v1 release of this plugin also offered a "Create an API Key" OAuth flow (via `console.anthropic.com`) that minted and stored an API key for you. OpenCode v2's plugin API does not yet support an OAuth authorization flow that ends in a stored API key, so that flow isn't available in this v2 release. Use manual API key entry (or `ANTHROPIC_API_KEY`) in the meantime — see [issue #203](https://github.com/ex-machina-co/opencode-anthropic-auth/issues/203) for status.
57
- >
58
- > OpenCode v2 continues to display Anthropic's API prices for these models even though requests authenticated through Claude Pro/Max use the subscription. Dynamic cost display is deferred until the beta plugin API can safely cancel the required connection event subscription.
107
+ > OpenCode v1 also offers a "Create an API Key" OAuth flow that mints and stores an API key. OpenCode v2's plugin API cannot yet complete an OAuth flow by storing a generated API key, so that option is unavailable in v2. Use manual API-key entry or `ANTHROPIC_API_KEY` in the meantime; see [issue #203](https://github.com/ex-machina-co/opencode-anthropic-auth/issues/203) for status.
59
108
 
60
109
  ## Configuration
61
110
 
62
- The plugin supports the following environment variables:
111
+ The plugin reads the following environment variables:
112
+
113
+ - **`ANTHROPIC_BASE_URL`** — Overrides the Anthropic API endpoint for both release lines, such as when using a proxy. Must be a valid HTTP(S) URL.
114
+ - **`ANTHROPIC_INSECURE`** — Skips TLS certificate verification. Behavior differs by OpenCode version:
115
+ - **OpenCode v1** — Set to `1` or `true` to skip verification. Only effective when `ANTHROPIC_BASE_URL` is also set.
116
+ - **OpenCode v2** — Not supported. OpenCode v2 plugin request hooks cannot disable TLS verification. If set, the plugin logs a warning and leaves verification enabled; requests to an untrusted or self-signed `ANTHROPIC_BASE_URL` will fail.
117
+ - **`ANTHROPIC_CLAUDE_CODE_VERSION`** — Overrides the Claude Code version reported to Anthropic for both release lines. Must be `major.minor.patch` (for example, `2.1.275`). Defaults to the bundled version; a malformed value logs an actionable error without echoing its contents, and the bundled version is used instead. A value older than the bundled version is honored but logs a warning, since reporting an older version can make newer models reject the request. Read once when the plugin loads, so restart OpenCode after changing it.
63
118
 
64
- | Variable | Description |
65
- |-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
66
- | `ANTHROPIC_BASE_URL` | Override the API endpoint URL (e.g. for proxying). Must be a valid HTTP(S) URL. |
67
- | `ANTHROPIC_INSECURE` | **Not supported under OpenCode v2.** OpenCode v2 plugin request hooks can rewrite a request but cannot disable TLS verification for it. If this is set, the plugin logs a warning and leaves TLS verification enabled — requests to a self-signed/untrusted `ANTHROPIC_BASE_URL` will fail. |
119
+ Anthropic gates model access on the reported Claude Code version server-side, returning a 400 `claude_code_version_too_old` error for models that require a newer client. `ANTHROPIC_CLAUDE_CODE_VERSION` lets you raise the reported version without waiting for a plugin release.
68
120
 
69
121
  ## How It Works
70
122
 
71
- For Claude Pro/Max authentication, the plugin:
123
+ For Claude Pro/Max authentication, both release lines:
72
124
 
73
- 1. Initiates a PKCE OAuth flow against Anthropic's authorization endpoint
74
- 2. Exchanges the authorization code for access and refresh tokens
75
- 3. Automatically refreshes expired tokens
76
- 4. Injects the required OAuth headers and beta flags into API requests
77
- 5. Sanitizes the system prompt for compatibility (see below)
125
+ 1. Initiate a PKCE OAuth flow against Anthropic's authorization endpoint
126
+ 2. Exchange the authorization code for access and refresh tokens
127
+ 3. Automatically refresh expired tokens
128
+ 4. Inject the required OAuth headers and beta flags into API requests
129
+ 5. Sanitize the system prompt for compatibility (see below)
130
+
131
+ Model-cost display differs by OpenCode version:
132
+
133
+ - **OpenCode v1** — The plugin zeros out displayed model costs because usage is covered by the subscription.
134
+ - **OpenCode v2** — OpenCode continues to display Anthropic's API prices even though requests authenticated through Claude Pro/Max use the subscription. Dynamic cost display is deferred until the beta plugin API can safely cancel the required connection event subscription.
78
135
 
79
136
  ### System Prompt Sanitization
80
137
 
@@ -102,9 +159,14 @@ This does three things:
102
159
  2. Symlinks the build output into `.opencode/plugins/` so OpenCode loads it as a local plugin
103
160
  3. Starts `tsc --watch` for automatic rebuilds on source changes
104
161
 
105
- After starting the dev script, restart OpenCode v2 (`opencode2`) in this project directory to pick up the local build. Any edits to `src/` will trigger a rebuild — restart OpenCode again to load the new version.
162
+ After starting the dev script:
163
+
164
+ - On the `main` branch, restart OpenCode in this project directory.
165
+ - On the `v2/main` branch, restart OpenCode v2 (`opencode2`) in this project directory.
106
166
 
107
- You can confirm the plugin loaded correctly via the OpenCode v2 API:
167
+ Edits to `src/` trigger a rebuild; restart the corresponding OpenCode version again to load the new build.
168
+
169
+ You can confirm that the v2 plugin loaded through the OpenCode v2 API:
108
170
 
109
171
  ```bash
110
172
  opencode2 api get /api/plugin # should list "ex-machina.anthropic-auth"
@@ -128,9 +190,12 @@ This project uses [changesets](https://github.com/changesets/changesets) for ver
128
190
  bun change # create a changeset describing your changes
129
191
  ```
130
192
 
131
- Changesets merged to a release branch cause CI to open a release PR; merging that PR publishes to npm. This repository runs two release trains — `main` publishes the v1 line to npm's `latest`, and `v2/main` publishes the v2 line to `next` as `2.x.y-next.N` prereleases.
193
+ Changesets merged to a release branch cause CI to open a release PR; merging that PR publishes to npm. The repository has two release trains:
194
+
195
+ - `main` publishes plugin v1 for OpenCode v1 to npm's `latest` tag.
196
+ - `v2/main` publishes plugin v2 prereleases for OpenCode v2 to npm's `next` tag.
132
197
 
133
- Maintainers: see [RELEASING.md](RELEASING.md) for the full runbook, including how `main` is synced into `v2/main`.
198
+ Maintainers can find the complete two-train release and branch-sync process in the [release runbook](https://github.com/ex-machina-co/opencode-anthropic-auth/blob/v2/main/RELEASING.md).
134
199
 
135
200
  ## License
136
201
 
package/dist/auth.d.ts CHANGED
@@ -25,8 +25,8 @@ export type RefreshResult = {
25
25
  };
26
26
  /**
27
27
  * Exchange a refresh token for a new access/refresh token pair.
28
- * Retries transient (5xx, network) failures with exponential backoff;
29
- * non-transient failures (e.g. 403 on a revoked/rotated-away token)
30
- * are returned immediately as `{ type: 'failed' }`.
28
+ * Refresh tokens may rotate after a request reaches the provider. Retrying an
29
+ * ambiguous 5xx, timeout, network failure, or response-body failure can replay
30
+ * an already consumed token, so each call makes exactly one token request.
31
31
  */
32
32
  export declare function refreshToken(refreshTokenValue: string): Promise<RefreshResult>;
package/dist/auth.js CHANGED
@@ -1,6 +1,37 @@
1
+ import { BodyLimitError, contentLength, InvalidUtf8Error, readBoundedText, } from "./bounded.js";
1
2
  import { AUTHORIZE_URLS, CLIENT_ID, CODE_CALLBACK_URL, OAUTH_SCOPES, TOKEN_URL, } from "./constants.js";
2
3
  import { generatePKCE } from "./pkce.js";
3
- const REFRESH_TIMEOUT_MS = 30_000;
4
+ const TOKEN_TIMEOUT_MS = 30_000;
5
+ const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
6
+ const MAX_TOKEN_LENGTH = 8 * 1024;
7
+ const MAX_CALLBACK_INPUT_BYTES = 16 * 1024;
8
+ const MAX_VERIFIER_BYTES = 1024;
9
+ const MAX_REDIRECT_URI_BYTES = 2 * 1024;
10
+ function isWellFormedUtf16(value) {
11
+ for (let index = 0; index < value.length; index += 1) {
12
+ const unit = value.charCodeAt(index);
13
+ if (unit >= 0xd800 && unit <= 0xdbff) {
14
+ if (index + 1 >= value.length)
15
+ return false;
16
+ const next = value.charCodeAt(index + 1);
17
+ if (next < 0xdc00 || next > 0xdfff)
18
+ return false;
19
+ index += 1;
20
+ }
21
+ else if (unit >= 0xdc00 && unit <= 0xdfff) {
22
+ return false;
23
+ }
24
+ }
25
+ return true;
26
+ }
27
+ function isBoundedUtf8(value, maxBytes) {
28
+ if (value.length === 0 ||
29
+ value.length > maxBytes ||
30
+ !isWellFormedUtf16(value)) {
31
+ return false;
32
+ }
33
+ return new TextEncoder().encode(value).byteLength <= maxBytes;
34
+ }
4
35
  function isTokenResponse(value) {
5
36
  if (typeof value !== 'object' || value === null)
6
37
  return false;
@@ -9,16 +40,23 @@ function isTokenResponse(value) {
9
40
  if (!('expires_in' in value))
10
41
  return false;
11
42
  return (typeof value.refresh_token === 'string' &&
12
- value.refresh_token.length > 0 &&
43
+ isBoundedUtf8(value.refresh_token, MAX_TOKEN_LENGTH) &&
13
44
  typeof value.access_token === 'string' &&
14
- value.access_token.length > 0 &&
45
+ isBoundedUtf8(value.access_token, MAX_TOKEN_LENGTH) &&
15
46
  typeof value.expires_in === 'number' &&
16
47
  Number.isSafeInteger(value.expires_in) &&
17
48
  value.expires_in > 0);
18
49
  }
19
50
  async function parseTokenResponse(response) {
51
+ const declaredLength = contentLength(response.headers);
52
+ if (declaredLength !== undefined &&
53
+ declaredLength > MAX_TOKEN_RESPONSE_BYTES) {
54
+ await response.body?.cancel().catch(() => { });
55
+ return undefined;
56
+ }
20
57
  try {
21
- const value = await response.json();
58
+ const text = await readBoundedText(response.body, MAX_TOKEN_RESPONSE_BYTES, 'Anthropic token response');
59
+ const value = JSON.parse(text);
22
60
  if (!isTokenResponse(value))
23
61
  return undefined;
24
62
  const expires = Date.now() + value.expires_in * 1000;
@@ -31,11 +69,54 @@ async function parseTokenResponse(response) {
31
69
  };
32
70
  }
33
71
  catch (error) {
34
- if (error instanceof SyntaxError)
72
+ if (error instanceof SyntaxError ||
73
+ error instanceof BodyLimitError ||
74
+ error instanceof InvalidUtf8Error) {
35
75
  return undefined;
76
+ }
36
77
  throw error;
37
78
  }
38
79
  }
80
+ function isTransientNetworkError(error) {
81
+ const seen = new WeakSet();
82
+ let current = error;
83
+ for (let depth = 0; depth < 8; depth++) {
84
+ if (typeof current !== 'object' || current === null)
85
+ return false;
86
+ if (seen.has(current))
87
+ return false;
88
+ seen.add(current);
89
+ if ('name' in current &&
90
+ (current.name === 'TimeoutError' || current.name === 'AbortError')) {
91
+ return true;
92
+ }
93
+ if ('code' in current) {
94
+ const code = current.code;
95
+ if (code === 'ECONNRESET' ||
96
+ code === 'ECONNREFUSED' ||
97
+ code === 'ETIMEDOUT' ||
98
+ code === 'EPIPE' ||
99
+ code === 'ENETUNREACH' ||
100
+ code === 'EAI_AGAIN' ||
101
+ code === 'UND_ERR_CONNECT_TIMEOUT' ||
102
+ code === 'UND_ERR_SOCKET') {
103
+ return true;
104
+ }
105
+ }
106
+ const message = current instanceof Error ? current.message.toLowerCase() : '';
107
+ if (message === 'fetch failed' ||
108
+ message === 'terminated' ||
109
+ message === 'network error' ||
110
+ message.includes('socket hang up') ||
111
+ message.includes('other side closed')) {
112
+ return true;
113
+ }
114
+ if (!('cause' in current))
115
+ return false;
116
+ current = current.cause;
117
+ }
118
+ return false;
119
+ }
39
120
  function generateState() {
40
121
  return crypto.randomUUID().replace(/-/g, '');
41
122
  }
@@ -65,23 +146,34 @@ function parseCallbackInput(input) {
65
146
  return null;
66
147
  }
67
148
  async function exchangeCode(callback, verifier, redirectUri) {
68
- const result = await fetch(TOKEN_URL, {
69
- method: 'POST',
70
- headers: {
71
- 'Content-Type': 'application/json',
72
- Accept: 'application/json, text/plain, */*',
73
- 'User-Agent': 'axios/1.13.6',
74
- },
75
- body: JSON.stringify({
76
- code: callback.code,
77
- state: callback.state,
78
- grant_type: 'authorization_code',
79
- client_id: CLIENT_ID,
80
- redirect_uri: redirectUri,
81
- code_verifier: verifier,
82
- }),
83
- });
149
+ let result;
150
+ try {
151
+ result = await fetch(TOKEN_URL, {
152
+ method: 'POST',
153
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
154
+ redirect: 'error',
155
+ headers: {
156
+ 'Content-Type': 'application/json',
157
+ Accept: 'application/json, text/plain, */*',
158
+ 'User-Agent': 'axios/1.13.6',
159
+ },
160
+ body: JSON.stringify({
161
+ code: callback.code,
162
+ state: callback.state,
163
+ grant_type: 'authorization_code',
164
+ client_id: CLIENT_ID,
165
+ redirect_uri: redirectUri,
166
+ code_verifier: verifier,
167
+ }),
168
+ });
169
+ }
170
+ catch (error) {
171
+ if (isTransientNetworkError(error))
172
+ return { type: 'failed' };
173
+ throw error;
174
+ }
84
175
  if (!result.ok) {
176
+ await result.body?.cancel().catch(() => { });
85
177
  return {
86
178
  type: 'failed',
87
179
  };
@@ -114,6 +206,13 @@ export async function authorize(mode) {
114
206
  };
115
207
  }
116
208
  export async function exchange(input, verifier, redirectUri, expectedState) {
209
+ if (!isBoundedUtf8(input, MAX_CALLBACK_INPUT_BYTES) ||
210
+ !isBoundedUtf8(verifier, MAX_VERIFIER_BYTES) ||
211
+ !isBoundedUtf8(redirectUri, MAX_REDIRECT_URI_BYTES) ||
212
+ (expectedState !== undefined &&
213
+ !isBoundedUtf8(expectedState, MAX_TOKEN_LENGTH))) {
214
+ return { type: 'failed' };
215
+ }
117
216
  const callback = parseCallbackInput(input);
118
217
  if (!callback) {
119
218
  return {
@@ -125,73 +224,53 @@ export async function exchange(input, verifier, redirectUri, expectedState) {
125
224
  type: 'failed',
126
225
  };
127
226
  }
227
+ if (!isBoundedUtf8(callback.code, MAX_TOKEN_LENGTH) ||
228
+ !isBoundedUtf8(callback.state, MAX_TOKEN_LENGTH)) {
229
+ return { type: 'failed' };
230
+ }
128
231
  return exchangeCode(callback, verifier, redirectUri);
129
232
  }
130
233
  /**
131
234
  * Exchange a refresh token for a new access/refresh token pair.
132
- * Retries transient (5xx, network) failures with exponential backoff;
133
- * non-transient failures (e.g. 403 on a revoked/rotated-away token)
134
- * are returned immediately as `{ type: 'failed' }`.
235
+ * Refresh tokens may rotate after a request reaches the provider. Retrying an
236
+ * ambiguous 5xx, timeout, network failure, or response-body failure can replay
237
+ * an already consumed token, so each call makes exactly one token request.
135
238
  */
136
239
  export async function refreshToken(refreshTokenValue) {
137
- const maxRetries = 2;
138
- const baseDelayMs = 500;
139
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
140
- try {
141
- if (attempt > 0) {
142
- const delay = baseDelayMs * 2 ** (attempt - 1);
143
- await new Promise((resolve) => setTimeout(resolve, delay));
144
- }
145
- const response = await fetch(TOKEN_URL, {
146
- method: 'POST',
147
- signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
148
- headers: {
149
- 'Content-Type': 'application/json',
150
- Accept: 'application/json, text/plain, */*',
151
- 'User-Agent': 'axios/1.13.6',
152
- },
153
- body: JSON.stringify({
154
- grant_type: 'refresh_token',
155
- refresh_token: refreshTokenValue,
156
- client_id: CLIENT_ID,
157
- }),
158
- });
159
- if (!response.ok) {
160
- if (response.status >= 500 && attempt < maxRetries) {
161
- await response.body?.cancel();
162
- continue;
163
- }
164
- await response.body?.cancel();
165
- return { type: 'failed', status: response.status };
166
- }
167
- const tokens = await parseTokenResponse(response);
168
- if (!tokens) {
169
- return { type: 'failed', status: response.status };
170
- }
171
- return {
172
- type: 'success',
173
- ...tokens,
174
- };
175
- }
176
- catch (error) {
177
- const isNetworkError = (typeof error === 'object' &&
178
- error !== null &&
179
- 'name' in error &&
180
- (error.name === 'TimeoutError' || error.name === 'AbortError')) ||
181
- (error instanceof Error &&
182
- (error.message.includes('fetch failed') ||
183
- ('code' in error &&
184
- (error.code === 'ECONNRESET' ||
185
- error.code === 'ECONNREFUSED' ||
186
- error.code === 'ETIMEDOUT' ||
187
- error.code === 'UND_ERR_CONNECT_TIMEOUT'))));
188
- if (attempt < maxRetries && isNetworkError) {
189
- continue;
190
- }
191
- throw error;
240
+ if (!isBoundedUtf8(refreshTokenValue, MAX_TOKEN_LENGTH)) {
241
+ return { type: 'failed', status: 400 };
242
+ }
243
+ try {
244
+ const response = await fetch(TOKEN_URL, {
245
+ method: 'POST',
246
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
247
+ redirect: 'error',
248
+ headers: {
249
+ 'Content-Type': 'application/json',
250
+ Accept: 'application/json, text/plain, */*',
251
+ 'User-Agent': 'axios/1.13.6',
252
+ },
253
+ body: JSON.stringify({
254
+ grant_type: 'refresh_token',
255
+ refresh_token: refreshTokenValue,
256
+ client_id: CLIENT_ID,
257
+ }),
258
+ });
259
+ if (!response.ok) {
260
+ await response.body?.cancel().catch(() => { });
261
+ return { type: 'failed', status: response.status };
192
262
  }
263
+ const tokens = await parseTokenResponse(response);
264
+ if (!tokens)
265
+ return { type: 'failed', status: response.status };
266
+ return {
267
+ type: 'success',
268
+ ...tokens,
269
+ };
270
+ }
271
+ catch (error) {
272
+ if (isTransientNetworkError(error))
273
+ return { type: 'failed', status: 0 };
274
+ throw error;
193
275
  }
194
- // Unreachable — each iteration either returns or throws.
195
- // Kept as a TypeScript exhaustiveness guard.
196
- throw new Error('Token refresh exhausted all retries');
197
276
  }
@@ -0,0 +1,8 @@
1
+ export declare class BodyLimitError extends Error {
2
+ constructor(label: string, limit: number);
3
+ }
4
+ export declare class InvalidUtf8Error extends Error {
5
+ constructor(label: string);
6
+ }
7
+ export declare function contentLength(headers: Headers): number | undefined;
8
+ export declare function readBoundedText(body: ReadableStream<Uint8Array> | null, limit: number, label: string): Promise<string>;
@@ -0,0 +1,59 @@
1
+ export class BodyLimitError extends Error {
2
+ constructor(label, limit) {
3
+ super(`${label} exceeds ${limit} byte limit`);
4
+ this.name = 'BodyLimitError';
5
+ }
6
+ }
7
+ export class InvalidUtf8Error extends Error {
8
+ constructor(label) {
9
+ super(`${label} is not valid UTF-8`);
10
+ this.name = 'InvalidUtf8Error';
11
+ }
12
+ }
13
+ export function contentLength(headers) {
14
+ const raw = headers.get('content-length');
15
+ if (!raw || !/^\d+$/.test(raw))
16
+ return undefined;
17
+ const value = Number(raw);
18
+ return Number.isSafeInteger(value) ? value : undefined;
19
+ }
20
+ export async function readBoundedText(body, limit, label) {
21
+ if (!body)
22
+ return '';
23
+ const reader = body.getReader();
24
+ const decoder = new TextDecoder('utf-8', { fatal: true });
25
+ const parts = [];
26
+ let total = 0;
27
+ try {
28
+ while (true) {
29
+ const { done, value } = await reader.read();
30
+ if (done)
31
+ break;
32
+ total += value.byteLength;
33
+ if (total > limit) {
34
+ await reader.cancel().catch(() => { });
35
+ throw new BodyLimitError(label, limit);
36
+ }
37
+ try {
38
+ parts.push(decoder.decode(value, { stream: true }));
39
+ }
40
+ catch {
41
+ throw new InvalidUtf8Error(label);
42
+ }
43
+ }
44
+ try {
45
+ parts.push(decoder.decode());
46
+ }
47
+ catch {
48
+ throw new InvalidUtf8Error(label);
49
+ }
50
+ return parts.join('');
51
+ }
52
+ catch (error) {
53
+ await reader.cancel(error).catch(() => { });
54
+ throw error;
55
+ }
56
+ finally {
57
+ reader.releaseLock();
58
+ }
59
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Environment variable that overrides the reported Claude Code version.
3
+ *
4
+ * Anthropic gates model access on the reported version server-side, and that
5
+ * gate moves on Anthropic's schedule rather than this plugin's release
6
+ * schedule. The override lets users unblock a newly-gated model without
7
+ * waiting for a published bump.
8
+ */
9
+ export declare const ANTHROPIC_CLAUDE_CODE_VERSION_ENV_VAR = "ANTHROPIC_CLAUDE_CODE_VERSION";
10
+ /**
11
+ * Outcome of reading the version override.
12
+ *
13
+ * The invalid arm carries no version: a malformed override must never reach
14
+ * the request path, so callers cannot accidentally report one. The outdated
15
+ * arm does carry one — an explicit older version is still honoured — but pairs
16
+ * it with the warning explaining why reporting it is risky.
17
+ */
18
+ export type ClaudeCodeVersionResolution = {
19
+ readonly type: 'success';
20
+ readonly version: string;
21
+ } | {
22
+ readonly type: 'outdated';
23
+ readonly version: string;
24
+ readonly warning: string;
25
+ } | {
26
+ readonly type: 'invalid';
27
+ readonly error: string;
28
+ };
29
+ /**
30
+ * Resolve the Claude Code version to report to Anthropic.
31
+ *
32
+ * Returns the bundled version when the override is unset. A set override is
33
+ * trimmed and must look like a Claude Code release; anything else resolves to
34
+ * `invalid` with a message describing how to correct it. An override older
35
+ * than the bundled version resolves to `outdated`: it is still reported, since
36
+ * it was set deliberately, but it can lock the user out of newer models.
37
+ * Never throws.
38
+ */
39
+ export declare function resolveClaudeCodeVersion(raw?: string | undefined): ClaudeCodeVersionResolution;