@modelprofile.com/authswitch 3.3.0 → 4.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.
Files changed (87) hide show
  1. package/dist_ts/00_commitinfo_data.js +3 -3
  2. package/dist_ts/accounts.d.ts +42 -15
  3. package/dist_ts/accounts.js +98 -27
  4. package/dist_ts/classes.accountlist.d.ts +0 -25
  5. package/dist_ts/classes.accountlist.js +2 -216
  6. package/dist_ts/classes.claudecodeharness.d.ts +59 -1
  7. package/dist_ts/classes.claudecodeharness.js +130 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +35 -0
  9. package/dist_ts/classes.claudecodelocks.js +117 -0
  10. package/dist_ts/classes.claudestatus.d.ts +15 -2
  11. package/dist_ts/classes.claudestatus.js +88 -73
  12. package/dist_ts/classes.claudetokenrefresh.d.ts +32 -0
  13. package/dist_ts/classes.claudetokenrefresh.js +77 -0
  14. package/dist_ts/classes.cli.d.ts +14 -7
  15. package/dist_ts/classes.cli.js +125 -66
  16. package/dist_ts/classes.codexharness.d.ts +4 -0
  17. package/dist_ts/classes.codexharness.js +5 -1
  18. package/dist_ts/classes.codexstatus.d.ts +2 -1
  19. package/dist_ts/classes.codexstatus.js +25 -9
  20. package/dist_ts/classes.credentialstore.d.ts +28 -2
  21. package/dist_ts/classes.credentialstore.js +41 -12
  22. package/dist_ts/classes.fileharness.d.ts +37 -17
  23. package/dist_ts/classes.fileharness.js +58 -24
  24. package/dist_ts/classes.limits.d.ts +46 -7
  25. package/dist_ts/classes.limits.js +94 -36
  26. package/dist_ts/classes.listrenderer.d.ts +17 -0
  27. package/dist_ts/classes.listrenderer.js +313 -0
  28. package/dist_ts/classes.login.d.ts +1 -1
  29. package/dist_ts/classes.login.js +1 -1
  30. package/dist_ts/classes.opencodeharness.d.ts +4 -0
  31. package/dist_ts/classes.opencodeharness.js +7 -3
  32. package/dist_ts/classes.operations.js +3 -2
  33. package/dist_ts/classes.tui.js +4 -3
  34. package/dist_ts/classes.watch.d.ts +108 -0
  35. package/dist_ts/classes.watch.js +219 -0
  36. package/dist_ts/classes.watchlock.d.ts +33 -0
  37. package/dist_ts/classes.watchlock.js +118 -0
  38. package/dist_ts/claudehttp.d.ts +39 -0
  39. package/dist_ts/claudehttp.js +83 -0
  40. package/dist_ts/cliargs.d.ts +36 -0
  41. package/dist_ts/cliargs.js +60 -0
  42. package/dist_ts/consoletable.d.ts +21 -0
  43. package/dist_ts/consoletable.js +63 -0
  44. package/dist_ts/helpers.d.ts +7 -0
  45. package/dist_ts/helpers.js +16 -1
  46. package/dist_ts/index.d.ts +3 -0
  47. package/dist_ts/index.js +4 -1
  48. package/dist_ts/interfaces.harness.d.ts +60 -11
  49. package/dist_ts/interfaces.list.d.ts +3 -1
  50. package/dist_ts/plugins.d.ts +7 -0
  51. package/dist_ts/plugins.js +6 -1
  52. package/dist_ts/ratelimit.d.ts +8 -0
  53. package/dist_ts/ratelimit.js +13 -0
  54. package/dist_ts/watchpolicy.d.ts +44 -0
  55. package/dist_ts/watchpolicy.js +82 -0
  56. package/package.json +5 -3
  57. package/readme.md +315 -110
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +109 -34
  60. package/ts/classes.accountlist.ts +2 -219
  61. package/ts/classes.claudecodeharness.ts +125 -12
  62. package/ts/classes.claudecodelocks.ts +132 -0
  63. package/ts/classes.claudestatus.ts +87 -54
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +112 -53
  66. package/ts/classes.codexharness.ts +4 -0
  67. package/ts/classes.codexstatus.ts +17 -7
  68. package/ts/classes.credentialstore.ts +53 -9
  69. package/ts/classes.fileharness.ts +67 -29
  70. package/ts/classes.limits.ts +110 -36
  71. package/ts/classes.listrenderer.ts +328 -0
  72. package/ts/classes.login.ts +1 -1
  73. package/ts/classes.opencodeharness.ts +6 -2
  74. package/ts/classes.operations.ts +2 -1
  75. package/ts/classes.tui.ts +3 -2
  76. package/ts/classes.watch.ts +263 -0
  77. package/ts/classes.watchlock.ts +100 -0
  78. package/ts/claudehttp.ts +92 -0
  79. package/ts/cliargs.ts +71 -0
  80. package/ts/consoletable.ts +62 -0
  81. package/ts/helpers.ts +14 -0
  82. package/ts/index.ts +3 -0
  83. package/ts/interfaces.harness.ts +60 -5
  84. package/ts/interfaces.list.ts +3 -1
  85. package/ts/plugins.ts +9 -0
  86. package/ts/ratelimit.ts +14 -0
  87. package/ts/watchpolicy.ts +121 -0
