@modelprofile.com/authswitch 3.3.0 → 5.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 +52 -15
  3. package/dist_ts/accounts.js +110 -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 +81 -2
  7. package/dist_ts/classes.claudecodeharness.js +208 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +38 -0
  9. package/dist_ts/classes.claudecodelocks.js +118 -0
  10. package/dist_ts/classes.claudestatus.d.ts +15 -2
  11. package/dist_ts/classes.claudestatus.js +100 -83
  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 +126 -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 +7 -2
  19. package/dist_ts/classes.codexstatus.js +49 -21
  20. package/dist_ts/classes.credentialstore.d.ts +43 -2
  21. package/dist_ts/classes.credentialstore.js +60 -13
  22. package/dist_ts/classes.fileharness.d.ts +45 -21
  23. package/dist_ts/classes.fileharness.js +64 -26
  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 +8 -4
  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 +349 -115
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +122 -35
  60. package/ts/classes.accountlist.ts +2 -219
  61. package/ts/classes.claudecodeharness.ts +199 -13
  62. package/ts/classes.claudecodelocks.ts +133 -0
  63. package/ts/classes.claudestatus.ts +100 -65
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +114 -54
  66. package/ts/classes.codexharness.ts +4 -0
  67. package/ts/classes.codexstatus.ts +40 -18
  68. package/ts/classes.credentialstore.ts +78 -10
  69. package/ts/classes.fileharness.ts +76 -34
  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 +7 -3
  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,10 +1,13 @@
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, type IStoredCredential } 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
- import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
10
+ import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
8
11
 
