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 CHANGED
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * Auth Commands:
9
9
  * login Add a new account via browser OAuth flow
10
- * logout <N> Revoke tokens and remove account N
11
- * logout --all Revoke all tokens and clear all accounts
10
+ * logout <N> Remove account N (discards its tokens)
11
+ * logout --all Remove all accounts (discards their tokens)
12
12
  * reauth <N> Re-authenticate account N with fresh OAuth tokens
13
13
  * refresh <N> Attempt token refresh (no browser needed)
14
14
  *
@@ -31,7 +31,8 @@
31
31
  import { loadAccounts, saveAccounts, getStoragePath, createDefaultStats } from "./lib/storage.mjs";
32
32
  import { loadConfig, saveConfig, getConfigPath, VALID_STRATEGIES } from "./lib/config.mjs";
33
33
  import { authorize, exchange, refreshToken, revoke } from "./lib/oauth.mjs";
34
- import { adjustActiveIndexAfterRemoval } from "./lib/account-state.mjs";
34
+ import { adjustActiveIndexAfterRemoval, isTokenExpired } from "./lib/account-state.mjs";
35
+ import { warnOnEphemeralTokenRotation } from "./lib/accounts.mjs";
35
36
  import { AsyncLocalStorage } from "node:async_hooks";
36
37
  import { writeFile } from "node:fs/promises";
37
38
  import path from "node:path";
