opencode-anthropic-fix 0.6.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/README.md CHANGED
@@ -58,7 +58,7 @@ The [original plugin](https://github.com/anomalyco/opencode-anthropic-auth) prov
58
58
  - **Health scoring** — tracks account reliability and prefers healthy accounts
59
59
  - **Standalone CLI** — manage accounts without opening OpenCode
60
60
  - **Configurable strategies** — sticky, round-robin, or hybrid account selection
61
- - **Claude Code signature emulation** — full HTTP header, system prompt, beta flag, and metadata mimicry derived from Claude Code's open source code. System prompt cache scoping follows the real CC's three-path `splitSysPromptPrefix()` architecture (boundary/global/org) with exact marker detection and scope-aware `cache_control` generation
61
+ - **Claude Code signature emulation** — full HTTP header, system prompt, beta flag, and metadata mimicry derived from Claude Code's open source code. Canonical Claude Code protocol composition (headers, body, system prefix, betas, URL) is sourced from [`@tormentalabs/claude-code-wire-compat`](https://www.npmjs.com/package/@tormentalabs/claude-code-wire-compat); system prompt cache scoping stays host policy (`splitSystemCacheScopes`, boundary/global/org) with exact marker detection and scope-aware `cache_control` generation
62
62
  - **OAuth endpoint fingerprint parity** — matches the real CLI's bundled axios 1.13.6 HTTP client signature (`Accept`, `User-Agent`, `Content-Type`) on all OAuth token endpoint calls, required since 2026-03-21 server-side enforcement
63
63
  - **Billing header fingerprint parity** — `cc_version` suffix uses the real CLI's 3-char fingerprint hash (SHA-256 of salt + first user message chars + version), `cch=00000` is a static placeholder (xxHash64 attestation was removed in Claude Code v2.1.97), system prompt is sanitized to match Claude Code's pattern validation, and `X-Claude-Code-Session-Id` header is sent on all requests
64
64
  - **Adaptive thinking for Opus/Sonnet 4.6** — automatically normalizes thinking to `{type: "adaptive"}` for supported models, with `effort-2025-11-24` beta
@@ -597,9 +597,36 @@ The plugin also:
597
597
  - Emulates Claude-style request headers and beta flags by default
598
598
  - Sanitizes "OpenCode" references to "Claude Code" in system prompts (required by Anthropic's API)
599
599
  - In `prompt_compaction="minimal"`, deduplicates repeated/contained system blocks and uses a compact dedicated prompt for internal title-generation requests
600
- - Adds `?beta=true` to `/v1/messages` and `/v1/messages/count_tokens` requests
600
+ - With signature emulation on, adds `?beta=true` to `/v1/messages` and `/v1/messages/count_tokens` requests (and
601
+ normalizes a `/messages` path to `/v1/messages`). With it off, the host's URL is sent untouched.
601
602
 
602
- When signature emulation is disabled (`signature_emulation.enabled=false`), the plugin falls back to legacy behavior including the Claude Code system prompt prefix.
603
+ ### Signature emulation
604
+
605
+ `signature_emulation.enabled` is a strictly binary switch — there is no half-way mode.
606
+
607
+ **On (default):** every `/v1/messages` and `/v1/messages/count_tokens` turn is built by the shared
608
+ `@tormentalabs/claude-code-wire-compat` package: full Claude Code header set, beta list, identity/billing system
609
+ blocks and `metadata.user_id`.
610
+
611
+ **Off: pure passthrough plus the auth envelope.** The plugin forges nothing. Your request goes out as your client wrote
612
+ it — same `user-agent`, same headers, same body bytes — with only three changes, all of which belong to the OAuth
613
+ transport rather than to the disguise:
614
+
615
+ - `authorization: Bearer <account token>` — without it there is no request and no account rotation;
616
+ - `anthropic-beta` — **additive**: your value is preserved verbatim and `oauth-2025-04-20` is appended only when
617
+ missing, because the API rejects an OAuth bearer that does not carry it;
618
+ - `x-api-key` and `x-session-affinity` are removed — a second credential must not travel next to your bearer, and the
619
+ affinity hint leaks session identity upstream.
620
+
621
+ The body is not transformed either. Two fields are stripped because the API rejects them outright: the body-level
622
+ `betas` field and any stainless-helper markers your client attached.
623
+
624
+ > **Changed in the unreleased version.** Emulation-off used to still forge a `claude-cli` user-agent and REPLACE your
625
+ > `anthropic-beta` with a minimal list, and it ran structural body normalizations (which also emptied your `system`
626
+ > prompt and injected a `temperature` you never sent). All of that is gone. See the CHANGELOG.
627
+
628
+ The system-prompt side is unchanged: with emulation off the plugin uses the legacy system transform path, including
629
+ the Claude Code system prompt prefix.
603
630
 
604
631
  ## Files
605
632
 
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")}