9
12
  export interface IClaudeCodeHarnessOptions {
10
13
  configDir?: string;
@@ -17,15 +20,51 @@ 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
 
29
+ /**
30
+ * How long a read waits for the store and for Claude Code's locks before leaving the saved copy as it found it.
31
+ *
32
+ * Reading accounts must stay as quick as it is while a switch, a refresh or a Claude Code session holds a lock. The
33
+ * saved copy has nothing to finish: whatever the operation holding the lock writes is what the next read mirrors.
34
+ */
35
+ const MIRROR_WAIT_MS = 1_000;
36
+
37
+ /**
38
+ * A saved login the sign-in service refuses, in terms the user can act on.
39
+ *
40
+ * Claude Code rotates the refresh token of the login it is running on (hints.md), and a saved copy keeps the token
41
+ * it was stashed with. Once such a login has been replaced outside authswitch -- a `claude /login`, a new device
42
+ * login -- nothing writes the rotation back, and the service was observed refusing the older token the saved copy
43
+ * still holds (2026-09-18, two saved logins). No retry, no other account and no switch changes that answer; logging
44
+ * in again and saving that login does, so the message says so.
45
+ */
46
+ const SAVED_LOGIN_REJECTED = `${CLAUDE_LOGIN_REJECTED} Claude Code rotates a login's refresh token as it uses it, and the service can refuse the older one a saved copy still holds once that login has been replaced outside authswitch; authswitch claude stash --keep saves the new one.`;
47
+
22
48
  const accountCaches = ['additionalModelOptionsCache', 'additionalModelOptionsAnsweredAt', 'additionalModelCostsCache', 'modelAccessCache', 'orgModelDefaultCache', 'lastSeenOrgDefaultUpdatedAt', 'clientDataCache', 'clientDataCacheSlots', 'autoCompactWindowsCache', 'cachedUsageUtilization'];
23
49
 
24
- /** File-backed Claude Code subscriber login and its matching global oauthAccount metadata. */
50
+ /**
51
+ * File-backed Claude Code subscriber login and its matching global oauthAccount metadata.
52
+ *
53
+ * A running Claude Code (verified against 2.1.273) re-reads its credential file whenever the file's mtime changes,
54
+ * before every request, and watches `.claude.json`, so a swap is picked up live without a restart. Every native read
55
+ * and write therefore runs under Claude Code's own locks instead of asking for it to be stopped.
56
+ *
57
+ * Nothing else refreshes a saved login, so its access token expires a few hours after it was saved. Reading such a
58
+ * login's status refreshes it first, the way Claude Code would; the active login's tokens belong to Claude Code, and
59
+ * reading the accounts mirrors that login into its own saved copy, so the stash keeps what Claude Code last wrote.
60
+ */
25
61
  export class ClaudeCodeHarness extends FileHarness {
26
62
  public readonly id = 'claude';
27
63
  public readonly label = 'Claude Code';
28
64
  public readonly loginHint = 'Log in using claude auth login, then authswitch claude stash --keep; Claude Code may stay running.';
65
+ public readonly liveSwap = true;
66
+ public readonly autoSwitch = true;
67
+ public readonly renewalUnavailableReason = "renewal dates are not exposed by Claude's account API.";
29
68
  protected readonly store: CredentialStore;
30
69
  private readonly file: string;
31
70
  private readonly configFile: string;
@@ -34,6 +73,8 @@ export class ClaudeCodeHarness extends FileHarness {
34
73
  private readonly status: ClaudeAccountStatus;
35
74
  public readonly processes: IHarnessProcessControl;
36
75
  private readonly settingsFiles: string[];
76
+ private readonly locks: ClaudeCodeLocks;
77
+ private readonly tokens: ClaudeTokenRefresh;
37
78
 
38
79
  constructor(options: IClaudeCodeHarnessOptions = {}) {
39
80
  super();
@@ -44,10 +85,16 @@ export class ClaudeCodeHarness extends FileHarness {
44
85
  this.configFile = options.globalConfigFile ?? plugins.path.join(custom || plugins.os.homedir(), '.claude.json');
45
86
  this.store = new CredentialStore(this.id, options.stashRoot);
46
87
  this.platform = options.platform ?? process.platform;
47
- this.status = new ClaudeAccountStatus(options.fetch);
88
+ this.status = new ClaudeAccountStatus(options.fetch, options.now);
89
+ this.tokens = new ClaudeTokenRefresh(options.fetch, options.now);
48
90
  this.processes = options.processes ?? new HarnessProcesses('claude', { platform: this.platform });
49
91
  this.settingsFiles = options.settingsFiles ?? [plugins.path.join(dir, 'settings.json'),
50
92
  plugins.path.join(process.cwd(), '.claude/settings.json'), plugins.path.join(process.cwd(), '.claude/settings.local.json')];
93
+ this.locks = new ClaudeCodeLocks(dir, this.configFile, { timeoutMs: options.lockTimeoutMs });
94
+ }
95
+ /** A login this adapter cannot manage has no Claude Code file to lock; the action itself reports why. */
96
+ protected nativeTransaction(actionArg: () => string[]): Promise<string[]> {
97
+ return this.unavailableReason() === null ? this.locks.hold(actionArg) : super.nativeTransaction(actionArg);
51
98
  }
52
99
  /** One record holds the credential and its matching metadata, so both files must be proven stable. */
53
100
  protected nativeSources(): (string | null)[] { return [readCredentialRaw(this.file), readCredentialRaw(this.configFile)]; }
@@ -59,8 +106,9 @@ export class ClaudeCodeHarness extends FileHarness {
59
106
  || !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
107
  return { slotId, identity: JSON.stringify([account.accountUuid, account.organizationUuid]), label: String(account.emailAddress).toLowerCase(), credential };
61
108
  }
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.' };
109
+ /** Why this installation's login is not a subscriber file login this adapter can manage; null when it is. */
110
+ private unavailableReason(): string | null {
111
+ 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
112
  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
113
  const settings: Record<string, unknown> = {};
66
114
  const environment: Record<string, unknown> = { ...this.env };
@@ -69,7 +117,12 @@ export class ClaudeCodeHarness extends FileHarness {
69
117
  Object.assign(settings, data);
70
118
  if (data.env != null) Object.assign(environment, credentialRecord(data.env));
71
119
  }
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.' };
120
+ return overrides.some(key => environment[key]) || credentialText(settings.apiKeyHelper)
121
+ ? 'Claude Code has a settings or environment credential/provider override. Remove it before managing subscriber file logins.' : null;
122
+ }
123
+ protected snapshot(): IFileHarnessSnapshot {
124
+ const unavailable = this.unavailableReason();
125
+ if (unavailable !== null) return { accounts: [], unavailable };
73
126
  const data = readCredentialDocument(this.file).data;
74
127
  if (data.claudeAiOauth == null) return { accounts: [], unavailable: null };
75
128
  const config = readCredentialDocument(this.configFile).data;
@@ -90,7 +143,7 @@ export class ClaudeCodeHarness extends FileHarness {
90
143
  for (const key of accountCaches) delete nextConfig[key];
91
144
  const raw = JSON.stringify(next, null, 2) + '\n';
92
145
  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.');
146
+ 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
147
  plugins.fs.mkdirSync(plugins.path.dirname(this.file), { recursive: true, mode: 0o700 });
95
148
  plugins.fs.mkdirSync(plugins.path.dirname(this.configFile), { recursive: true, mode: 0o700 });
96
149
  try {
@@ -103,12 +156,145 @@ export class ClaudeCodeHarness extends FileHarness {
103
156
  try {
104
157
  if (readCredentialDocument(this.file).raw === raw) writeSecretFileAtomically(this.file, original.raw ?? '{}\n');
105
158
  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.');
159
+ } 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.'); }
160
+ 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
161
  }
109
162
  }
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);
163
+ /** The hash that identifies a saved login's refresh token, so a rejected grant is remembered without keeping it. */
164
+ private grantKey(oauthArg: Record<string, unknown>): string { return credentialHash(credentialText(oauthArg.refreshToken) ?? ''); }
165
+
166
+ /**
167
+ * The harness's accounts, with the saved copy of the active login brought up to date first.
168
+ *
169
+ * Claude Code rotates the refresh token of the login it is running on, and until now only a switch or a save wrote
170
+ * that back into the stash. A login replaced outside authswitch between two switches therefore left a saved copy
171
+ * holding the token from before the rotation, which the service was observed refusing -- a saved copy that only
172
+ * turns out to be unusable when that account's status is next read. Every read of the native login now mirrors it
173
+ * into its own saved record, so the stash keeps the credential Claude Code last wrote for an account that has one.
174
+ */
175
+ public async readState(): Promise<IHarnessState> {
176
+ await this.mirrorActiveLogin();
177
+ return super.readState();
178
+ }
179
+
180
+ /**
181
+ * Writes the active login into its own saved record when the two differ.
182
+ *
183
+ * This is the only write a read makes, and it is advisory: a store another operation holds, a lock Claude Code
184
+ * holds and files that keep changing under it all leave the saved copy as it is, because the next read mirrors
185
+ * whatever they wrote. The native files are only ever read, and a login without a saved record of its own is
186
+ * never saved implicitly -- that is what `stash` is for.
187
+ */
188
+ private async mirrorActiveLogin(): Promise<void> {
189
+ try {
190
+ if (this.staleSavedCopy(this.snapshot()) === null) return;
191
+ await this.store.locked(() => this.locks.hold(() => {
192
+ // Under both locks the decision is taken again, from a snapshot proven not to have been read across a write.
193
+ const stale = this.staleSavedCopy(this.stableSnapshot());
194
+ if (stale !== null) this.store.replaceCredential(stale.id, stale.credential);
195
+ return [];
196
+ }, { timeoutMs: MIRROR_WAIT_MS }), { signal: AbortSignal.timeout(MIRROR_WAIT_MS) });
197
+ } catch { /* A saved copy that could not be brought up to date is no reason to fail a read of the accounts. */ }
198
+ }
199
+
200
+ /** The active login's saved record when it no longer holds what Claude Code wrote, with the credential to write. */
201
+ private staleSavedCopy(snapshotArg: IFileHarnessSnapshot): { id: string; credential: Record<string, unknown> } | null {
202
+ // Claude Code runs one login, and an installation this adapter cannot read reports none at all.
203
+ const [active] = snapshotArg.accounts;
204
+ if (!active) return null;
205
+ const id = this.store.id(active.slotId, active.identity);
206
+ let saved: IStoredCredential;
207
+ try { saved = this.store.read(id); } catch { return null; }
208
+ return credentialHash(JSON.stringify(saved.credential)) === credentialHash(JSON.stringify(active.credential))
209
+ ? null : { id, credential: active.credential };
210
+ }
211
+
212
+ /**
213
+ * What the native files say about this account: its active login, and why they could not be read at all.
214
+ *
215
+ * Both answers come from one snapshot, because only their combination proves that a login is *not* the active one.
216
+ * An installation this adapter cannot read reports no accounts, so the login Claude Code is actually running on
217
+ * reads as inactive there; anything that would renew a saved login has to refuse instead.
218
+ */
219
+ private nativeView(idArg: string): { active: IFileAccount | null; unavailable: string | null } {
220
+ const snapshot = this.snapshot();
221
+ return { active: snapshot.accounts.find(account => this.store.id(account.slotId, account.identity) === idArg) ?? null, unavailable: snapshot.unavailable };
222
+ }
223
+
224
+ /**
225
+ * The account's status. The active login is read as it is: Claude Code refreshes it. A saved login whose access token
226
+ * is due is refreshed first; when that fails, the status says why and keeps the stored plan, and the saved login is
227
+ * left exactly as it was.
228
+ *
229
+ * A refresh is only ever sent for a login the native files prove is not the active one (`nativeView`), and a grant
230
+ * the service already rejected is not sent again while the saved record still holds it.
231
+ */
232
+ public async readAccountStatus(id: string, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
233
+ const read = (accountArg: IFileAccount) =>
234
+ this.status.read(credentialRecord(accountArg.credential.claudeAiOauth), credentialRecord(accountArg.credential.oauthAccount), optionsArg);
235
+ const view = this.nativeView(id);
236
+ if (view.active) return read(view.active);
237
+ const saved = this.savedCredential(id);
238
+ const oauth = credentialRecord(saved.credential.claudeAiOauth);
239
+ if (!this.tokens.due(oauth)) return read(saved);
240
+ if (view.unavailable !== null) {
241
+ return this.status.unavailable(oauth, `Login refresh: this saved login's access token has expired and cannot be renewed for this installation. ${view.unavailable}`);
242
+ }
243
+ if (this.store.read(id).rejectedGrant === this.grantKey(oauth)) return this.status.unavailable(oauth, `Login refresh: ${SAVED_LOGIN_REJECTED}`);
244
+ let refreshed: IFileAccount;
245
+ try { refreshed = await this.refreshSaved(id, optionsArg.signal); }
246
+ catch (error) {
247
+ if (optionsArg.signal?.aborted) throw error;
248
+ const problem = error instanceof ClaudeLoginRejectedError ? SAVED_LOGIN_REJECTED
249
+ : error instanceof Error ? error.message : 'the saved login could not be refreshed.';
250
+ return this.status.unavailable(oauth, `Login refresh: ${problem}`,
251
+ error instanceof ClaudeRateLimitError ? { retryAt: error.retryAt } : undefined);
252
+ }
253
+ return read(refreshed);
254
+ }
255
+
256
+ /**
257
+ * A saved login with fresh tokens, refreshed as the only operation on the store: a switch, a save or another refresh
258
+ * waits for it, so none of them can read or activate a refresh token the service is replacing. Under the lock the
259
+ * native files are read again, so the decision that this is not the active login is taken where it holds: one that
260
+ * became active meanwhile belongs to Claude Code and is returned as it is, one in an installation that can no
261
+ * longer be read is returned unrefreshed, and one another refresh already renewed is not refreshed twice. The new
262
+ * tokens replace the saved ones only while the record still holds the refresh token that was sent, the
263
+ * compare-and-swap Claude Code 2.1.273 applies to its own store (`$fn`, @15410824).
264
+ */
265
+ private refreshSaved(id: string, signal?: AbortSignal): Promise<IFileAccount> {
266
+ return this.store.locked(async () => {
267
+ const view = this.nativeView(id);
268
+ if (view.active) return view.active;
269
+ const saved = this.savedCredential(id);
270
+ const oauth = credentialRecord(saved.credential.claudeAiOauth);
271
+ if (view.unavailable !== null || !this.tokens.due(oauth)) return saved;
272
+ signal?.throwIfAborted();
273
+ let tokens: IClaudeTokens;
274
+ try { tokens = await this.tokens.refresh(oauth); }
275
+ catch (error) {
276
+ // Only this token was rejected; a login saved again replaces it and is tried like any other.
277
+ if (error instanceof ClaudeLoginRejectedError) this.recordRejectedGrant(id, oauth);
278
+ throw error;
279
+ }
280
+ const latest = this.savedCredential(id);
281
+ const latestOauth = credentialRecord(latest.credential.claudeAiOauth);
282
+ if (latestOauth.refreshToken !== oauth.refreshToken) return latest;
283
+ this.store.replaceCredential(id, { ...latest.credential, claudeAiOauth: { ...latestOauth, ...tokens } });
284
+ return this.savedCredential(id);
285
+ }, { signal });
286
+ }
287
+
288
+ /**
289
+ * Remembers a rejected grant in the saved record, so the next process does not send it again either -- a watch
290
+ * would otherwise replay a dead login every tick, and every new CLI run once more. It is written only while the
291
+ * record still holds the token that was sent, and a record that could not be written changes nothing but that:
292
+ * the refusal the caller reports is the answer, and the grant is simply tried once more.
293
+ */
294
+ private recordRejectedGrant(id: string, oauthArg: Record<string, unknown>): void {
295
+ try {
296
+ const latest = credentialRecord(this.savedCredential(id).credential.claudeAiOauth);
297
+ if (latest.refreshToken === oauthArg.refreshToken) this.store.rememberRejectedGrant(id, this.grantKey(oauthArg));
298
+ } catch { /* Recording is advisory; the rejection is reported either way. */ }
113
299
  }
114
300
  }
@@ -0,0 +1,133 @@
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. `timeoutMs` shortens the wait for a caller that must not make a user wait for it -- a read
69
+ * bringing a saved copy up to date has nothing to finish and can leave it to the next read.
70
+ */
71
+ public async hold(actionArg: () => string[], optionsArg: { timeoutMs?: number } = {}): Promise<string[]> {
72
+ const lockfile = await plugins.loadProperLockfile();
73
+ const deadline = Date.now() + (optionsArg.timeoutMs ?? this.timeoutMs);
74
+ const held: IHeldLock[] = [];
75
+ const unreleased: string[] = [];
76
+ const releaseAll = async (): Promise<void> => {
77
+ for (const lock of held.splice(0).reverse()) {
78
+ // A compromised lock is no longer ours to release; the operation already failed for it.
79
+ try { await lock.release(); } catch (error) { if (errorCode(error) !== 'ERELEASED') unreleased.push(lock.lockfilePath); }
80
+ }
81
+ };
82
+ let outcome: { lines: string[] } | { error: unknown };
83
+ try {
84
+ for (const directory of new Set([this.configDir, plugins.path.dirname(this.configFile)])) {
85
+ plugins.fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
86
+ }
87
+ const locks = await this.locks();
88
+ for (;;) {
89
+ // Each attempt owns its flag, so a late report about a lock released in an earlier attempt changes nothing.
90
+ const attempt = { compromised: false };
91
+ const busy = await this.acquireAll(lockfile, locks, held, () => { attempt.compromised = true; });
92
+ if (busy === null) {
93
+ if (attempt.compromised) throw new Error('A Claude Code lock was taken over while authswitch took the others; nothing was changed. Try again.');
94
+ break;
95
+ }
96
+ await releaseAll();
97
+ if (Date.now() >= deadline) {
98
+ throw new Error(`Claude Code is refreshing or saving its login (${busy.lockfilePath} is held); nothing was changed. Try again in a moment.`);
99
+ }
100
+ await new Promise<void>(resolve => { setTimeout(resolve, Math.min(RETRY_MS, Math.max(1, deadline - Date.now()))); });
101
+ }
102
+ outcome = { lines: actionArg() };
103
+ } catch (error) { outcome = { error }; }
104
+ await releaseAll();
105
+ const note = unreleased.length ? `Claude Code's lock ${unreleased.join(', ')} could not be released; it expires on its own within a minute.` : null;
106
+ if ('error' in outcome) {
107
+ if (note === null) throw outcome.error;
108
+ throw new Error(`${outcome.error instanceof Error ? outcome.error.message : 'Credential operation failed.'} ${note}`);
109
+ }
110
+ return note === null ? outcome.lines : [...outcome.lines, note];
111
+ }
112
+
113
+ /** Takes the locks in order into `heldArg` and returns the first one another process holds, or null once all are held. */
114
+ private async acquireAll(
115
+ lockfileArg: plugins.TProperLockfile,
116
+ locksArg: readonly IClaudeCodeLock[],
117
+ heldArg: IHeldLock[],
118
+ onCompromisedArg: (errorArg: Error) => void,
119
+ ): Promise<IClaudeCodeLock | null> {
120
+ for (const lock of locksArg) {
121
+ try {
122
+ const release = await lockfileArg.lock(lock.file, {
123
+ lockfilePath: lock.lockfilePath, realpath: false, stale: lock.stale, update: lock.update, onCompromised: onCompromisedArg,
124
+ });
125
+ heldArg.push({ lockfilePath: lock.lockfilePath, release });
126
+ } catch (error) {
127
+ if (errorCode(error) === 'ELOCKED') return lock;
128
+ if (!lock.bestEffort) throw new Error(`Claude Code's lock ${lock.lockfilePath} could not be taken (${errorCode(error) ?? 'unknown error'}); nothing was changed.`);
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+ }