@modelprofile.com/authswitch 4.0.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.
@@ -1,13 +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, credentialHash, 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
6
  import { ClaudeCodeLocks } from './classes.claudecodelocks.js';
7
7
  import { ClaudeTokenRefresh, type IClaudeTokens } from './classes.claudetokenrefresh.js';
8
8
  import { CLAUDE_LOGIN_REJECTED, ClaudeLoginRejectedError, ClaudeRateLimitError } from './claudehttp.js';
9
9
  import { writeSecretFileAtomically } from './helpers.js';
10
- import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
10
+ import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
11
11
 
12
12
  export interface IClaudeCodeHarnessOptions {
13
13
  configDir?: string;
@@ -26,6 +26,25 @@ export interface IClaudeCodeHarnessOptions {
26
26
  now?: () => number;
27
27
  }
28
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
+
29
48
  const accountCaches = ['additionalModelOptionsCache', 'additionalModelOptionsAnsweredAt', 'additionalModelCostsCache', 'modelAccessCache', 'orgModelDefaultCache', 'lastSeenOrgDefaultUpdatedAt', 'clientDataCache', 'clientDataCacheSlots', 'autoCompactWindowsCache', 'cachedUsageUtilization'];
30
49
 
31
50
  /**
@@ -36,7 +55,8 @@ const accountCaches = ['additionalModelOptionsCache', 'additionalModelOptionsAns
36
55
  * and write therefore runs under Claude Code's own locks instead of asking for it to be stopped.
37
56
  *
38
57
  * 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.
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.
40
60
  */
41
61
  export class ClaudeCodeHarness extends FileHarness {
42
62
  public readonly id = 'claude';
@@ -55,14 +75,6 @@ export class ClaudeCodeHarness extends FileHarness {
55
75
  private readonly settingsFiles: string[];
56
76
  private readonly locks: ClaudeCodeLocks;
57
77
  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>();
66
78
 
67
79
  constructor(options: IClaudeCodeHarnessOptions = {}) {
68
80
  super();
@@ -151,6 +163,52 @@ export class ClaudeCodeHarness extends FileHarness {
151
163
  /** The hash that identifies a saved login's refresh token, so a rejected grant is remembered without keeping it. */
152
164
  private grantKey(oauthArg: Record<string, unknown>): string { return credentialHash(credentialText(oauthArg.refreshToken) ?? ''); }
153
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
+
154
212
  /**
155
213
  * What the native files say about this account: its active login, and why they could not be read at all.
156
214
  *
@@ -182,12 +240,14 @@ export class ClaudeCodeHarness extends FileHarness {
182
240
  if (view.unavailable !== null) {
183
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}`);
184
242
  }
185
- if (this.rejectedGrants.get(id) === this.grantKey(oauth)) return this.status.unavailable(oauth, `Login refresh: ${CLAUDE_LOGIN_REJECTED}`);
243
+ if (this.store.read(id).rejectedGrant === this.grantKey(oauth)) return this.status.unavailable(oauth, `Login refresh: ${SAVED_LOGIN_REJECTED}`);
186
244
  let refreshed: IFileAccount;
187
245
  try { refreshed = await this.refreshSaved(id, optionsArg.signal); }
188
246
  catch (error) {
189
247
  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.'}`,
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}`,
191
251
  error instanceof ClaudeRateLimitError ? { retryAt: error.retryAt } : undefined);
192
252
  }
193
253
  return read(refreshed);
@@ -214,7 +274,7 @@ export class ClaudeCodeHarness extends FileHarness {
214
274
  try { tokens = await this.tokens.refresh(oauth); }
215
275
  catch (error) {
216
276
  // 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));
277
+ if (error instanceof ClaudeLoginRejectedError) this.recordRejectedGrant(id, oauth);
218
278
  throw error;
219
279
  }
220
280
  const latest = this.savedCredential(id);
@@ -224,4 +284,17 @@ export class ClaudeCodeHarness extends FileHarness {
224
284
  return this.savedCredential(id);
225
285
  }, { signal });
226
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. */ }
299
+ }
227
300
  }
@@ -65,11 +65,12 @@ export class ClaudeCodeLocks {
65
65
  * A held lock never makes authswitch sit on the others: like Claude Code, it releases what it holds, waits and
66
66
  * starts again, so a live session is never blocked by a switch that is still waiting. A lock that could not be
67
67
  * released afterwards is reported as an outcome line, because the write it guarded has completed and the lock
68
- * expires on its own.
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.
69
70
  */
70
- public async hold(actionArg: () => string[]): Promise<string[]> {
71
+ public async hold(actionArg: () => string[], optionsArg: { timeoutMs?: number } = {}): Promise<string[]> {
71
72
  const lockfile = await plugins.loadProperLockfile();
72
- const deadline = Date.now() + this.timeoutMs;
73
+ const deadline = Date.now() + (optionsArg.timeoutMs ?? this.timeoutMs);
73
74
  const held: IHeldLock[] = [];
74
75
  const unreleased: string[] = [];
75
76
  const releaseAll = async (): Promise<void> => {
@@ -2,6 +2,7 @@ import { credentialHash, credentialRecord, credentialText } from './classes.cred
2
2
  import { claudeRequest, ClaudeLoginRejectedError, ClaudeRateLimitError, ClaudeRequestError } from './claudehttp.js';
3
3
  import { latestRetryAt } from './ratelimit.js';
4
4
  import { plainText } from './formatting.js';
5
+ import { usageWindowPeriod } from './accounts.js';
5
6
  import type { IHarnessAccountStatus, IHarnessStatusFact, IHarnessStatusOptions, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
6
7
 
7
8
  interface IClaudeUsageWindows {
@@ -22,31 +23,31 @@ const serverName = (value: unknown): string => {
22
23
  return name;
23
24
  };
24
25
 
25
- /** Keyed windows, read only from a usage body that carries no limits[] rows. */
26
+ /** Keyed windows, read only from a usage body that carries no limits[] rows: what each meter is about, and how long its window is. */
26
27
  const KEYED_WINDOWS = [
27
- ['five_hour', 'Claude five-hour', 18000, 'account'], ['seven_day', 'Claude weekly', 604800, 'account'],
28
- ['seven_day_oauth_apps', 'OAuth apps weekly', 604800, 'feature'], ['seven_day_opus', 'Opus weekly', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet weekly', 604800, 'feature'],
28
+ ['five_hour', 'Claude', 18000, 'account'], ['seven_day', 'Claude', 604800, 'account'],
29
+ ['seven_day_oauth_apps', 'OAuth apps', 604800, 'feature'], ['seven_day_opus', 'Opus', 604800, 'feature'], ['seven_day_sonnet', 'Sonnet', 604800, 'feature'],
29
30
  ] as const;
30
31
 
31
32
  const keyedWindows = (usage: Record<string, unknown>): IHarnessUsageWindow[] => {
32
33
  const windows: IHarnessUsageWindow[] = [];
33
- for (const [key, label, durationSeconds, scope] of KEYED_WINDOWS) {
34
+ for (const [key, subject, durationSeconds, scope] of KEYED_WINDOWS) {
34
35
  if (usage[key] == null) continue;
35
36
  const window = credentialRecord(usage[key]);
36
37
  if (window.utilization == null) continue;
37
38
  if (typeof window.utilization !== 'number' || !Number.isFinite(window.utilization) || window.utilization < 0) throw new Error('Unsupported Claude usage utilization.');
38
- windows.push({ label, durationSeconds, scope, usedPercent: window.utilization, resetAt: resetTimestamp(window.resets_at) });
39
+ windows.push({ label: `${subject} ${usageWindowPeriod(durationSeconds)}`, durationSeconds, scope, usedPercent: window.utilization, resetAt: resetTimestamp(window.resets_at) });
39
40
  }
40
41
  return windows;
41
42
  };
42
43
 
43
44
  /**
44
- * The limits[] row groups the window model represents: each group's duration, the period word its
45
- * labels use, and the kind of the group's unscoped account meter, which keeps the plain label.
45
+ * The limits[] row groups the window model represents: each group's duration, which names its windows,
46
+ * and the kind of the group's unscoped account meter, which keeps the plain label.
46
47
  */
47
- const LIMIT_GROUPS: ReadonlyMap<string, { durationSeconds: number; period: string; accountKind: string }> = new Map([
48
- ['session', { durationSeconds: 18000, period: 'five-hour', accountKind: 'session' }],
49
- ['weekly', { durationSeconds: 604800, period: 'weekly', accountKind: 'weekly_all' }],
48
+ const LIMIT_GROUPS: ReadonlyMap<string, { durationSeconds: number; accountKind: string }> = new Map([
49
+ ['session', { durationSeconds: 18000, accountKind: 'session' }],
50
+ ['weekly', { durationSeconds: 604800, accountKind: 'weekly_all' }],
50
51
  ]);
51
52
 
52
53
  const isUsageSeverity = (value: unknown): value is TUsageSeverity => value === 'normal' || value === 'warning' || value === 'critical';
@@ -82,8 +83,9 @@ const limitWindows = (rows: readonly unknown[]): IClaudeUsageWindows => {
82
83
  continue;
83
84
  }
84
85
  const severity = row.severity;
86
+ const period = usageWindowPeriod(meter.durationSeconds);
85
87
  windows.push({
86
- label: subject === undefined ? `Claude ${meter.period}${kind === meter.accountKind ? '' : ` (${kindName})`}` : `${subject} ${meter.period}`,
88
+ label: subject === undefined ? `Claude ${period}${kind === meter.accountKind ? '' : ` (${kindName})`}` : `${subject} ${period}`,
87
89
  durationSeconds: meter.durationSeconds, scope: subject === undefined ? 'account' : 'feature', usedPercent, resetAt,
88
90
  ...(isUsageSeverity(severity) ? { severity } : {}),
89
91
  ...(row.is_active === true ? { headline: true as const } : {}),
package/ts/classes.cli.ts CHANGED
@@ -9,7 +9,7 @@ import { readAccountList } from './classes.accountlist.js';
9
9
  import { AccountListRenderer } from './classes.listrenderer.js';
10
10
  import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
11
11
  import { consoleTable } from './consoletable.js';
12
- import { accountName, credentialDriftNote, duration, orderedUsageWindows, readAccountBadges, until, usagePercentText } from './accounts.js';
12
+ import { accountName, credentialDriftNote, orderedUsageWindows, readAccountBadges, until, usagePercentText } from './accounts.js';
13
13
  import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
14
14
  import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
15
15
  import { parseCommandArgs, parseDurationOption, parseIntegerOption, UsageError } from './cliargs.js';
@@ -383,7 +383,8 @@ ${bold('Environment')}
383
383
  const now = Date.now();
384
384
  process.stdout.write('Reset schedule reported after the prompt:\n');
385
385
  await consoleTable(this.out, windows, [
386
- { key: 'window', title: 'Window', value: row => `${plainText(row.label)} (${duration(row.durationSeconds)})` },
386
+ // The label already names the window by its length, so the column never repeats it.
387
+ { key: 'window', title: 'Window', value: row => plainText(row.label) },
387
388
  { key: 'usage', title: 'Used', value: row => usagePercentText(row.usedPercent) },
388
389
  { key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
389
390
  ]);
@@ -1,6 +1,7 @@
1
1
  import type { ICodexIdentity } from './interfaces.js';
2
2
  import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessStatusSummary } from './interfaces.harness.js';
3
3
  import { commitinfo } from './00_commitinfo_data.js';
4
+ import { usageWindowPeriod } from './accounts.js';
4
5
  import { latestRetryAt, retryAtFrom } from './ratelimit.js';
5
6
 
6
7
  // Verified Chromium-compatible billing request profile, independent of any locally installed browser.
@@ -38,9 +39,13 @@ class StatusRateLimitError extends StatusRequestError {
38
39
  }
39
40
 
40
41
  /**
41
- * Read-only ChatGPT status contract verified against Codex 0.154.0.
42
+ * Read-only ChatGPT status contract verified against Codex 0.155.0.
42
43
  * These source-defined backend routes are isolated here. No token refresh,
43
44
  * credential writes, app-server lifecycle changes or reset consumption occur.
45
+ *
46
+ * The service decides how many usage windows an account has: it sends the ones it keeps for that
47
+ * account, leaving the other slot `null`, so a login whose general quota is a single weekly window is
48
+ * reported with that one window and no five-hour limit is invented to fill the gap.
44
49
  */
45
50
  export class CodexAccountStatus {
46
51
  constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
@@ -184,16 +189,23 @@ export class CodexAccountStatus {
184
189
  // The service's own reading of the same windows, so a view that shows the windows need not repeat it.
185
190
  facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} usage allowed`, value: boolean(limit.allowed) ? 'yes' : 'no' });
186
191
  facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} limit reached`, value: boolean(limit.limit_reached) ? 'yes' : 'no' });
187
- for (const [key, name] of [['primary_window', 'primary'], ['secondary_window', 'secondary']]) {
188
- if (limit[key] == null) continue;
189
- const window = record(limit[key]);
190
- const used = numeric(window.used_percent);
191
- const durationSeconds = numeric(window.limit_window_seconds);
192
- const minutes = durationSeconds / 60;
193
- const duration = minutes % 1440 === 0 ? `${minutes / 1440}d` : minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m`;
194
- const resetAt = timestamp(window.reset_at);
195
- usageWindows.push({ label: `${nameArg} ${name}`, scope: scopeArg, durationSeconds, usedPercent: used, resetAt });
196
- facts.push({ section: 'Usage', summaryKey: 'usageWindows', label: `${nameArg} ${name} (${duration})`, value: `${used}% used, ${Math.max(0, 100 - used)}% remaining; resets ${resetAt}` });
192
+ // Both slots are read before either is named: only a second window of the same length makes the
193
+ // vendor's slot worth showing, and that is decided from the pair.
194
+ const windows = (['primary_window', 'secondary_window'] as const).flatMap((slotArg) => {
195
+ if (limit[slotArg] == null) return [];
196
+ const window = record(limit[slotArg]);
197
+ return [{
198
+ slot: slotArg === 'primary_window' ? 'primary' : 'secondary',
199
+ usedPercent: numeric(window.used_percent), durationSeconds: numeric(window.limit_window_seconds), resetAt: timestamp(window.reset_at),
200
+ }];
201
+ });
202
+ for (const window of windows) {
203
+ // Named by its length, as Codex names its own windows: a slot carries no length, and which
204
+ // window the service puts in it varies by plan and by which limit is currently binding.
205
+ const shared = windows.some(other => other !== window && other.durationSeconds === window.durationSeconds);
206
+ const label = `${nameArg} ${usageWindowPeriod(window.durationSeconds)}${shared ? ` (${window.slot})` : ''}`;
207
+ usageWindows.push({ label, scope: scopeArg, durationSeconds: window.durationSeconds, usedPercent: window.usedPercent, resetAt: window.resetAt });
208
+ facts.push({ section: 'Usage', summaryKey: 'usageWindows', label, value: `${window.usedPercent}% used, ${Math.max(0, 100 - window.usedPercent)}% remaining; resets ${window.resetAt}` });
197
209
  }
198
210
  };
199
211
  appendLimit('Codex', bodyArg.rate_limit, 'account');
@@ -62,6 +62,14 @@ export interface IStoredCredential {
62
62
  savedAt: string;
63
63
  /** Harness-owned payload. This object must never cross the public account interface. */
64
64
  credential: Record<string, unknown>;
65
+ /**
66
+ * A refresh token of this credential that the provider refused, as a hash -- never the token.
67
+ *
68
+ * A grant the provider rejected stays rejected, so replaying it only adds traffic to an auth endpoint, once per
69
+ * status read for as long as the record holds that token. Remembering it in the record instead of in memory is
70
+ * what makes that true for the next process too. Every write that replaces the credential drops it.
71
+ */
72
+ rejectedGrant?: string;
65
73
  }
66
74
 
67
75
  /** The root of authswitch's own state: `AUTHSWITCH_HOME`, or `~/.authswitch`. */
@@ -89,8 +97,10 @@ export class CredentialStore {
89
97
  this.lockTimeoutMs = optionsArg.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
90
98
  }
91
99
  public id(slot: string, identity: string): string { return credentialHash(JSON.stringify([slot, identity])); }
100
+ /** Whether a value names a credential hash: a rejected grant, or an account id. */
101
+ private isHash(value: unknown): boolean { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); }
92
102
  private file(id: string): string {
93
- if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid saved account ID.');
103
+ if (!this.isHash(id)) throw new Error('Invalid saved account ID.');
94
104
  return plugins.path.join(this.dir, `${id}.json`);
95
105
  }
96
106
  public read(id: string): IStoredCredential {
@@ -99,6 +109,9 @@ export class CredentialStore {
99
109
  || !credentialText(value.label) || typeof value.savedAt !== 'string' || !Number.isFinite(Date.parse(value.savedAt))
100
110
  || this.id(value.slotId as string, value.identity as string) !== id) throw new Error('Saved account metadata is invalid; restore it from a verified backup.');
101
111
  credentialRecord(value.credential);
112
+ // Advisory state: a rejected grant that is not a hash says nothing about the login and is dropped, never made
113
+ // the record's problem -- the grant is sent once more instead, which is what a record without one means anyway.
114
+ if (!this.isHash(value.rejectedGrant)) delete value.rejectedGrant;
102
115
  return value as unknown as IStoredCredential;
103
116
  }
104
117
  public list(): IStoredCredential[] {
@@ -115,7 +128,18 @@ export class CredentialStore {
115
128
  * it was saved stay as they are. The caller holds the lock and has checked the record it replaces.
116
129
  */
117
130
  public replaceCredential(id: string, credential: Record<string, unknown>): IStoredCredential {
118
- return this.write({ ...this.read(id), credential }, 'The refreshed login could not be verified after saving it.');
131
+ const record = this.read(id);
132
+ // A remembered rejection belongs to the credential it was taken from; the new one has not been refused.
133
+ delete record.rejectedGrant;
134
+ return this.write({ ...record, credential }, 'The refreshed login could not be verified after saving it.');
135
+ }
136
+ /**
137
+ * Remembers that the provider refused the refresh token this record holds, as `grantHashArg`. The caller holds the
138
+ * lock and has checked that the record still holds the token it sent.
139
+ */
140
+ public rememberRejectedGrant(id: string, grantHashArg: string): IStoredCredential {
141
+ if (!this.isHash(grantHashArg)) throw new Error('A rejected grant is remembered as a credential hash.');
142
+ return this.write({ ...this.read(id), rejectedGrant: grantHashArg }, 'The refused sign-in could not be recorded.');
119
143
  }
120
144
  private write(record: IStoredCredential, failureArg: string): IStoredCredential {
121
145
  plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
@@ -1,4 +1,4 @@
1
- import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
1
+ import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions, THarnessResult } from './interfaces.harness.js';
2
2
  import { credentialHash, CredentialStore, type IStoredCredential, type ISwitchRecord } from './classes.credentialstore.js';
3
3
  import { pauseSynchronously } from './helpers.js';
4
4
  import { plainText } from './formatting.js';
@@ -52,7 +52,7 @@ export abstract class FileHarness implements IAuthHarness {
52
52
  * credential out of the stash. Both the retry count and the pause between attempts are bounded; an
53
53
  * unstable file is reported, never guessed at.
54
54
  */
55
- private stableSnapshot(): IFileHarnessSnapshot {
55
+ protected stableSnapshot(): IFileHarnessSnapshot {
56
56
  let failure: unknown;
57
57
  for (let attempt = 0; attempt < 5; attempt++) {
58
58
  if (attempt) pauseSynchronously(20);
@@ -72,7 +72,11 @@ export abstract class FileHarness implements IAuthHarness {
72
72
  if (account.identity !== entry.identity) throw new Error('Saved credential does not match its account identity.');
73
73
  return account;
74
74
  }
75
- public readState(): IHarnessState {
75
+ /**
76
+ * The harness's accounts. A subclass may have to bring its own state up to date first -- the Claude Code adapter
77
+ * mirrors the active login into its saved copy -- which is why this is the interface's `THarnessResult`.
78
+ */
79
+ public readState(): THarnessResult<IHarnessState> {
76
80
  const snapshot = this.snapshot();
77
81
  const saved = this.store.list();
78
82
  const accounts: IHarnessState['accounts'] = saved.map(entry => {
@@ -243,9 +247,9 @@ export abstract class FileHarness implements IAuthHarness {
243
247
  public removeAccount(id: string): Promise<IHarnessOutcome> {
244
248
  return this.outcome(() => { const account = this.store.read(id); this.store.remove(id); return `Removed saved login for ${plainText(account.label)}; the active login is preserved.`; }, false);
245
249
  }
246
- public diagnose(): IHarnessOutcome {
250
+ public async diagnose(): Promise<IHarnessOutcome> {
247
251
  try {
248
- const state = this.readState();
252
+ const state = await this.readState();
249
253
  return { lines: [`${this.label}: ${state.accounts.filter(account => account.isActive).length} active login(s), ${state.accounts.filter(account => account.isStashed).length} saved account(s).`, this.loginHint], problems: state.saveUnavailableReason ? [state.saveUnavailableReason] : [] };
250
254
  } catch { return { lines: [], problems: ['Credential storage could not be verified. Check file permissions and saved credential records.'] }; }
251
255
  }
@@ -53,7 +53,7 @@ export class OpenCodeHarness extends FileHarness {
53
53
  public beginLogin(options: IHarnessLoginOptions = { providerId: 'openai', flow: 'device' }) {
54
54
  return beginSavedOpenAiLogin(async result => {
55
55
  const saved = await this.importCredential('openai', openCodeCredentialFromLogin(result.credential));
56
- const account = this.readState().accounts.find(account => account.id === saved.accountId)!;
56
+ const account = (await this.readState()).accounts.find(account => account.id === saved.accountId)!;
57
57
  return { outcome: { lines: [`Saved OpenAI login for ${plainText(account.label)}. Select it to switch OpenCode.`], problems: [] }, account: { id: account.id, label: account.label, slotId: account.slotId } };
58
58
  }, options, this.fetcher);
59
59
  }