opencode-anthropic-fix 1.0.0 → 2.1.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")}