@@ -150,9 +151,16 @@ function rpad(str, width) {
150
151
  */
151
152
  export async function refreshAccessToken(account) {
152
153
  try {
153
- const json = await refreshToken(account.refreshToken, { signal: AbortSignal.timeout(5000) });
154
+ // `clientId` is undefined for every normal account, which means "use the
155
+ // default". Only a CLAUDE_CODE_OAUTH_CLIENT_ID-seeded env account carries one
156
+ // (contract §5.2, divergence D10).
157
+ const json = await refreshToken(account.refreshToken, {
158
+ signal: AbortSignal.timeout(5000),
159
+ clientId: account.clientId,
160
+ });
154
161
  account.access = json.access_token;
155
162
  account.expires = Date.now() + json.expires_in * 1000;
163
+ warnOnEphemeralTokenRotation(account, json.refresh_token);
156
164
  if (json.refresh_token) account.refreshToken = json.refresh_token;
157
165
  account.token_updated_at = Date.now();
158
166
  return json.access_token;
@@ -194,7 +202,7 @@ export async function ensureTokenAndFetchUsage(account) {
194
202
  let token = account.access;
195
203
  let tokenRefreshed = false;
196
204
 
197
- if (!token || !account.expires || account.expires < Date.now()) {
205
+ if (!token || isTokenExpired(account.expires)) {
198
206
  token = await refreshAccessToken(account);
199
207
  tokenRefreshed = !!token;
200
208
  if (!token) return { usage: null, tokenRefreshed: false };
@@ -301,7 +309,19 @@ function openBrowser(url) {
301
309
  * @returns {Promise<{refresh: string, access: string, expires: number, email?: string} | null>}
302
310
  */
303
311
  async function runOAuthFlow() {
304
- const { url, verifier } = await authorize("max");
312
+ /** @type {{url: string, verifier: string}} */
313
+ let authorization;
314
+ try {
315
+ // `authorize()` rejects on a configuration problem before it does any work —
316
+ // an unapproved CLAUDE_CODE_CUSTOM_OAUTH_URL, or an approved one this plugin
317
+ // cannot honour. Those messages are written for the operator, so print them
318
+ // as an error rather than letting the promise reject into a stack trace.
319
+ authorization = await authorize("max");
320
+ } catch (err) {
321
+ console.error(c.red(`Error: ${err?.message ?? err}`));
322
+ return null;
323
+ }
324
+ const { url, verifier } = authorization;
305
325
 
306
326
  console.log("");
307
327
  console.log(c.bold("Opening browser for Anthropic OAuth login..."));
@@ -412,7 +432,28 @@ export async function cmdLogin() {
412
432
  }
413
433
 
414
434
  /**
415
- * Logout: revoke tokens and remove an account, or all accounts.
435
+ * Turn a `revoke()` outcome into a line that says what actually happened.
436
+ *
437
+ * Three outcomes, three messages. Collapsing "we did not try" and "we tried and
438
+ * it failed" into one line tells a user who opted into server-side revocation
439
+ * that their token was merely discarded locally, when in fact it is still live
440
+ * on the server. That is the one thing this function exists to prevent.
441
+ *
442
+ * @param {{ attempted: boolean, ok: boolean, status?: number, error?: string }} outcome
443
+ * @returns {string}
444
+ * @see docs/oauth-2.1.280-contract.md §7 divergence D1
445
+ */
446
+ export function describeRevokeOutcome(outcome) {
447
+ if (!outcome?.attempted) {
448
+ return "Token discarded locally (server-side revocation is off; set oauth.revoke_on_logout to enable).";
449
+ }
450
+ if (outcome.ok) return "Token revoked server-side.";
451
+ const detail = outcome.status ? `HTTP ${outcome.status}` : outcome.error || "unknown error";
452
+ return `Server-side revocation failed (${detail}). Token removed locally; it may still be valid.`;
453
+ }
454
+
455
+ /**
456
+ * Logout: discard tokens and remove an account, or all accounts.
416
457
  * @param {string} arg - Account number
417
458
  * @param {object} [opts]
418
459
  * @param {boolean} [opts.force] Skip confirmation prompt
@@ -453,7 +494,7 @@ export async function cmdLogout(arg, opts = {}) {
453
494
  const rl = createInterface({ input: stdin, output: stdout });
454
495
  try {
455
496
  const answer = await rl.question(
456
- `Logout account #${n} (${label})? This will revoke tokens and remove the account. [y/N]: `,
497
+ `Logout account #${n} (${label})? This will discard its tokens and remove the account. [y/N]: `,
457
498
  );
458
499
  if (answer.trim().toLowerCase() !== "y") {
459
500
  console.log(c.dim("Cancelled."));
@@ -464,13 +505,11 @@ export async function cmdLogout(arg, opts = {}) {
464
505
  }
465
506
  }
466
507
 
467
- // Attempt token revocation (best-effort)
468
- const revoked = await revoke(stored.accounts[idx].refreshToken);
469
- if (revoked) {
470
- console.log(c.dim("Token revoked server-side."));
471
- } else {
472
- console.log(c.dim("Token revocation skipped (server may not support it)."));
473
- }
508
+ // Server-side revocation is opt-in (oauth.revoke_on_logout, default off): the
509
+ // genuine client has no revoke endpoint. Report what actually happened rather
510
+ // than implying a network call that was never made.
511
+ const outcome = await revoke(stored.accounts[idx].refreshToken);
512
+ console.log(c.dim(describeRevokeOutcome(outcome)));
474
513
 
475
514
  // Remove the account
476
515
  stored.accounts.splice(idx, 1);
@@ -491,7 +530,7 @@ export async function cmdLogout(arg, opts = {}) {
491
530
  }
492
531
 
493
532
  /**
494
- * Logout all accounts: revoke all tokens and clear storage.
533
+ * Logout all accounts: discard all tokens and clear storage.
495
534
  * @param {object} [opts]
496
535
  * @param {boolean} [opts.force] Skip confirmation prompt
497
536
  * @returns {Promise<number>} exit code
@@ -514,7 +553,7 @@ async function cmdLogoutAll(opts = {}) {
514
553
  const rl = createInterface({ input: stdin, output: stdout });
515
554
  try {
516
555
  const answer = await rl.question(
517
- `Logout all ${count} account(s)? This will revoke tokens and remove all accounts. [y/N]: `,
556
+ `Logout all ${count} account(s)? This will discard their tokens and remove all accounts. [y/N]: `,
518
557
  );
519
558
  if (answer.trim().toLowerCase() !== "y") {
520
559
  console.log(c.dim("Cancelled."));
@@ -525,12 +564,26 @@ async function cmdLogoutAll(opts = {}) {
525
564
  }
526
565
  }
527
566
 
528
- // Attempt token revocation for each account (best-effort, in parallel)
567
+ // Server-side revocation is opt-in (oauth.revoke_on_logout, default off).
529
568
  const results = await Promise.allSettled(stored.accounts.map((acc) => revoke(acc.refreshToken)));
530
- const revokedCount = results.filter((r) => r.status === "fulfilled" && r.value === true).length;
531
-
532
- if (revokedCount > 0) {
569
+ const outcomes = results.map((r) => (r.status === "fulfilled" ? r.value : { attempted: true, ok: false }));
570
+ const attempted = outcomes.filter((o) => o?.attempted === true).length;
571
+ const revokedCount = outcomes.filter((o) => o?.ok === true).length;
572
+
573
+ if (attempted === 0) {
574
+ // Nothing was sent. Say so, and say how to change it.
575
+ console.log(c.dim(`Discarded ${count} token(s) locally (server-side revocation is off).`));
576
+ console.log(c.dim("Enable it with: oauth.revoke_on_logout = true"));
577
+ } else if (revokedCount === attempted) {
533
578
  console.log(c.dim(`Revoked ${revokedCount} of ${count} token(s) server-side.`));
579
+ } else {
580
+ // Partial or total failure must not be reported as a clean local discard.
581
+ console.log(
582
+ c.yellow(
583
+ `Revoked ${revokedCount} of ${attempted} token(s) server-side; ` +
584
+ `${attempted - revokedCount} revocation(s) failed. Tokens removed locally anyway.`,
585
+ ),
586
+ );
534
587
  }
535
588
 
536
589
  // Write explicit empty state so running plugin instances reconcile immediately.
@@ -1574,8 +1627,8 @@ ${c.dim("Usage:")}
1574
1627
 
1575
1628
  ${c.dim("Auth Commands:")}
1576
1629
  ${pad(c.cyan("login"), 22)}Add a new account via browser OAuth flow
1577
- ${pad(c.cyan("logout") + " <N>", 22)}Revoke tokens and remove account N
1578
- ${pad(c.cyan("logout") + " --all", 22)}Revoke all tokens and clear all accounts
1630
+ ${pad(c.cyan("logout") + " <N>", 22)}Remove account N (discards its tokens)
1631
+ ${pad(c.cyan("logout") + " --all", 22)}Remove all accounts (discards their tokens)
1579
1632
  ${pad(c.cyan("reauth") + " <N>", 22)}Re-authenticate account N with fresh tokens
1580
1633
  ${pad(c.cyan("refresh") + " <N>", 22)}Attempt token refresh (no browser needed)
1581
1634
 
@@ -1602,7 +1655,7 @@ ${c.dim("Options:")}
1602
1655
 
1603
1656
  ${c.dim("Examples:")}
1604
1657
  ${bin} login ${c.dim("# Add a new account via browser")}
1605
- ${bin} logout 2 ${c.dim("# Revoke tokens & remove account 2")}
1658
+ ${bin} logout 2 ${c.dim("# Remove account 2 & discard its tokens")}
1606
1659
  ${bin} logout --all ${c.dim("# Logout all accounts")}
1607
1660
  ${bin} reauth 1 ${c.dim("# Re-authenticate account 1")}
1608
1661
  ${bin} refresh 1 ${c.dim("# Quick token refresh for account 1")}
package/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { stdin, stdout } from "node:process";
3
3
  import { randomBytes, randomUUID, createHash as createHashCrypto } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join, resolve, basename } from "node:path";
6
- import { AccountManager, RATE_LIMIT_KEY_FAST } from "./lib/accounts.mjs";
6
+ import { AccountManager, RATE_LIMIT_KEY_FAST, warnOnEphemeralTokenRotation } from "./lib/accounts.mjs";
7
7
  import {
8
8
  authorize as oauthAuthorize,
9
9
  exchange as oauthExchange,
@@ -32,7 +32,7 @@ import {
32
32
  maybeApplySessionToolResultDedupe,
33
33
  applyContextHintCompaction,
34
34
  } from "./lib/token-economy/transforms.mjs";
35
- import { shouldMicrocompact, buildMicrocompactBetas } from "./lib/token-economy/microcompact.mjs";
35
+ import { shouldMicrocompact } from "./lib/token-economy/microcompact.mjs";
36
36
  import { redactSecrets, redactString } from "./lib/redact.mjs";
37
37
  import {
38
38
  createTransformedSSEStream,
@@ -44,7 +44,12 @@ import { isTruthyEnv } from "./lib/env.mjs";
44
44
  import { sessionMetrics, createInitialSessionMetrics, getAverageCacheHitRate } from "./lib/session-metrics.mjs";
45
45
  import { repairOrphanedToolUseBlocks } from "./lib/mimicry/request-helpers.mjs";
46
46
  import { resolveCacheTtl, shouldPlaceToolBreakpoint, updateBoundaryStability } from "./lib/mimicry/cache.mjs";
47
- import { SERVICE_WIDE_MAX_RETRIES, CONSECUTIVE_529_FALLBACK_THRESHOLD } from "./lib/tuning.mjs";
47
+ import {
48
+ SERVICE_WIDE_MAX_RETRIES,
49
+ CONSECUTIVE_529_FALLBACK_THRESHOLD,
50
+ FOREGROUND_REFRESH_EXPIRY_BUFFER_MS,
51
+ } from "./lib/tuning.mjs";
52
+ import { isTokenExpired, isTokenUsable } from "./lib/account-state.mjs";
48
53
  import {
49
54
  computeServiceRetrySleepMs,
50
55
  selectFallbackModel,
@@ -1424,7 +1429,7 @@ export async function AnthropicAuthPlugin({ client }) {
1424
1429
  */
1425
1430
  async function getFilesAuth(acct) {
1426
1431
  let tok = acct.access;
1427
- if (!tok || !acct.expires || acct.expires < Date.now()) {
1432
+ if (!tok || isTokenExpired(acct.expires)) {
1428
1433
  tok = await refreshAccountTokenSingleFlight(acct);
1429
1434
  }
1430
1435
  return {
@@ -2284,7 +2289,7 @@ export async function AnthropicAuthPlugin({ client }) {
2284
2289
  // Ignore idle failure here; foreground path handles refresh decisions.
2285
2290
  }
2286
2291
 
2287
- if (account.access && account.expires && account.expires > Date.now()) {
2292
+ if (account.access && isTokenUsable(account.expires)) {
2288
2293
  return account.access;
2289
2294
  }
2290
2295
  } else {
@@ -2397,7 +2402,7 @@ export async function AnthropicAuthPlugin({ client }) {
2397
2402
  const excluded = new Set([activeAccount.index]);
2398
2403
  const candidates = accountManager
2399
2404
  .getEnabledAccounts(excluded)
2400
- .filter((acc) => !acc.expires || acc.expires <= now + getIdleRefreshWindowMs())
2405
+ .filter((acc) => isTokenExpired(acc.expires, getIdleRefreshWindowMs(), now))
2401
2406
  .filter((acc) => {
2402
2407
  const last = idleRefreshLastAttempt.get(acc.id) ?? 0;
2403
2408
  return now - last >= getIdleRefreshMinIntervalMs();
@@ -2738,7 +2743,7 @@ export async function AnthropicAuthPlugin({ client }) {
2738
2743
  let accessToken;
2739
2744
  // Per-account token refresh
2740
2745
  // Refresh 5 minutes before expiry to avoid mid-request token expiration (RE doc §1.10)
2741
- if (!account.access || !account.expires || account.expires < Date.now() + 300_000) {
2746
+ if (!account.access || isTokenExpired(account.expires, FOREGROUND_REFRESH_EXPIRY_BUFFER_MS)) {
2742
2747
  const attemptedRefreshToken = account.refreshToken;
2743
2748
  try {
2744
2749
  accessToken = await refreshAccountTokenSingleFlight(account);
@@ -2898,14 +2903,12 @@ export async function AnthropicAuthPlugin({ client }) {
2898
2903
  const _baseTE = config.token_economy || {};
2899
2904
  const _tokenEconomy = { ..._baseTE, __requestRole: _requestRole };
2900
2905
 
2901
- // Microcompact: inject clear betas at high context utilization
2902
- let _microcompactBetas = null;
2906
+ // Microcompact: track high context utilization (state + toast only)
2903
2907
  if (requestInit.body) {
2904
2908
  const estimatedTokens = _parsedBodyOnce
2905
2909
  ? estimatePromptTokensFromParsed(_parsedBodyOnce)
2906
2910
  : estimatePromptTokens(requestInit.body);
2907
2911
  if (shouldMicrocompact(estimatedTokens, config)) {
2908
- _microcompactBetas = buildMicrocompactBetas();
2909
2912
  if (!microcompactState.active) {
2910
2913
  microcompactState.active = true;
2911
2914
  microcompactState.lastActivatedTurn = sessionMetrics.turns;
@@ -4527,7 +4530,7 @@ export async function AnthropicAuthPlugin({ client }) {
4527
4530
 
4528
4531
  const getAccessToken = async () => {
4529
4532
  let tok = account.access;
4530
- if (!tok || !account.expires || account.expires < Date.now()) {
4533
+ if (!tok || isTokenExpired(account.expires)) {
4531
4534
  tok = await refreshAccountTokenSingleFlight(account);
4532
4535
  }
4533
4536
  if (!tok) throw new Error("no access token available for Haiku call");
@@ -6170,7 +6173,7 @@ function applyDiskAuthIfFresher(account, diskAuth, options = {}) {
6170
6173
  const diskTokenUpdatedAt = diskAuth.tokenUpdatedAt || 0;
6171
6174
  const memTokenUpdatedAt = account.tokenUpdatedAt || 0;
6172
6175
  const diskHasDifferentAuth = diskAuth.refreshToken !== account.refreshToken || diskAuth.access !== account.access;
6173
- const memAuthExpired = !account.expires || account.expires <= Date.now();
6176
+ const memAuthExpired = isTokenExpired(account.expires);
6174
6177
  const allowExpiredFallback = options.allowExpiredFallback === true;
6175
6178
  if (diskTokenUpdatedAt <= memTokenUpdatedAt && !(allowExpiredFallback && diskHasDifferentAuth && memAuthExpired)) {
6176
6179
  return false;
@@ -6206,7 +6209,7 @@ async function refreshAccountToken(account, client, _source = "foreground", { on
6206
6209
  // Accept CC credential if:
6207
6210
  // - expiresAt is in the future (normal case), OR
6208
6211
  // - expiresAt is 0/missing (CC didn't provide expiry — trust the token, let API 401 if stale)
6209
- if (match && (match.expiresAt === 0 || match.expiresAt > Date.now())) {
6212
+ if (match && (match.expiresAt === 0 || isTokenUsable(match.expiresAt))) {
6210
6213
  account.access = match.accessToken;
6211
6214
  account.expires = match.expiresAt || Date.now() + 3600_000; // default 1h if unknown
6212
6215
  markTokenStateUpdated(account);
@@ -6242,7 +6245,7 @@ async function refreshAccountToken(account, client, _source = "foreground", { on
6242
6245
  if (!lock.acquired) {
6243
6246
  const diskAuth = await readDiskAccountAuth(account.id);
6244
6247
  const adopted = applyDiskAuthIfFresher(account, diskAuth, { allowExpiredFallback: true });
6245
- if (adopted && account.access && account.expires && account.expires > Date.now()) {
6248
+ if (adopted && account.access && isTokenUsable(account.expires)) {
6246
6249
  return account.access;
6247
6250
  }
6248
6251
  throw new Error("Refresh lock busy");
@@ -6253,14 +6256,21 @@ async function refreshAccountToken(account, client, _source = "foreground", { on
6253
6256
  const adopted = applyDiskAuthIfFresher(account, diskAuthBeforeRefresh);
6254
6257
  // Apply fresher disk tokens for both foreground and idle paths — prevents an
6255
6258
  // unnecessary HTTP refresh when another process already rotated the token.
6256
- if (adopted && account.access && account.expires && account.expires > Date.now()) {
6259
+ if (adopted && account.access && isTokenUsable(account.expires)) {
6257
6260
  return account.access;
6258
6261
  }
6259
6262
 
6260
- const json = await refreshToken(account.refreshToken, { signal: AbortSignal.timeout(15_000) });
6263
+ // `clientId` is undefined for every normal account, which means "use the
6264
+ // default". Only a CLAUDE_CODE_OAUTH_CLIENT_ID-seeded env account carries one
6265
+ // (contract §5.2, divergence D10).
6266
+ const json = await refreshToken(account.refreshToken, {
6267
+ signal: AbortSignal.timeout(15_000),
6268
+ clientId: account.clientId,
6269
+ });
6261
6270
 
6262
6271
  account.access = json.access_token;
6263
6272
  account.expires = Date.now() + json.expires_in * 1000;
6273
+ warnOnEphemeralTokenRotation(account, json.refresh_token);
6264
6274
  if (json.refresh_token) {
6265
6275
  account.refreshToken = json.refresh_token;
6266
6276
  }
@@ -1,3 +1,51 @@
1
+ import { OAUTH_EXPIRY_SKEW_SECONDS } from "./oauth-constants.mjs";
2
+
3
+ /** The attested 30-second expiry skew, in milliseconds. @see docs/oauth-2.1.280-contract.md §3 */
4
+ export const TOKEN_EXPIRY_SKEW_MS = OAUTH_EXPIRY_SKEW_SECONDS * 1000;
5
+
6
+ /**
7
+ * The single expiry predicate. Every "is this access token still usable?"
8
+ * question in this repository routes through here.
9
+ *
10
+ * ATTESTED: §13.8 (byte 4418342) records `expiry skew (seconds): 30`. A token is
11
+ * therefore treated as expired 30 s before its nominal expiry, so a request is
12
+ * never sent with a credential that dies in flight.
13
+ *
14
+ * WHY leadMs IS A FLOOR AND NOT AN ADDEND. Some callers deliberately refresh far
15
+ * ahead of expiry — a foreground buffer, an idle-refresh window. Adding the skew
16
+ * to those would silently retune them; dropping the skew for them would leave a
17
+ * site that can still call a token valid seconds before it dies. Taking the
18
+ * larger of the two keeps every tuned lead time exactly as it was while
19
+ * guaranteeing the 30 s floor everywhere.
20
+ *
21
+ * A missing or non-numeric `expires` is EXPIRED, not valid: an unknown expiry is
22
+ * not evidence of freshness, and the alternative is sending a dead token.
23
+ *
24
+ * @param {number | null | undefined} expires - Absolute expiry, ms since epoch
25
+ * @param {number} [leadMs=0] - Extra lead time this caller wants
26
+ * @param {number} [now=Date.now()] - Injectable clock for tests
27
+ * @returns {boolean}
28
+ * @see docs/oauth-2.1.280-contract.md §3
29
+ */
30
+ export function isTokenExpired(expires, leadMs = 0, now = Date.now()) {
31
+ if (typeof expires !== "number" || !Number.isFinite(expires)) return true;
32
+ const lead = Math.max(Number.isFinite(leadMs) ? leadMs : 0, TOKEN_EXPIRY_SKEW_MS);
33
+ return now >= expires - lead;
34
+ }
35
+
36
+ /**
37
+ * Convenience inverse of {@link isTokenExpired}. Exists so call sites read as
38
+ * the question they are asking instead of as a negation.
39
+ *
40
+ * @param {number | null | undefined} expires
41
+ * @param {number} [leadMs=0]
42
+ * @param {number} [now=Date.now()]
43
+ * @returns {boolean}
44
+ */
45
+ export function isTokenUsable(expires, leadMs = 0, now = Date.now()) {
46
+ return !isTokenExpired(expires, leadMs, now);
47
+ }
48
+
1
49
  /**
2
50
  * @typedef {import('./storage.mjs').AccountMetadata} AccountMetadata
3
51
  * @typedef {import('./storage.mjs').AccountStorage} AccountStorage