@@ -1,8 +1,11 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
3
- import { CredentialStore, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
3
+ import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
4
4
  import { HarnessProcesses } from './classes.harnessprocesses.js';
5
5
  import { ClaudeAccountStatus } from './classes.claudestatus.js';
6
+ import { ClaudeCodeLocks } from './classes.claudecodelocks.js';
7
+ import { ClaudeTokenRefresh, type IClaudeTokens } from './classes.claudetokenrefresh.js';
8
+ import { CLAUDE_LOGIN_REJECTED, ClaudeLoginRejectedError, ClaudeRateLimitError } from './claudehttp.js';
6
9
  import { writeSecretFileAtomically } from './helpers.js';
7
10
  import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
8
11
 
@@ -17,15 +20,31 @@ export interface IClaudeCodeHarnessOptions {
17
20
  processes?: IHarnessProcessControl;
18
21
  /** Local settings sources to inspect for credential overrides, in increasing precedence. */
19
22
  settingsFiles?: string[];
23
+ /** How long a credential change waits for a lock Claude Code holds; 15 seconds by default. */
24
+ lockTimeoutMs?: number;
25
+ /** The clock that decides when a saved login's access token is due for a refresh; `Date.now` by default. */
26
+ now?: () => number;
20
27
  }
21
28
 
22
29
  const accountCaches = ['additionalModelOptionsCache', 'additionalModelOptionsAnsweredAt', 'additionalModelCostsCache', 'modelAccessCache', 'orgModelDefaultCache', 'lastSeenOrgDefaultUpdatedAt', 'clientDataCache', 'clientDataCacheSlots', 'autoCompactWindowsCache', 'cachedUsageUtilization'];
23
30
 
24
- /** File-backed Claude Code subscriber login and its matching global oauthAccount metadata. */
31
+ /**
32
+ * File-backed Claude Code subscriber login and its matching global oauthAccount metadata.
33
+ *
34
+ * A running Claude Code (verified against 2.1.273) re-reads its credential file whenever the file's mtime changes,
35
+ * before every request, and watches `.claude.json`, so a swap is picked up live without a restart. Every native read
36
+ * and write therefore runs under Claude Code's own locks instead of asking for it to be stopped.
37
+ *
38
+ * Nothing else refreshes a saved login, so its access token expires a few hours after it was saved. Reading such a
39
+ * login's status refreshes it first, the way Claude Code would; the active login's tokens belong to Claude Code.
40
+ */
25
41
  export class ClaudeCodeHarness extends FileHarness {
26
42
  public readonly id = 'claude';
27
43
  public readonly label = 'Claude Code';
28
44
  public readonly loginHint = 'Log in using claude auth login, then authswitch claude stash --keep; Claude Code may stay running.';
45
+ public readonly liveSwap = true;
46
+ public readonly autoSwitch = true;
47
+ public readonly renewalUnavailableReason = "renewal dates are not exposed by Claude's account API.";
29
48
  protected readonly store: CredentialStore;
30
49
  private readonly file: string;
31
50
  private readonly configFile: string;
@@ -34,6 +53,16 @@ export class ClaudeCodeHarness extends FileHarness {
34
53
  private readonly status: ClaudeAccountStatus;
35
54
  public readonly processes: IHarnessProcessControl;
36
55
  private readonly settingsFiles: string[];
56
+ private readonly locks: ClaudeCodeLocks;
57
+ private readonly tokens: ClaudeTokenRefresh;
58
+ /**
59
+ * The refresh token the service rejected, per account: a hash, never the token itself.
60
+ *
61
+ * A rejected grant stays rejected until a new Claude Code login replaces the saved record, so replaying it would
62
+ * only add traffic to an auth endpoint -- once every tick of a watch, for as long as it runs. The entry is
63
+ * superseded as soon as the record holds a different refresh token.
64
+ */
65
+ private readonly rejectedGrants = new Map<string, string>();
37
66
 
38
67
  constructor(options: IClaudeCodeHarnessOptions = {}) {
39
68
  super();
@@ -44,10 +73,16 @@ export class ClaudeCodeHarness extends FileHarness {
44
73
  this.configFile = options.globalConfigFile ?? plugins.path.join(custom || plugins.os.homedir(), '.claude.json');
45
74
  this.store = new CredentialStore(this.id, options.stashRoot);
46
75
  this.platform = options.platform ?? process.platform;
47
- this.status = new ClaudeAccountStatus(options.fetch);
76
+ this.status = new ClaudeAccountStatus(options.fetch, options.now);
77
+ this.tokens = new ClaudeTokenRefresh(options.fetch, options.now);
48
78
  this.processes = options.processes ?? new HarnessProcesses('claude', { platform: this.platform });
49
79
  this.settingsFiles = options.settingsFiles ?? [plugins.path.join(dir, 'settings.json'),
50
80
  plugins.path.join(process.cwd(), '.claude/settings.json'), plugins.path.join(process.cwd(), '.claude/settings.local.json')];
81
+ this.locks = new ClaudeCodeLocks(dir, this.configFile, { timeoutMs: options.lockTimeoutMs });
82
+ }
83
+ /** A login this adapter cannot manage has no Claude Code file to lock; the action itself reports why. */
84
+ protected nativeTransaction(actionArg: () => string[]): Promise<string[]> {
85
+ return this.unavailableReason() === null ? this.locks.hold(actionArg) : super.nativeTransaction(actionArg);
51
86
  }
52
87
  /** One record holds the credential and its matching metadata, so both files must be proven stable. */
53
88
  protected nativeSources(): (string | null)[] { return [readCredentialRaw(this.file), readCredentialRaw(this.configFile)]; }
@@ -59,8 +94,9 @@ export class ClaudeCodeHarness extends FileHarness {
59
94
  || !credentialText(account.accountUuid) || !credentialText(account.organizationUuid) || !credentialText(account.emailAddress)) throw new Error('Claude Code login or its matching account metadata is incomplete. Log in again with Claude Code before saving.');
60
95
  return { slotId, identity: JSON.stringify([account.accountUuid, account.organizationUuid]), label: String(account.emailAddress).toLowerCase(), credential };
61
96
  }
62
- protected snapshot(): IFileHarnessSnapshot {
63
- if (this.platform === 'darwin') return { accounts: [], unavailable: 'Claude Code uses the macOS Keychain. This adapter supports file credentials on Linux and Windows; Keychain switching is unavailable.' };
97
+ /** Why this installation's login is not a subscriber file login this adapter can manage; null when it is. */
98
+ private unavailableReason(): string | null {
99
+ if (this.platform === 'darwin') return 'Claude Code uses the macOS Keychain. This adapter supports file credentials on Linux and Windows; Keychain switching is unavailable.';
64
100
  const overrides = ['CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_PROFILE', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'ANTHROPIC_BASE_URL'];
65
101
  const settings: Record<string, unknown> = {};
66
102
  const environment: Record<string, unknown> = { ...this.env };
@@ -69,7 +105,12 @@ export class ClaudeCodeHarness extends FileHarness {
69
105
  Object.assign(settings, data);
70
106
  if (data.env != null) Object.assign(environment, credentialRecord(data.env));
71
107
  }
72
- if (overrides.some(key => environment[key]) || credentialText(settings.apiKeyHelper)) return { accounts: [], unavailable: 'Claude Code has a settings or environment credential/provider override. Remove it before managing subscriber file logins.' };
108
+ return overrides.some(key => environment[key]) || credentialText(settings.apiKeyHelper)
109
+ ? 'Claude Code has a settings or environment credential/provider override. Remove it before managing subscriber file logins.' : null;
110
+ }
111
+ protected snapshot(): IFileHarnessSnapshot {
112
+ const unavailable = this.unavailableReason();
113
+ if (unavailable !== null) return { accounts: [], unavailable };
73
114
  const data = readCredentialDocument(this.file).data;
74
115
  if (data.claudeAiOauth == null) return { accounts: [], unavailable: null };
75
116
  const config = readCredentialDocument(this.configFile).data;
@@ -90,7 +131,7 @@ export class ClaudeCodeHarness extends FileHarness {
90
131
  for (const key of accountCaches) delete nextConfig[key];
91
132
  const raw = JSON.stringify(next, null, 2) + '\n';
92
133
  const configRaw = JSON.stringify(nextConfig, null, 2) + '\n';
93
- if (readCredentialDocument(this.file).raw !== original.raw || readCredentialDocument(this.configFile).raw !== config.raw) throw new Error('Claude Code files changed during the operation; try again after Claude Code exits.');
134
+ if (readCredentialDocument(this.file).raw !== original.raw || readCredentialDocument(this.configFile).raw !== config.raw) throw new Error('Claude Code files changed during the operation; no credential was replaced. Try again.');
94
135
  plugins.fs.mkdirSync(plugins.path.dirname(this.file), { recursive: true, mode: 0o700 });
95
136
  plugins.fs.mkdirSync(plugins.path.dirname(this.configFile), { recursive: true, mode: 0o700 });
96
137
  try {
@@ -103,12 +144,84 @@ export class ClaudeCodeHarness extends FileHarness {
103
144
  try {
104
145
  if (readCredentialDocument(this.file).raw === raw) writeSecretFileAtomically(this.file, original.raw ?? '{}\n');
105
146
  if (readCredentialDocument(this.configFile).raw === configRaw) writeSecretFileAtomically(this.configFile, config.raw ?? '{}\n');
106
- } catch { throw new Error('Claude Code switch and rollback were incomplete. Keep Claude Code stopped and restore a verified saved account.'); }
107
- throw new Error('Claude Code switch failed while updating account metadata. The outgoing login remains saved; check doctor before restarting.');
147
+ } catch { throw new Error('Claude Code switch and rollback were incomplete, so its login and account metadata may not match. Activate a verified saved account with authswitch claude use, then check authswitch claude active.'); }
148
+ throw new Error('Claude Code switch failed while updating account metadata and was rolled back. The outgoing login remains saved; check authswitch claude doctor.');
108
149
  }
109
150
  }
110
- public async readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
111
- const account = this.readCredential(id);
112
- return this.status.read(credentialRecord(account.credential.claudeAiOauth), credentialRecord(account.credential.oauthAccount), optionsArg);
151
+ /** The hash that identifies a saved login's refresh token, so a rejected grant is remembered without keeping it. */
152
+ private grantKey(oauthArg: Record<string, unknown>): string { return credentialHash(credentialText(oauthArg.refreshToken) ?? ''); }
153
+
154
+ /**
155
+ * What the native files say about this account: its active login, and why they could not be read at all.
156
+ *
157
+ * Both answers come from one snapshot, because only their combination proves that a login is *not* the active one.
158
+ * An installation this adapter cannot read reports no accounts, so the login Claude Code is actually running on
159
+ * reads as inactive there; anything that would renew a saved login has to refuse instead.
160
+ */
161
+ private nativeView(idArg: string): { active: IFileAccount | null; unavailable: string | null } {
162
+ const snapshot = this.snapshot();
163
+ return { active: snapshot.accounts.find(account => this.store.id(account.slotId, account.identity) === idArg) ?? null, unavailable: snapshot.unavailable };
164
+ }
165
+
166
+ /**
167
+ * The account's status. The active login is read as it is: Claude Code refreshes it. A saved login whose access token
168
+ * is due is refreshed first; when that fails, the status says why and keeps the stored plan, and the saved login is
169
+ * left exactly as it was.
170
+ *
171
+ * A refresh is only ever sent for a login the native files prove is not the active one (`nativeView`), and a grant
172
+ * the service already rejected is not sent again while the saved record still holds it.
173
+ */
174
+ public async readAccountStatus(id: string, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
175
+ const read = (accountArg: IFileAccount) =>
176
+ this.status.read(credentialRecord(accountArg.credential.claudeAiOauth), credentialRecord(accountArg.credential.oauthAccount), optionsArg);
177
+ const view = this.nativeView(id);
178
+ if (view.active) return read(view.active);
179
+ const saved = this.savedCredential(id);
180
+ const oauth = credentialRecord(saved.credential.claudeAiOauth);
181
+ if (!this.tokens.due(oauth)) return read(saved);
182
+ if (view.unavailable !== null) {
183
+ return this.status.unavailable(oauth, `Login refresh: this saved login's access token has expired and cannot be renewed for this installation. ${view.unavailable}`);
184
+ }
185
+ if (this.rejectedGrants.get(id) === this.grantKey(oauth)) return this.status.unavailable(oauth, `Login refresh: ${CLAUDE_LOGIN_REJECTED}`);
186
+ let refreshed: IFileAccount;
187
+ try { refreshed = await this.refreshSaved(id, optionsArg.signal); }
188
+ catch (error) {
189
+ if (optionsArg.signal?.aborted) throw error;
190
+ return this.status.unavailable(oauth, `Login refresh: ${error instanceof Error ? error.message : 'the saved login could not be refreshed.'}`,
191
+ error instanceof ClaudeRateLimitError ? { retryAt: error.retryAt } : undefined);
192
+ }
193
+ return read(refreshed);
194
+ }
195
+
196
+ /**
197
+ * A saved login with fresh tokens, refreshed as the only operation on the store: a switch, a save or another refresh
198
+ * waits for it, so none of them can read or activate a refresh token the service is replacing. Under the lock the
199
+ * native files are read again, so the decision that this is not the active login is taken where it holds: one that
200
+ * became active meanwhile belongs to Claude Code and is returned as it is, one in an installation that can no
201
+ * longer be read is returned unrefreshed, and one another refresh already renewed is not refreshed twice. The new
202
+ * tokens replace the saved ones only while the record still holds the refresh token that was sent, the
203
+ * compare-and-swap Claude Code 2.1.273 applies to its own store (`$fn`, @15410824).
204
+ */
205
+ private refreshSaved(id: string, signal?: AbortSignal): Promise<IFileAccount> {
206
+ return this.store.locked(async () => {
207
+ const view = this.nativeView(id);
208
+ if (view.active) return view.active;
209
+ const saved = this.savedCredential(id);
210
+ const oauth = credentialRecord(saved.credential.claudeAiOauth);
211
+ if (view.unavailable !== null || !this.tokens.due(oauth)) return saved;
212
+ signal?.throwIfAborted();
213
+ let tokens: IClaudeTokens;
214
+ try { tokens = await this.tokens.refresh(oauth); }
215
+ catch (error) {
216
+ // Only this token was rejected; a login saved again replaces it and is tried like any other.
217
+ if (error instanceof ClaudeLoginRejectedError) this.rejectedGrants.set(id, this.grantKey(oauth));
218
+ throw error;
219
+ }
220
+ const latest = this.savedCredential(id);
221
+ const latestOauth = credentialRecord(latest.credential.claudeAiOauth);
222
+ if (latestOauth.refreshToken !== oauth.refreshToken) return latest;
223
+ this.store.replaceCredential(id, { ...latest.credential, claudeAiOauth: { ...latestOauth, ...tokens } });
224
+ return this.savedCredential(id);
225
+ }, { signal });
113
226
  }
114
227
  }
@@ -0,0 +1,132 @@
1
+ import * as plugins from './plugins.js';
2
+ import { errorCode } from './helpers.js';
3
+
4
+ /** One lock Claude Code takes, with the options it takes it with. */
5
+ interface IClaudeCodeLock {
6
+ /** The path proper-lockfile keys the lock by. */
7
+ file: string;
8
+ /** The lock directory. */
9
+ lockfilePath: string;
10
+ stale: number;
11
+ /** Omitted where Claude Code leaves it to proper-lockfile's default of half the stale time. */
12
+ update?: number;
13
+ /** Claude Code proceeds without this lock when it fails for any reason other than being held. */
14
+ bestEffort: boolean;
15
+ }
16
+
17
+ interface IHeldLock {
18
+ lockfilePath: string;
19
+ release: () => Promise<void>;
20
+ }
21
+
22
+ export interface IClaudeCodeLocksOptions {
23
+ /** How long an operation waits for a lock that Claude Code holds. */
24
+ timeoutMs?: number;
25
+ }
26
+
27
+ const DEFAULT_TIMEOUT_MS = 15_000;
28
+ const RETRY_MS = 250;
29
+
30
+ /**
31
+ * Claude Code's own locks around its login and global config, taken with its own library and options.
32
+ *
33
+ * A running Claude Code picks a swapped credential up on its next request, so a switch no longer waits for it to
34
+ * stop. What it must not do is interleave with that session's own writes: a token refresh that is in flight when
35
+ * the credential is swapped leaves the outgoing account's saved copy with a refresh token the server may already
36
+ * have replaced, and a config save that read `.claude.json` before the swap writes the previous account's
37
+ * metadata back. Claude Code 2.1.273 serialises those writers with proper-lockfile (see hints.md); this class
38
+ * takes the same locks, in the order a refresh nests them, waits a bounded time for a held one, and releases them
39
+ * in reverse order on every path. A lock left behind by a Claude Code that died is stale after its own threshold
40
+ * and is taken over, exactly as Claude Code itself would.
41
+ */
42
+ export class ClaudeCodeLocks {
43
+ private readonly timeoutMs: number;
44
+
45
+ constructor(private readonly configDir: string, private readonly configFile: string, optionsArg: IClaudeCodeLocksOptions = {}) {
46
+ this.timeoutMs = optionsArg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
47
+ }
48
+
49
+ /** The locks of Claude Code 2.1.273, in acquisition order. */
50
+ private async locks(): Promise<IClaudeCodeLock[]> {
51
+ // The OAuth refresh lock's legacy twin is keyed by the resolved directory; Claude Code falls back to the path as given.
52
+ const resolved = await plugins.fs.promises.realpath(this.configDir).catch(() => this.configDir);
53
+ const storageWrite = plugins.path.join(this.configDir, '.storage-write');
54
+ return [
55
+ { file: this.configDir, lockfilePath: plugins.path.join(this.configDir, '.oauth_refresh.lock'), stale: 60_000, update: 5_000, bestEffort: false },
56
+ { file: `${resolved}.lock`, lockfilePath: `${resolved}.lock`, stale: 60_000, update: 5_000, bestEffort: true },
57
+ { file: storageWrite, lockfilePath: `${storageWrite}.lock`, stale: 15_000, bestEffort: false },
58
+ { file: this.configFile, lockfilePath: `${this.configFile}.lock`, stale: 10_000, bestEffort: false },
59
+ ];
60
+ }
61
+
62
+ /**
63
+ * Runs a synchronous read-and-write of the native login while every lock is held.
64
+ *
65
+ * A held lock never makes authswitch sit on the others: like Claude Code, it releases what it holds, waits and
66
+ * starts again, so a live session is never blocked by a switch that is still waiting. A lock that could not be
67
+ * released afterwards is reported as an outcome line, because the write it guarded has completed and the lock
68
+ * expires on its own.
69
+ */
70
+ public async hold(actionArg: () => string[]): Promise<string[]> {
71
+ const lockfile = await plugins.loadProperLockfile();
72
+ const deadline = Date.now() + this.timeoutMs;
73
+ const held: IHeldLock[] = [];
74
+ const unreleased: string[] = [];
75
+ const releaseAll = async (): Promise<void> => {
76
+ for (const lock of held.splice(0).reverse()) {
77
+ // A compromised lock is no longer ours to release; the operation already failed for it.
78
+ try { await lock.release(); } catch (error) { if (errorCode(error) !== 'ERELEASED') unreleased.push(lock.lockfilePath); }
79
+ }
80
+ };
81
+ let outcome: { lines: string[] } | { error: unknown };
82
+ try {
83
+ for (const directory of new Set([this.configDir, plugins.path.dirname(this.configFile)])) {
84
+ plugins.fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
85
+ }
86
+ const locks = await this.locks();
87
+ for (;;) {
88
+ // Each attempt owns its flag, so a late report about a lock released in an earlier attempt changes nothing.
89
+ const attempt = { compromised: false };
90
+ const busy = await this.acquireAll(lockfile, locks, held, () => { attempt.compromised = true; });
91
+ if (busy === null) {
92
+ if (attempt.compromised) throw new Error('A Claude Code lock was taken over while authswitch took the others; nothing was changed. Try again.');
93
+ break;
94
+ }
95
+ await releaseAll();
96
+ if (Date.now() >= deadline) {
97
+ throw new Error(`Claude Code is refreshing or saving its login (${busy.lockfilePath} is held); nothing was changed. Try again in a moment.`);
98
+ }
99
+ await new Promise<void>(resolve => { setTimeout(resolve, Math.min(RETRY_MS, Math.max(1, deadline - Date.now()))); });
100
+ }
101
+ outcome = { lines: actionArg() };
102
+ } catch (error) { outcome = { error }; }
103
+ await releaseAll();
104
+ const note = unreleased.length ? `Claude Code's lock ${unreleased.join(', ')} could not be released; it expires on its own within a minute.` : null;
105
+ if ('error' in outcome) {
106
+ if (note === null) throw outcome.error;
107
+ throw new Error(`${outcome.error instanceof Error ? outcome.error.message : 'Credential operation failed.'} ${note}`);
108
+ }
109
+ return note === null ? outcome.lines : [...outcome.lines, note];
110
+ }
111
+
112
+ /** Takes the locks in order into `heldArg` and returns the first one another process holds, or null once all are held. */
113
+ private async acquireAll(
114
+ lockfileArg: plugins.TProperLockfile,
115
+ locksArg: readonly IClaudeCodeLock[],
116
+ heldArg: IHeldLock[],
117
+ onCompromisedArg: (errorArg: Error) => void,
118
+ ): Promise<IClaudeCodeLock | null> {
119
+ for (const lock of locksArg) {
120
+ try {
121
+ const release = await lockfileArg.lock(lock.file, {
122
+ lockfilePath: lock.lockfilePath, realpath: false, stale: lock.stale, update: lock.update, onCompromised: onCompromisedArg,
123
+ });
124
+ heldArg.push({ lockfilePath: lock.lockfilePath, release });
125
+ } catch (error) {
126
+ if (errorCode(error) === 'ELOCKED') return lock;
127
+ if (!lock.bestEffort) throw new Error(`Claude Code's lock ${lock.lockfilePath} could not be taken (${errorCode(error) ?? 'unknown error'}); nothing was changed.`);
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+ }
@@ -1,8 +1,8 @@
1
- import { credentialRecord, credentialText } from './classes.credentialstore.js';
1
+ import { credentialHash, credentialRecord, credentialText } from './classes.credentialstore.js';
2
+ import { claudeRequest, ClaudeLoginRejectedError, ClaudeRateLimitError, ClaudeRequestError } from './claudehttp.js';
3
+ import { latestRetryAt } from './ratelimit.js';
2
4
  import { plainText } from './formatting.js';
3
- import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
4
-
5
- class ClaudeStatusError extends Error {}
5
+ import type { IHarnessAccountStatus, IHarnessStatusFact, IHarnessStatusOptions, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
6
6
 
7
7
  interface IClaudeUsageWindows {
8
8
  windows: IHarnessUsageWindow[];
@@ -102,66 +102,96 @@ const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows
102
102
  return Array.isArray(limits) && limits.length ? limitWindows(limits) : { windows: keyedWindows(usage), unsupported: [] };
103
103
  };
104
104
 
105
- /** Read-only Claude Code OAuth account endpoints. No refresh, inference, or browser session is used. */
105
+ /** A profile lookup's answer. Only a lookup that could not be completed is thrown, and so never reused. */
106
+ type TClaudeProfile =
107
+ | { kind: 'found'; plan: string | undefined; facts: IHarnessStatusFact[] }
108
+ | { kind: 'mismatch' }
109
+ | { kind: 'unavailable'; problem: string };
110
+
111
+ /**
112
+ * How long a session reuses an account's profile. The watch polls every few minutes, and the profile (plan,
113
+ * organization, billing type) changes far more rarely, so it is read at most hourly there.
114
+ */
115
+ const PROFILE_REUSE_MS = 3_600_000;
116
+ const REQUEST_TIMEOUT_MS = 10_000;
117
+ const MISMATCH = 'Claude returned a different account or organization; no account data was displayed.';
118
+
119
+ /**
120
+ * Claude Code's read-only OAuth account endpoints: the profile and usage of one login, with the access token it
121
+ * carries. It never refreshes that token; `ClaudeCodeHarness` refreshes a saved login first, and leaves the active
122
+ * login's tokens to Claude Code. No inference or browser session is used.
123
+ */
106
124
  export class ClaudeAccountStatus {
107
- constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
108
- private async get(route: 'usage' | 'profile', accessToken: string, cancelArg?: AbortSignal): Promise<Record<string, unknown>> {
109
- const timeout = AbortSignal.timeout(10_000);
110
- const signal = cancelArg ? AbortSignal.any([cancelArg, timeout]) : timeout;
125
+ constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
126
+
127
+ private async get(route: 'usage' | 'profile', accessToken: string, signal?: AbortSignal): Promise<Record<string, unknown>> {
128
+ const { status, body } = await claudeRequest(this.fetcher, {
129
+ url: `https://api.anthropic.com/api/oauth/${route}`, method: 'GET', subject: 'Claude account', timeoutMs: REQUEST_TIMEOUT_MS, signal, now: this.now,
130
+ headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json',
131
+ ...(route === 'usage' ? { 'anthropic-beta': 'oauth-2025-04-20' } : { 'Cache-Control': 'no-cache' }) },
132
+ });
133
+ if (status === 401) throw new ClaudeLoginRejectedError();
134
+ if (status < 200 || status > 299) throw new ClaudeRequestError(`Claude account service returned HTTP ${status}.`);
135
+ if (body === null) throw new ClaudeRequestError('The Claude account service returned invalid data.');
136
+ return body;
137
+ }
138
+
139
+ /** The profile, from the session while it is younger than an hour; the session key is a hash of the token, never the token. */
140
+ private profile(token: string, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions): Promise<TClaudeProfile> {
141
+ const lookup = () => this.lookupProfile(token, account, optionsArg.signal);
142
+ return optionsArg.session ? optionsArg.session.reuse(`claude:profile:${credentialHash(token)}`, PROFILE_REUSE_MS, lookup) : lookup();
143
+ }
144
+
145
+ /** A rejected login and an unsupported or foreign profile are answers; a rate limit or a failed request is thrown. */
146
+ private async lookupProfile(token: string, account: Record<string, unknown>, signal?: AbortSignal): Promise<TClaudeProfile> {
147
+ let body: Record<string, unknown>;
148
+ try { body = await this.get('profile', token, signal); }
149
+ catch (error) {
150
+ if (error instanceof ClaudeLoginRejectedError) return { kind: 'unavailable', problem: `Profile: ${error.message}` };
151
+ throw error;
152
+ }
111
153
  try {
112
- const response = await this.fetcher(`https://api.anthropic.com/api/oauth/${route}`, {
113
- method: 'GET', redirect: 'error', signal,
114
- headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json',
115
- ...(route === 'usage' ? { 'anthropic-beta': 'oauth-2025-04-20' } : { 'Cache-Control': 'no-cache' }) },
116
- });
117
- if (!response.ok) {
118
- await response.body?.cancel();
119
- throw new ClaudeStatusError(response.status === 401 ? 'Login expired or was rejected. Log in again with Claude Code and save it.' : `Claude account service returned HTTP ${response.status}.`);
154
+ const user = credentialRecord(body.account);
155
+ const organization = credentialRecord(body.organization);
156
+ if (user.uuid !== account.accountUuid || organization.uuid !== account.organizationUuid) return { kind: 'mismatch' };
157
+ const facts: IHarnessStatusFact[] = [];
158
+ for (const [label, value] of [['Email', user.email], ['Organization', organization.name], ['Billing type', organization.billing_type], ['Seat tier', organization.seat_tier]]) {
159
+ if (credentialText(value)) facts.push({ section: 'Account', label: String(label), value: String(value) });
120
160
  }
121
- const reader = response.body?.getReader();
122
- if (!reader) throw new ClaudeStatusError('The Claude account service returned an empty response.');
123
- const chunks: Uint8Array[] = [];
124
- let size = 0;
125
- try {
126
- while (true) {
127
- const part = await reader.read();
128
- if (part.done) break;
129
- size += part.value.byteLength;
130
- if (size > 1024 * 1024) { await reader.cancel(); throw new ClaudeStatusError('The Claude account response is too large.'); }
131
- chunks.push(part.value);
132
- }
133
- } finally { reader.releaseLock(); }
134
- try { return credentialRecord(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
135
- catch { throw new ClaudeStatusError('The Claude account service returned invalid data.'); }
136
- } catch (error) {
137
- if (signal.aborted) throw new ClaudeStatusError(timeout.aborted ? 'The Claude account request timed out.' : 'The Claude account request was cancelled.');
138
- if (error instanceof ClaudeStatusError) throw error;
139
- throw new ClaudeStatusError('The Claude account service could not be reached.');
140
- }
161
+ return { kind: 'found', plan: credentialText(organization.organization_type)?.replace(/^claude_/, ''), facts };
162
+ } catch { return { kind: 'unavailable', problem: 'Claude profile response is unsupported; no profile values were inferred.' }; }
163
+ }
164
+
165
+ /** The plan the login was saved with, the only one known while the service cannot be asked. */
166
+ private storedSummary(oauth: Record<string, unknown>): Pick<IHarnessAccountStatus, 'summary'> {
167
+ const plan = credentialText(oauth.subscriptionType);
168
+ return plan ? { summary: { subscription: { plan, source: 'stored' } } } : {};
141
169
  }
170
+
171
+ /** A login that could not be made usable for a lookup: its stored plan and why, with the service's retry time for a rate limit. */
172
+ public unavailable(oauth: Record<string, unknown>, problem: string, rateLimit?: IHarnessAccountStatus['rateLimit']): IHarnessAccountStatus {
173
+ return { facts: [], problems: [problem], ...this.storedSummary(oauth), ...(rateLimit ? { rateLimit } : {}) };
174
+ }
175
+
142
176
  public async read(oauth: Record<string, unknown>, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
143
- const result: IHarnessAccountStatus = { facts: [], problems: [] };
144
- const storedPlan = credentialText(oauth.subscriptionType);
145
- if (storedPlan) result.summary = { subscription: { plan: storedPlan, source: 'stored' } };
177
+ const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(oauth) };
146
178
  if (!Array.isArray(oauth.scopes) || !oauth.scopes.includes('user:profile')) {
147
179
  result.facts.push({ section: 'Availability', label: 'Live account status', value: 'This login lacks the user:profile scope. Log in through Claude Code to obtain subscriber status.' });
148
180
  return result;
149
181
  }
150
182
  const token = credentialText(oauth.accessToken);
151
183
  if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
152
- const [profile, usage] = await Promise.allSettled([this.get('profile', token, optionsArg.signal), this.get('usage', token, optionsArg.signal)]);
184
+ const [profile, usage] = await Promise.allSettled([this.profile(token, account, optionsArg), this.get('usage', token, optionsArg.signal)]);
185
+ const failure = (reason: unknown): string => reason instanceof ClaudeRequestError ? reason.message : 'Lookup failed.';
153
186
  if (profile.status === 'fulfilled') {
154
- try {
155
- const user = credentialRecord(profile.value.account);
156
- const organization = credentialRecord(profile.value.organization);
157
- if (user.uuid !== account.accountUuid || organization.uuid !== account.organizationUuid) return { facts: [], problems: ['Claude returned a different account or organization; no account data was displayed.'] };
158
- const plan = credentialText(organization.organization_type)?.replace(/^claude_/, '');
159
- if (plan) result.summary = { ...result.summary, subscription: { plan, source: 'live' } };
160
- for (const [label, value] of [['Email', user.email], ['Organization', organization.name], ['Billing type', organization.billing_type], ['Seat tier', organization.seat_tier]]) {
161
- if (credentialText(value)) result.facts.push({ section: 'Account', label: String(label), value: String(value) });
162
- }
163
- } catch { result.problems.push('Claude profile response is unsupported; no profile values were inferred.'); }
164
- } else result.problems.push(`Profile: ${profile.reason instanceof ClaudeStatusError ? profile.reason.message : 'Lookup failed.'}`);
187
+ const found = profile.value;
188
+ if (found.kind === 'mismatch') return { facts: [], problems: [MISMATCH] };
189
+ if (found.kind === 'unavailable') result.problems.push(found.problem);
190
+ else {
191
+ if (found.plan) result.summary = { ...result.summary, subscription: { plan: found.plan, source: 'live' } };
192
+ result.facts.push(...found.facts.map(fact => ({ ...fact })));
193
+ }
194
+ } else result.problems.push(`Profile: ${failure(profile.reason)}`);
165
195
  if (usage.status === 'fulfilled') {
166
196
  try {
167
197
  const { windows, unsupported } = claudeUsageWindows(usage.value);
@@ -181,8 +211,11 @@ export class ClaudeAccountStatus {
181
211
  }
182
212
  if (unsupported.length) result.facts.push({ section: 'Availability', label: 'Unsupported limits', value: `The service returned limits without a supported reset window, which are not shown: ${unsupported.join(', ')}.` });
183
213
  } catch { result.problems.push('Claude usage response is unsupported; missing values were not inferred.'); }
184
- } else result.problems.push(`Usage: ${usage.reason instanceof ClaudeStatusError ? usage.reason.message : 'Lookup failed.'}`);
185
- result.facts.push({ section: 'Availability', label: 'Billing dates and earned resets', value: 'Renewal dates, cancellation dates and earned reset credits are not exposed by these Claude Code endpoints.' });
214
+ } else result.problems.push(`Usage: ${failure(usage.reason)}`);
215
+ const limited = [profile, usage].flatMap(item => item.status === 'rejected' && item.reason instanceof ClaudeRateLimitError ? [item.reason] : []);
216
+ if (limited.length) result.rateLimit = { retryAt: latestRetryAt(limited.map(error => error.retryAt)) };
217
+ // The missing renewal dates are the harness's `renewalUnavailableReason`, stated once by the views that show them.
218
+ result.facts.push({ section: 'Availability', label: 'Earned resets', value: 'authswitch does not read earned reset credits for Claude Code logins.' });
186
219
  return result;
187
220
  }
188
221
  }
@@ -0,0 +1,85 @@
1
+ import { credentialText } from './classes.credentialstore.js';
2
+ import { claudeRequest, ClaudeLoginRejectedError, ClaudeRequestError } from './claudehttp.js';
3
+
4
+ /** Claude Code 2.1.273's token endpoint (`TOKEN_URL`, bundle offset @12733057; see hints.md). */
5
+ const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
6
+ /** Claude Code 2.1.273's public OAuth client (`CLIENT_ID`, @12733057), used when a login does not name its own. */
7
+ export const CLAUDE_CODE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
8
+ /** Claude Code 2.1.273 refreshes an access token that expires within five minutes (`cF`, @15359182). */
9
+ const REFRESH_MARGIN_MS = 300_000;
10
+ /**
11
+ * Claude Code 2.1.273 allows a refresh 30 seconds. A refresh is never cut shorter than that, nor cancelled once sent:
12
+ * the service may already have replaced the refresh token the request carries.
13
+ */
14
+ const REFRESH_TIMEOUT_MS = 30_000;
15
+
16
+ /** The token fields a refresh replaces in a stored `claudeAiOauth`; every other field is kept. */
17
+ export interface IClaudeTokens {
18
+ accessToken: string;
19
+ refreshToken: string;
20
+ /** Milliseconds since the epoch, as Claude Code stores them. */
21
+ expiresAt: number;
22
+ refreshTokenExpiresAt?: number;
23
+ scopes?: string[];
24
+ }
25
+
26
+ const seconds = (valueArg: unknown): number | undefined =>
27
+ typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0 ? valueArg : undefined;
28
+
29
+ /**
30
+ * Refreshes a saved Claude Code login with the refresh-token grant Claude Code 2.1.273 uses (`RQ`, @15355055).
31
+ *
32
+ * It only talks to the token endpoint; which login is refreshed, and how the result is stored, belongs to the harness.
33
+ */
34
+ export class ClaudeTokenRefresh {
35
+ constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
36
+
37
+ /**
38
+ * Whether Claude Code would refresh this login before using it: its access token expires within five minutes. A
39
+ * login without a numeric expiry or without a refresh token is used as it is, exactly as Claude Code does.
40
+ */
41
+ public due(oauthArg: Record<string, unknown>): boolean {
42
+ const expiresAt = oauthArg.expiresAt;
43
+ return typeof expiresAt === 'number' && Number.isFinite(expiresAt) && credentialText(oauthArg.refreshToken) !== undefined
44
+ && this.now() + REFRESH_MARGIN_MS >= expiresAt;
45
+ }
46
+
47
+ /**
48
+ * New tokens for the login. A response without a refresh token keeps the one sent, and one without a scope keeps the
49
+ * stored scopes, as Claude Code merges them. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
50
+ * rejects is `ClaudeLoginRejectedError`, and a rate limit is `ClaudeRateLimitError`.
51
+ */
52
+ public async refresh(oauthArg: Record<string, unknown>): Promise<IClaudeTokens> {
53
+ const refreshToken = credentialText(oauthArg.refreshToken);
54
+ const scopes = oauthArg.scopes;
55
+ if (!refreshToken || !Array.isArray(scopes) || !scopes.every(scope => typeof scope === 'string')) {
56
+ throw new ClaudeRequestError('This saved login has no refresh token or scopes to refresh with. Log in again with Claude Code and save it.');
57
+ }
58
+ const { status, body } = await claudeRequest(this.fetcher, {
59
+ url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
60
+ body: JSON.stringify({
61
+ grant_type: 'refresh_token', refresh_token: refreshToken,
62
+ client_id: credentialText(oauthArg.clientId) ?? CLAUDE_CODE_CLIENT_ID, scope: scopes.join(' '),
63
+ }),
64
+ timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
65
+ });
66
+ if ((status === 400 || status === 401) && body?.error === 'invalid_grant') throw new ClaudeLoginRejectedError();
67
+ if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
68
+ const receivedAt = this.now();
69
+ const accessToken = credentialText(body?.access_token);
70
+ const tokenType = body?.token_type;
71
+ const expiresIn = seconds(body?.expires_in);
72
+ const refreshExpiresIn = body?.refresh_token_expires_in == null ? null : seconds(body.refresh_token_expires_in);
73
+ const rotated = body?.refresh_token == null ? null : credentialText(body.refresh_token);
74
+ const scope = body?.scope == null ? null : typeof body.scope === 'string' ? body.scope.split(/\s+/).filter(Boolean) : undefined;
75
+ if (!accessToken || (tokenType != null && (typeof tokenType !== 'string' || tokenType.toLowerCase() !== 'bearer'))
76
+ || expiresIn === undefined || refreshExpiresIn === undefined || rotated === undefined || scope === undefined) {
77
+ throw new ClaudeRequestError('The Claude sign-in service returned an unsupported token response; the saved login was not changed.');
78
+ }
79
+ return {
80
+ accessToken, refreshToken: rotated ?? refreshToken, expiresAt: receivedAt + expiresIn * 1000,
81
+ ...(refreshExpiresIn === null ? {} : { refreshTokenExpiresAt: receivedAt + refreshExpiresIn * 1000 }),
82
+ ...(scope === null || !scope.length ? {} : { scopes: scope }),
83
+ };
84
+ }
85
+ }