opencode-claude-auth 2.1.5 → 2.2.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 +15 -9
- package/dist/credentials.d.ts +65 -0
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +307 -26
- package/dist/credentials.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -3
- package/dist/index.js.map +1 -1
- package/dist/model-config.js +9 -9
- package/dist/model-config.js.map +1 -1
- package/dist/refresh-backoff.d.ts +49 -0
- package/dist/refresh-backoff.d.ts.map +1 -0
- package/dist/refresh-backoff.js +99 -0
- package/dist/refresh-backoff.js.map +1 -0
- package/dist/refresh-lock.d.ts +22 -0
- package/dist/refresh-lock.d.ts.map +1 -0
- package/dist/refresh-lock.js +117 -0
- package/dist/refresh-lock.js.map +1 -0
- package/dist/transforms.d.ts +41 -1
- package/dist/transforms.d.ts.map +1 -1
- package/dist/transforms.js +238 -46
- package/dist/transforms.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,11 +55,12 @@ Just run OpenCode. The plugin handles auth automatically — it reads your Claud
|
|
|
55
55
|
|
|
56
56
|
## Supported models
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
14 supported models. Run `pnpm run test:models` to verify against your account.
|
|
59
59
|
|
|
60
60
|
| Model |
|
|
61
61
|
| -------------------------- |
|
|
62
62
|
| claude-fable-5 |
|
|
63
|
+
| claude-fable-5-1 |
|
|
63
64
|
| claude-haiku-4-5 |
|
|
64
65
|
| claude-haiku-4-5-20251001 |
|
|
65
66
|
| claude-opus-4-5 |
|
|
@@ -151,14 +152,19 @@ This reads your stored credentials, calls Anthropic's OAuth token endpoint, and
|
|
|
151
152
|
|
|
152
153
|
All configurable parameters can be overridden via environment variables. If Anthropic changes something before we publish an update, set an env var and keep working:
|
|
153
154
|
|
|
154
|
-
| Variable
|
|
155
|
-
|
|
|
156
|
-
| `ANTHROPIC_CLI_VERSION`
|
|
157
|
-
| `ANTHROPIC_USER_AGENT`
|
|
158
|
-
| `ANTHROPIC_BETA_FLAGS`
|
|
159
|
-
| `CLAUDE_AUTH_DEBUG`
|
|
160
|
-
| `CLAUDE_CONFIG_DIR`
|
|
161
|
-
| `OPENCODE_CLAUDE_AUTH_MAX_RETRY_MS`
|
|
155
|
+
| Variable | Description | Default |
|
|
156
|
+
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
|
157
|
+
| `ANTHROPIC_CLI_VERSION` | Claude CLI version for user-agent and billing headers | `config.ccVersion` in [`src/model-config.ts`](src/model-config.ts) |
|
|
158
|
+
| `ANTHROPIC_USER_AGENT` | Full User-Agent string (overrides CLI version) | `claude-cli/{version} (external, sdk-cli)` |
|
|
159
|
+
| `ANTHROPIC_BETA_FLAGS` | Comma-separated beta feature flags | `baseBetas` list in [`src/model-config.ts`](src/model-config.ts) |
|
|
160
|
+
| `CLAUDE_AUTH_DEBUG` | Enable diagnostic logging (`1` for default path, or a custom file path) | disabled |
|
|
161
|
+
| `CLAUDE_CONFIG_DIR` | Claude Code config directory used for the credentials-file fallback (reads `$CLAUDE_CONFIG_DIR/.credentials.json`). macOS still checks the Keychain first. | `~/.claude` |
|
|
162
|
+
| `OPENCODE_CLAUDE_AUTH_MAX_RETRY_MS` | Max ms the plugin waits when honouring a 429/529 `retry-after` header. Beyond this cap the response surfaces immediately so OpenCode doesn't appear to hang on hour-long quota resets. | `30000` |
|
|
163
|
+
| `OPENCODE_CLAUDE_AUTH_TOOL_REPAIR` | Strategy for reconciling `tool_use`/`tool_result` adjacency broken by OpenCode auto-compaction. `placeholder` synthesizes a paired result for orphaned `tool_use` blocks (lossless, preserves `thinking` blocks); `drop` removes orphaned blocks (omitting whole thinking turns). | `placeholder` |
|
|
164
|
+
| `OPENCODE_CLAUDE_AUTH_REFRESH_WAIT_MS` | Max ms a single request waits through a transient token-refresh rate-limit (429) before returning a retryable error instead of a hard "run `claude`". | `45000` |
|
|
165
|
+
| `OPENCODE_CLAUDE_AUTH_REFRESH_COOLDOWN_MS` | Base per-account cooldown after a rate-limited refresh, before the plugin retries the token endpoint. Escalates with consecutive failures and is jittered; capped at 60s. | `15000` |
|
|
166
|
+
| `OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_TTL_MS` | TTL for the cross-process refresh lock. A held lock older than this is treated as stale (crashed holder) and taken over. | `20000` |
|
|
167
|
+
| `OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR` | Directory for the advisory cross-process refresh lock files. | OpenCode data dir (`~/.local/share/opencode`) |
|
|
162
168
|
|
|
163
169
|
Example:
|
|
164
170
|
|
package/dist/credentials.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ClaudeAccount, type ClaudeCredentials } from "./keychain.ts";
|
|
2
|
+
import { type RefreshFailureKind } from "./refresh-backoff.ts";
|
|
2
3
|
export type { ClaudeAccount } from "./keychain.ts";
|
|
3
4
|
export type { ClaudeCredentials } from "./keychain.ts";
|
|
4
5
|
export declare function initAccounts(accounts: ClaudeAccount[]): void;
|
|
@@ -11,6 +12,17 @@ export declare function syncAuthJson(creds: ClaudeCredentials): void;
|
|
|
11
12
|
export declare const OAUTH_TOKEN_URL = "https://claude.ai/v1/oauth/token";
|
|
12
13
|
export declare const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
13
14
|
export declare function parseOAuthResponse(raw: string, currentRefreshToken: string, now?: number): ClaudeCredentials | null;
|
|
15
|
+
/**
|
|
16
|
+
* Extract the non-secret failure reason from an OAuth token-endpoint error
|
|
17
|
+
* body so a refresh failure is diagnosable from the debug log. Handles both the
|
|
18
|
+
* OAuth shape (`{ error, error_description }`) and Anthropic's API error
|
|
19
|
+
* envelope (`{ error: { type, message } }`). Values are truncated and never
|
|
20
|
+
* include tokens; the logger additionally redacts anything JWT-shaped.
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractOAuthError(raw: string): {
|
|
23
|
+
oauthError?: string;
|
|
24
|
+
oauthErrorDescription?: string;
|
|
25
|
+
};
|
|
14
26
|
/**
|
|
15
27
|
* Exchanges a refresh token for fresh credentials using the runtime's own
|
|
16
28
|
* fetch.
|
|
@@ -23,6 +35,35 @@ export declare function parseOAuthResponse(raw: string, currentRefreshToken: str
|
|
|
23
35
|
* non-zero with empty stdout and silently fell through to the claude CLI.
|
|
24
36
|
* Node 18+ and Bun both expose a global fetch, so no subprocess is needed.
|
|
25
37
|
*/
|
|
38
|
+
/**
|
|
39
|
+
* Classified result of an OAuth refresh. A `transient` outcome (429/5xx/network
|
|
40
|
+
* /`rate_limit_error`) means the refresh token is still good and the caller
|
|
41
|
+
* should back off and retry rather than surface a hard error; a `terminal`
|
|
42
|
+
* outcome (`invalid_grant`, ...) means the refresh token is dead.
|
|
43
|
+
*/
|
|
44
|
+
export type RefreshOutcome = {
|
|
45
|
+
kind: "ok";
|
|
46
|
+
creds: ClaudeCredentials;
|
|
47
|
+
} | {
|
|
48
|
+
kind: "transient";
|
|
49
|
+
status: number;
|
|
50
|
+
oauthError?: string;
|
|
51
|
+
retryAfterMs?: number;
|
|
52
|
+
} | {
|
|
53
|
+
kind: "terminal";
|
|
54
|
+
status: number;
|
|
55
|
+
oauthError?: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Exchange a refresh token for fresh credentials and classify the result.
|
|
59
|
+
* See {@link RefreshOutcome}. Uses the runtime's own fetch (no subprocess).
|
|
60
|
+
*/
|
|
61
|
+
export declare function refreshViaOAuthDetailed(refreshToken: string, timeoutMs?: number): Promise<RefreshOutcome>;
|
|
62
|
+
/**
|
|
63
|
+
* Backward-compatible wrapper: returns credentials on success, else null.
|
|
64
|
+
* Prefer {@link refreshViaOAuthDetailed} when the transient/terminal
|
|
65
|
+
* distinction matters (cooldown, CLI-fallback gating).
|
|
66
|
+
*/
|
|
26
67
|
export declare function refreshViaOAuth(refreshToken: string, timeoutMs?: number): Promise<ClaudeCredentials | null>;
|
|
27
68
|
/**
|
|
28
69
|
* Refreshes the given (or active) account's credentials if they are within
|
|
@@ -64,5 +105,29 @@ export declare function forceRefreshActiveAccount(refresh?: (refreshToken: strin
|
|
|
64
105
|
*/
|
|
65
106
|
export declare function invalidateCredentialCache(): void;
|
|
66
107
|
export declare function getCachedCredentials(): Promise<ClaudeCredentials | null>;
|
|
108
|
+
export interface CredentialWaitOptions {
|
|
109
|
+
maxWaitMs?: number;
|
|
110
|
+
pollMs?: number;
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
now?: () => number;
|
|
113
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
114
|
+
rng?: () => number;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Resolve credentials, waiting through a transient refresh rate-limit rather
|
|
118
|
+
* than failing hard. Returns as soon as a token is available — ours refreshed
|
|
119
|
+
* once the cooldown clears, or a sibling OpenCode instance / the `claude` CLI
|
|
120
|
+
* wrote a fresh one to the shared store. Returns null promptly on a terminal
|
|
121
|
+
* failure (dead refresh token) or when the wait budget is exhausted, so the
|
|
122
|
+
* caller can decide between a retryable response and a hard error.
|
|
123
|
+
*/
|
|
124
|
+
export declare function getCredentialsWithBackoff(opts?: CredentialWaitOptions): Promise<ClaudeCredentials | null>;
|
|
125
|
+
/**
|
|
126
|
+
* Whether the active account's most recent refresh failure was transient
|
|
127
|
+
* (rate-limited/retryable) or terminal (dead refresh token), for callers
|
|
128
|
+
* deciding between a retryable response and a hard "re-authenticate" error.
|
|
129
|
+
* An active cooldown implies a transient failure.
|
|
130
|
+
*/
|
|
131
|
+
export declare function getActiveRefreshFailureKind(): RefreshFailureKind | null;
|
|
67
132
|
export declare function reloadCredentialsFromSource(): ClaudeCredentials | null;
|
|
68
133
|
//# sourceMappingURL=credentials.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"AAUA,OAAO,EAKL,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACvB,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"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"}
|
package/dist/credentials.js
CHANGED
|
@@ -6,6 +6,8 @@ import { PRIMARY_SERVICE, readAllClaudeAccounts, refreshAccount, writeBackCreden
|
|
|
6
6
|
import { resetExcludedBetas } from "./betas.js";
|
|
7
7
|
import { fetchWithRetry } from "./http.js";
|
|
8
8
|
import { log } from "./logger.js";
|
|
9
|
+
import { classifyRefreshFailure, clearRefreshOutcome, getRefreshCooldownUntil, getRefreshFailureKind, isRefreshCooldownActive, noteRefreshTerminal, noteRefreshTransient, } from "./refresh-backoff.js";
|
|
10
|
+
import { acquireRefreshLock } from "./refresh-lock.js";
|
|
9
11
|
const CREDENTIAL_CACHE_TTL_MS = 30_000;
|
|
10
12
|
// Only inside this window will the claude CLI actually rotate a token, so
|
|
11
13
|
// it is also the only window where spawning it is worth a real API request.
|
|
@@ -146,26 +148,72 @@ export function parseOAuthResponse(raw, currentRefreshToken, now = Date.now()) {
|
|
|
146
148
|
}
|
|
147
149
|
if (!data.access_token)
|
|
148
150
|
return null;
|
|
151
|
+
// Prefer an absolute `expires_at` (ms) when the endpoint provides one, but
|
|
152
|
+
// only if it is a future millisecond timestamp — a seconds-precision value
|
|
153
|
+
// would land in 1970 and read as already-expired, so fall back to the
|
|
154
|
+
// relative `expires_in` (or a conservative default) in that case.
|
|
155
|
+
const expiresAt = typeof data.expires_at === "number" && data.expires_at > now
|
|
156
|
+
? Math.trunc(data.expires_at)
|
|
157
|
+
: Math.trunc(now + (data.expires_in ?? 36_000) * 1000);
|
|
149
158
|
return {
|
|
150
159
|
accessToken: data.access_token,
|
|
151
160
|
refreshToken: data.refresh_token ?? currentRefreshToken,
|
|
152
|
-
expiresAt
|
|
161
|
+
expiresAt,
|
|
153
162
|
};
|
|
154
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Extract the non-secret failure reason from an OAuth token-endpoint error
|
|
166
|
+
* body so a refresh failure is diagnosable from the debug log. Handles both the
|
|
167
|
+
* OAuth shape (`{ error, error_description }`) and Anthropic's API error
|
|
168
|
+
* envelope (`{ error: { type, message } }`). Values are truncated and never
|
|
169
|
+
* include tokens; the logger additionally redacts anything JWT-shaped.
|
|
170
|
+
*/
|
|
171
|
+
export function extractOAuthError(raw) {
|
|
172
|
+
let data;
|
|
173
|
+
try {
|
|
174
|
+
data = JSON.parse(raw);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
// JSON.parse succeeds for primitives and arrays too (`null`, `123`, `"str"`,
|
|
180
|
+
// `[...]`); dereferencing `data.error` on those would throw and, worse,
|
|
181
|
+
// escape into refreshViaOAuthDetailed's outer catch — erasing the HTTP status
|
|
182
|
+
// this function exists to preserve. Only object bodies carry an error shape.
|
|
183
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
const out = {};
|
|
187
|
+
if (typeof data.error === "string") {
|
|
188
|
+
out.oauthError = data.error.slice(0, 200);
|
|
189
|
+
}
|
|
190
|
+
else if (data.error && typeof data.error === "object") {
|
|
191
|
+
const nested = data.error;
|
|
192
|
+
if (typeof nested.type === "string")
|
|
193
|
+
out.oauthError = nested.type.slice(0, 200);
|
|
194
|
+
if (typeof nested.message === "string") {
|
|
195
|
+
out.oauthErrorDescription = nested.message.slice(0, 500);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// The flat OAuth-standard `error_description` is canonical, so it deliberately
|
|
199
|
+
// wins over a nested-envelope `message` when a response carries both.
|
|
200
|
+
if (typeof data.error_description === "string") {
|
|
201
|
+
out.oauthErrorDescription = data.error_description.slice(0, 500);
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
155
205
|
const OAUTH_TIMEOUT_MS = 15_000;
|
|
206
|
+
function parseRetryAfterMs(headerValue) {
|
|
207
|
+
if (!headerValue)
|
|
208
|
+
return undefined;
|
|
209
|
+
const seconds = Number.parseInt(headerValue, 10);
|
|
210
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
|
|
211
|
+
}
|
|
156
212
|
/**
|
|
157
|
-
*
|
|
158
|
-
* fetch.
|
|
159
|
-
*
|
|
160
|
-
* This previously ran the request inside a child process spawned as
|
|
161
|
-
* `process.execPath -e <script>`. That assumed process.execPath is a
|
|
162
|
-
* JavaScript runtime, which does not hold inside OpenCode: the plugin runs
|
|
163
|
-
* in a compiled single-file executable, so process.execPath is the OpenCode
|
|
164
|
-
* binary itself and `-e` is not a script to evaluate. Every refresh exited
|
|
165
|
-
* non-zero with empty stdout and silently fell through to the claude CLI.
|
|
166
|
-
* Node 18+ and Bun both expose a global fetch, so no subprocess is needed.
|
|
213
|
+
* Exchange a refresh token for fresh credentials and classify the result.
|
|
214
|
+
* See {@link RefreshOutcome}. Uses the runtime's own fetch (no subprocess).
|
|
167
215
|
*/
|
|
168
|
-
export async function
|
|
216
|
+
export async function refreshViaOAuthDetailed(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS) {
|
|
169
217
|
const body = new URLSearchParams({
|
|
170
218
|
grant_type: "refresh_token",
|
|
171
219
|
client_id: OAUTH_CLIENT_ID,
|
|
@@ -175,10 +223,6 @@ export async function refreshViaOAuth(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS
|
|
|
175
223
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
176
224
|
try {
|
|
177
225
|
log("refresh_started", { source: "oauth" });
|
|
178
|
-
// The token endpoint rate-limits valid refresh requests, and several
|
|
179
|
-
// OpenCode instances refreshing near expiry cluster their calls, so a
|
|
180
|
-
// 429 here is transient rather than terminal. The shared helper caps
|
|
181
|
-
// its own backoff, and the abort signal bounds the whole sequence.
|
|
182
226
|
const response = await fetchWithRetry(OAUTH_TOKEN_URL, {
|
|
183
227
|
method: "POST",
|
|
184
228
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
@@ -186,34 +230,63 @@ export async function refreshViaOAuth(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS
|
|
|
186
230
|
signal: controller.signal,
|
|
187
231
|
});
|
|
188
232
|
if (!response.ok) {
|
|
233
|
+
// Capture the token endpoint's own failure reason (invalid_grant,
|
|
234
|
+
// invalid_client, rate_limit_error, ...) so a persistent 401 is
|
|
235
|
+
// diagnosable rather than an opaque "HTTP 400".
|
|
236
|
+
const detail = extractOAuthError(await response.text().catch(() => ""));
|
|
237
|
+
const kind = classifyRefreshFailure(response.status, detail.oauthError);
|
|
238
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
|
|
189
239
|
log("refresh_failed", {
|
|
190
240
|
source: "oauth",
|
|
191
241
|
error: `HTTP ${response.status}`,
|
|
242
|
+
kind,
|
|
243
|
+
...detail,
|
|
192
244
|
});
|
|
193
|
-
return
|
|
245
|
+
return kind === "terminal"
|
|
246
|
+
? { kind, status: response.status, oauthError: detail.oauthError }
|
|
247
|
+
: {
|
|
248
|
+
kind,
|
|
249
|
+
status: response.status,
|
|
250
|
+
oauthError: detail.oauthError,
|
|
251
|
+
retryAfterMs,
|
|
252
|
+
};
|
|
194
253
|
}
|
|
195
254
|
const creds = parseOAuthResponse(await response.text(), refreshToken);
|
|
196
255
|
if (!creds) {
|
|
256
|
+
// A 200 we cannot parse is an endpoint hiccup, not a dead token — treat
|
|
257
|
+
// it as transient so a retry can recover.
|
|
197
258
|
log("refresh_failed", {
|
|
198
259
|
source: "oauth",
|
|
199
260
|
error: "no access_token in response",
|
|
261
|
+
kind: "transient",
|
|
200
262
|
});
|
|
201
|
-
return
|
|
263
|
+
return { kind: "transient", status: response.status };
|
|
202
264
|
}
|
|
203
265
|
log("refresh_success", { source: "oauth" });
|
|
204
|
-
return creds;
|
|
266
|
+
return { kind: "ok", creds };
|
|
205
267
|
}
|
|
206
268
|
catch (err) {
|
|
269
|
+
// Network error / abort: transient by nature.
|
|
207
270
|
log("refresh_failed", {
|
|
208
271
|
source: "oauth",
|
|
209
272
|
error: err instanceof Error ? err.message : String(err),
|
|
273
|
+
kind: "transient",
|
|
210
274
|
});
|
|
211
|
-
return
|
|
275
|
+
return { kind: "transient", status: 0 };
|
|
212
276
|
}
|
|
213
277
|
finally {
|
|
214
278
|
clearTimeout(timer);
|
|
215
279
|
}
|
|
216
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Backward-compatible wrapper: returns credentials on success, else null.
|
|
283
|
+
* Prefer {@link refreshViaOAuthDetailed} when the transient/terminal
|
|
284
|
+
* distinction matters (cooldown, CLI-fallback gating).
|
|
285
|
+
*/
|
|
286
|
+
export async function refreshViaOAuth(refreshToken, timeoutMs = OAUTH_TIMEOUT_MS) {
|
|
287
|
+
const outcome = await refreshViaOAuthDetailed(refreshToken, timeoutMs);
|
|
288
|
+
return outcome.kind === "ok" ? outcome.creds : null;
|
|
289
|
+
}
|
|
217
290
|
function refreshViaCli(configDir, requireConfigDir = false) {
|
|
218
291
|
if (requireConfigDir && !configDir) {
|
|
219
292
|
log("refresh_cli_skipped", {
|
|
@@ -317,6 +390,22 @@ export async function refreshIfNeeded(account, thresholdMs = 60_000) {
|
|
|
317
390
|
const creds = target.credentials;
|
|
318
391
|
if (creds.expiresAt > Date.now() + thresholdMs)
|
|
319
392
|
return creds;
|
|
393
|
+
// If a recent refresh was rate-limited, don't re-hit the endpoint until the
|
|
394
|
+
// cooldown clears — adopt a sibling instance's / the CLI's fresh token if one
|
|
395
|
+
// has appeared, else defer. This is what stops N OpenCode instances from
|
|
396
|
+
// turning a single transient 429 into a sustained storm. Borrowed accounts
|
|
397
|
+
// are exempt: their recovery (refreshBorrowedAccount) is a distinct path.
|
|
398
|
+
if (!borrowedCredentialAccounts.has(target) &&
|
|
399
|
+
isRefreshCooldownActive(target.source)) {
|
|
400
|
+
const adopted = adoptFreshFromSource(target, creds.accessToken);
|
|
401
|
+
if (adopted)
|
|
402
|
+
return adopted;
|
|
403
|
+
log("refresh_cooldown_skip", {
|
|
404
|
+
source: target.source,
|
|
405
|
+
until: getRefreshCooldownUntil(target.source),
|
|
406
|
+
});
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
320
409
|
// The proactive sync timer calls this directly while the request path
|
|
321
410
|
// arrives via getCachedCredentials(). A rotation invalidates the refresh
|
|
322
411
|
// token it was issued against, so two concurrent refreshes would leave
|
|
@@ -326,7 +415,29 @@ export async function refreshIfNeeded(account, thresholdMs = 60_000) {
|
|
|
326
415
|
log("refresh_joined", { source: target.source });
|
|
327
416
|
return inFlight;
|
|
328
417
|
}
|
|
329
|
-
|
|
418
|
+
// Cross-process single-flight: only one OpenCode instance / the CLI should
|
|
419
|
+
// hit the token endpoint at a time. If another holds the lock, wait briefly
|
|
420
|
+
// and adopt its result rather than piling onto an already-strained endpoint.
|
|
421
|
+
const lock = acquireRefreshLock(target.source);
|
|
422
|
+
if (!lock) {
|
|
423
|
+
log("refresh_lock_busy", { source: target.source });
|
|
424
|
+
const adopted = await waitForAdopt(target, creds.accessToken);
|
|
425
|
+
if (adopted)
|
|
426
|
+
return adopted;
|
|
427
|
+
// The holder produced nothing within the window (likely crashed; its lock
|
|
428
|
+
// ages out by TTL). Defer rather than refresh lock-free, so we don't
|
|
429
|
+
// recreate the burst the lock exists to prevent — the request-level wait
|
|
430
|
+
// loop and the lock TTL drive eventual progress.
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const pending = (async () => {
|
|
434
|
+
try {
|
|
435
|
+
return await performRefresh(target, creds);
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
lock.release();
|
|
439
|
+
}
|
|
440
|
+
})();
|
|
330
441
|
inFlightRefreshes.set(target.source, pending);
|
|
331
442
|
try {
|
|
332
443
|
return await pending;
|
|
@@ -335,6 +446,54 @@ export async function refreshIfNeeded(account, thresholdMs = 60_000) {
|
|
|
335
446
|
inFlightRefreshes.delete(target.source);
|
|
336
447
|
}
|
|
337
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Re-read the account's own source and adopt a token another OpenCode instance
|
|
451
|
+
* or the `claude` CLI has just written. Returns the adopted credentials when
|
|
452
|
+
* the store now holds a distinct, still-valid token, else null.
|
|
453
|
+
*/
|
|
454
|
+
function adoptFreshFromSource(target, rejectedAccessToken) {
|
|
455
|
+
let stored = null;
|
|
456
|
+
try {
|
|
457
|
+
stored = refreshAccount(target.source, target.configDir);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
if (stored &&
|
|
463
|
+
stored.accessToken !== rejectedAccessToken &&
|
|
464
|
+
stored.expiresAt > Date.now() + 60_000) {
|
|
465
|
+
target.credentials = stored;
|
|
466
|
+
borrowedCredentialAccounts.delete(target);
|
|
467
|
+
clearRefreshOutcome(target.source);
|
|
468
|
+
log("refresh_adopted_from_source", { source: target.source });
|
|
469
|
+
return stored;
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
const LOCK_ADOPT_WAIT_MS = 5_000;
|
|
474
|
+
const LOCK_ADOPT_POLL_MS = 250;
|
|
475
|
+
/**
|
|
476
|
+
* Another instance holds the refresh lock and is presumably refreshing. Poll
|
|
477
|
+
* the shared store for the token it is about to write, up to a short budget,
|
|
478
|
+
* before giving up.
|
|
479
|
+
*/
|
|
480
|
+
async function waitForAdopt(target, rejectedAccessToken, opts = {}) {
|
|
481
|
+
const now = opts.now ?? Date.now;
|
|
482
|
+
const sleep = opts.sleep ?? ((ms) => sleepAbortable(ms));
|
|
483
|
+
const maxMs = opts.maxMs ?? LOCK_ADOPT_WAIT_MS;
|
|
484
|
+
const pollMs = opts.pollMs ?? LOCK_ADOPT_POLL_MS;
|
|
485
|
+
const immediate = adoptFreshFromSource(target, rejectedAccessToken);
|
|
486
|
+
if (immediate)
|
|
487
|
+
return immediate;
|
|
488
|
+
const deadline = now() + maxMs;
|
|
489
|
+
while (now() < deadline) {
|
|
490
|
+
await sleep(pollMs);
|
|
491
|
+
const adopted = adoptFreshFromSource(target, rejectedAccessToken);
|
|
492
|
+
if (adopted)
|
|
493
|
+
return adopted;
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
338
497
|
async function performRefresh(target, creds) {
|
|
339
498
|
if (borrowedCredentialAccounts.has(target)) {
|
|
340
499
|
return refreshBorrowedAccount(target);
|
|
@@ -345,10 +504,12 @@ async function performRefresh(target, creds) {
|
|
|
345
504
|
expiresIn: creds.expiresAt - Date.now(),
|
|
346
505
|
});
|
|
347
506
|
if (creds.refreshToken) {
|
|
348
|
-
const
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
507
|
+
const outcome = await refreshViaOAuthDetailed(creds.refreshToken);
|
|
508
|
+
if (outcome.kind === "ok" &&
|
|
509
|
+
outcome.creds.expiresAt > Date.now() + 60_000) {
|
|
510
|
+
clearRefreshOutcome(target.source);
|
|
511
|
+
target.credentials = outcome.creds;
|
|
512
|
+
if (!writeBackCredentials(target.source, outcome.creds, target.configDir, creds.accessToken)) {
|
|
352
513
|
// Mirrors force_refresh_writeback_failed on the forced path. The
|
|
353
514
|
// session continues from memory either way, so this stays a log
|
|
354
515
|
// rather than a control-flow change: acting on the two causes
|
|
@@ -357,7 +518,48 @@ async function performRefresh(target, creds) {
|
|
|
357
518
|
// the validated re-read — is tracked as a follow-up.
|
|
358
519
|
log("refresh_writeback_failed", { source: target.source });
|
|
359
520
|
}
|
|
360
|
-
return
|
|
521
|
+
return outcome.creds;
|
|
522
|
+
}
|
|
523
|
+
if (outcome.kind === "transient") {
|
|
524
|
+
// A rate-limit / 5xx / network blip: the refresh token is still valid.
|
|
525
|
+
// Back off so we (and our sibling OpenCode instances) stop hammering the
|
|
526
|
+
// endpoint, adopt a token another instance/CLI may have just written,
|
|
527
|
+
// and — crucially — do NOT spawn the claude CLI, which hits the same
|
|
528
|
+
// rate-limited endpoint and only deepens the limit.
|
|
529
|
+
const cooldownMs = noteRefreshTransient(target.source, {
|
|
530
|
+
retryAfterMs: outcome.retryAfterMs,
|
|
531
|
+
});
|
|
532
|
+
log("refresh_transient", {
|
|
533
|
+
source: target.source,
|
|
534
|
+
status: outcome.status,
|
|
535
|
+
oauthError: outcome.oauthError,
|
|
536
|
+
cooldownMs,
|
|
537
|
+
});
|
|
538
|
+
const adopted = adoptFreshFromSource(target, creds.accessToken);
|
|
539
|
+
if (adopted)
|
|
540
|
+
return adopted;
|
|
541
|
+
// Keep serving still-usable credentials on the proactive path.
|
|
542
|
+
if (creds.expiresAt > Date.now() + CLI_FALLBACK_THRESHOLD_MS)
|
|
543
|
+
return creds;
|
|
544
|
+
// Borrow a sibling account's still-valid token rather than spawning the
|
|
545
|
+
// claude CLI, which hits the same rate-limited endpoint.
|
|
546
|
+
const borrowed = tryFallbackAccount(target.source);
|
|
547
|
+
if (borrowed) {
|
|
548
|
+
target.credentials = borrowed;
|
|
549
|
+
borrowedCredentialAccounts.add(target);
|
|
550
|
+
return borrowed;
|
|
551
|
+
}
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
if (outcome.kind === "terminal") {
|
|
555
|
+
// The refresh token itself is dead (invalid_grant, ...). Fall through to
|
|
556
|
+
// the CLI fallback / borrowed-account recovery below.
|
|
557
|
+
noteRefreshTerminal(target.source);
|
|
558
|
+
log("refresh_terminal", {
|
|
559
|
+
source: target.source,
|
|
560
|
+
status: outcome.status,
|
|
561
|
+
oauthError: outcome.oauthError,
|
|
562
|
+
});
|
|
361
563
|
}
|
|
362
564
|
}
|
|
363
565
|
// The claude CLI only rotates a token that is itself close to expiry, so
|
|
@@ -650,6 +852,85 @@ export async function getCachedCredentials() {
|
|
|
650
852
|
accountCacheMap.set(account.source, { creds: fresh, cachedAt: Date.now() });
|
|
651
853
|
return fresh;
|
|
652
854
|
}
|
|
855
|
+
/** Max time a single request will wait through a transient refresh rate-limit. */
|
|
856
|
+
const REFRESH_WAIT_MS = (() => {
|
|
857
|
+
const raw = process.env.OPENCODE_CLAUDE_AUTH_REFRESH_WAIT_MS;
|
|
858
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
859
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 45_000;
|
|
860
|
+
})();
|
|
861
|
+
const REFRESH_POLL_MS = 2_500;
|
|
862
|
+
function sleepAbortable(ms, signal) {
|
|
863
|
+
return new Promise((resolve) => {
|
|
864
|
+
if (signal?.aborted) {
|
|
865
|
+
resolve();
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const done = () => {
|
|
869
|
+
clearTimeout(timer);
|
|
870
|
+
signal?.removeEventListener("abort", done);
|
|
871
|
+
resolve();
|
|
872
|
+
};
|
|
873
|
+
const timer = setTimeout(done, ms);
|
|
874
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Resolve credentials, waiting through a transient refresh rate-limit rather
|
|
879
|
+
* than failing hard. Returns as soon as a token is available — ours refreshed
|
|
880
|
+
* once the cooldown clears, or a sibling OpenCode instance / the `claude` CLI
|
|
881
|
+
* wrote a fresh one to the shared store. Returns null promptly on a terminal
|
|
882
|
+
* failure (dead refresh token) or when the wait budget is exhausted, so the
|
|
883
|
+
* caller can decide between a retryable response and a hard error.
|
|
884
|
+
*/
|
|
885
|
+
export async function getCredentialsWithBackoff(opts = {}) {
|
|
886
|
+
const first = await getCachedCredentials();
|
|
887
|
+
if (first)
|
|
888
|
+
return first;
|
|
889
|
+
const source = getActiveAccount()?.source;
|
|
890
|
+
// No active account means no in-progress refresh could ever produce a token,
|
|
891
|
+
// so waiting is pointless — fail fast instead of spinning the wait budget.
|
|
892
|
+
if (!source)
|
|
893
|
+
return null;
|
|
894
|
+
// A dead refresh token will not fix itself by waiting.
|
|
895
|
+
if (getRefreshFailureKind(source) === "terminal")
|
|
896
|
+
return null;
|
|
897
|
+
const now = opts.now ?? Date.now;
|
|
898
|
+
const sleep = opts.sleep ?? sleepAbortable;
|
|
899
|
+
const rng = opts.rng ?? Math.random;
|
|
900
|
+
const maxWaitMs = opts.maxWaitMs ?? REFRESH_WAIT_MS;
|
|
901
|
+
const pollMs = opts.pollMs ?? REFRESH_POLL_MS;
|
|
902
|
+
const deadline = now() + maxWaitMs;
|
|
903
|
+
log("fetch_credentials_wait", { source: source ?? null, maxWaitMs });
|
|
904
|
+
while (now() < deadline) {
|
|
905
|
+
if (opts.signal?.aborted)
|
|
906
|
+
return null;
|
|
907
|
+
// Jittered poll so sibling instances desynchronize their re-reads.
|
|
908
|
+
await sleep(Math.round(pollMs * (0.5 + rng() * 0.5)), opts.signal);
|
|
909
|
+
if (opts.signal?.aborted)
|
|
910
|
+
return null;
|
|
911
|
+
const creds = await getCachedCredentials();
|
|
912
|
+
if (creds)
|
|
913
|
+
return creds;
|
|
914
|
+
if (source && getRefreshFailureKind(source) === "terminal")
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Whether the active account's most recent refresh failure was transient
|
|
921
|
+
* (rate-limited/retryable) or terminal (dead refresh token), for callers
|
|
922
|
+
* deciding between a retryable response and a hard "re-authenticate" error.
|
|
923
|
+
* An active cooldown implies a transient failure.
|
|
924
|
+
*/
|
|
925
|
+
export function getActiveRefreshFailureKind() {
|
|
926
|
+
const source = getActiveAccount()?.source;
|
|
927
|
+
if (!source)
|
|
928
|
+
return null;
|
|
929
|
+
const kind = getRefreshFailureKind(source);
|
|
930
|
+
if (kind === "transient" || isRefreshCooldownActive(source))
|
|
931
|
+
return "transient";
|
|
932
|
+
return kind;
|
|
933
|
+
}
|
|
653
934
|
export function reloadCredentialsFromSource() {
|
|
654
935
|
const account = getActiveAccount();
|
|
655
936
|
if (!account)
|