@modelprofile.com/authswitch 4.0.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/accounts.d.ts +10 -0
- package/dist_ts/accounts.js +13 -1
- package/dist_ts/classes.claudecodeharness.d.ts +31 -10
- package/dist_ts/classes.claudecodeharness.js +92 -14
- package/dist_ts/classes.claudecodelocks.d.ts +5 -2
- package/dist_ts/classes.claudecodelocks.js +5 -4
- package/dist_ts/classes.claudestatus.js +13 -11
- package/dist_ts/classes.cli.js +6 -6
- package/dist_ts/classes.codexstatus.d.ts +5 -1
- package/dist_ts/classes.codexstatus.js +25 -13
- package/dist_ts/classes.credentialstore.d.ts +15 -0
- package/dist_ts/classes.credentialstore.js +21 -3
- package/dist_ts/classes.fileharness.d.ts +8 -4
- package/dist_ts/classes.fileharness.js +7 -3
- package/dist_ts/classes.opencodeharness.js +2 -2
- package/dist_ts/classes.operations.d.ts +2 -0
- package/dist_ts/classes.operations.js +3 -1
- package/dist_ts/classes.tui.js +6 -5
- package/package.json +1 -1
- package/readme.md +60 -25
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/accounts.ts +12 -0
- package/ts/classes.claudecodeharness.ts +87 -14
- package/ts/classes.claudecodelocks.ts +4 -3
- package/ts/classes.claudestatus.ts +13 -11
- package/ts/classes.cli.ts +5 -5
- package/ts/classes.codexstatus.ts +23 -11
- package/ts/classes.credentialstore.ts +26 -2
- package/ts/classes.fileharness.ts +9 -5
- package/ts/classes.opencodeharness.ts +1 -1
- package/ts/classes.operations.ts +4 -0
- package/ts/classes.tui.ts +7 -6
package/readme.md
CHANGED
|
@@ -187,6 +187,12 @@ authswitch uses `agl authswitch --request <json>` without an AGL package depende
|
|
|
187
187
|
A failed or ambiguous transport never falls through to a second local mutation.
|
|
188
188
|
AGL and the CLI must use matching credential locations and compatible versions.
|
|
189
189
|
|
|
190
|
+
`authSwitchMutationReplacesLogin(mutation)` says whether a mutation replaces the
|
|
191
|
+
harness's active login -- `switch`, or `save` without `keepActive` -- and therefore
|
|
192
|
+
concerns the instances running on it. The command line, the dashboard and a host
|
|
193
|
+
such as AGL all read this one rule; `remove` and a save that keeps the login
|
|
194
|
+
active leave the loaded login in place.
|
|
195
|
+
|
|
190
196
|
OpenCode credentials live in `${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json`.
|
|
191
197
|
Several providers can be active at once. Saving or switching `openai` preserves
|
|
192
198
|
every other provider, and `stash openai` without `--keep` clears only OpenAI.
|
|
@@ -241,11 +247,31 @@ keeps every other field of the login, and writes the result only while the saved
|
|
|
241
247
|
still holds the refresh token it sent. The refresh holds the account store's lock, so a
|
|
242
248
|
switch started at the same time waits for it and then activates the refreshed login. The
|
|
243
249
|
**active** login is never refreshed -- Claude Code owns it, and two parties refreshing one
|
|
244
|
-
login can invalidate the grant. A refusal is reported, never worked around
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
250
|
+
login can invalidate the grant. A refusal is reported, never worked around -- a rejected
|
|
251
|
+
grant says so and says what fixes it (below), and a service failure, an unreachable service
|
|
252
|
+
or an unsupported response leave the saved login untouched. Requests carry `User-Agent:
|
|
253
|
+
authswitch/<version>`, and HTTP 429 is reported as rate limiting with the service's retry
|
|
254
|
+
time -- never as an exhausted or expired account.
|
|
255
|
+
|
|
256
|
+
Claude Code also rotates the refresh token of the login it **is** running on, so reading the
|
|
257
|
+
accounts (`list`, `limits`, `active`, `current`, `watch`, the dashboard) mirrors that login
|
|
258
|
+
into its own saved record whenever the two differ. Without it a login replaced outside
|
|
259
|
+
authswitch -- a `/login` in Claude Code, a new device login -- left the stash holding the
|
|
260
|
+
token from before the rotation, which the sign-in service can refuse: it did for two saved
|
|
261
|
+
logins on 2026-09-18. The record keeps its account, its identity and its save time, Claude
|
|
262
|
+
Code's own files are only ever read, and a login with no saved record of its own is never
|
|
263
|
+
saved implicitly; `stash` is what saves a login. An installation whose native login
|
|
264
|
+
authswitch cannot read mirrors nothing, and a read waits at most a second for a lock a
|
|
265
|
+
switch or Claude Code holds before leaving the saved copy to the next read.
|
|
266
|
+
|
|
267
|
+
A grant the sign-in service refused is remembered in the saved record itself, as a hash of
|
|
268
|
+
the refresh token -- never the token -- so a new authswitch run, a restarted watch or a
|
|
269
|
+
second dashboard does not replay a dead login. Saving that login again clears it, as does
|
|
270
|
+
any refresh that replaces the saved credential. Such a login reports `Login expired or was
|
|
271
|
+
rejected. Log in again with Claude Code and save it. Claude Code rotates a login's refresh
|
|
272
|
+
token as it uses it, and the service can refuse the older one a saved copy still holds once
|
|
273
|
+
that login has been replaced outside authswitch; authswitch claude stash --keep saves the
|
|
274
|
+
new one.`
|
|
249
275
|
|
|
250
276
|
OpenCode and Claude saved records are separate owner-only files under
|
|
251
277
|
`~/.authswitch/opencode` and `~/.authswitch/claude`. Emails are accepted as account
|
|
@@ -308,7 +334,7 @@ authswitch watch --json # one JSON event per line
|
|
|
308
334
|
14:02:07 Watching Claude Code, Codex every 2m; switching at 95%.
|
|
309
335
|
14:02:07 Running Codex sessions keep the login they loaded; new sessions use the switched one.
|
|
310
336
|
14:02:09 Claude Code alice@example.com (type:Max) (5h:17%) (w:4%) ok
|
|
311
|
-
14:02:10 Codex switched bob@example.com → carol@example.com (type:Pro) (5h:3%) (w:12%) — Codex
|
|
337
|
+
14:02:10 Codex switched bob@example.com → carol@example.com (type:Pro) (5h:3%) (w:12%) — Codex weekly 96% ≥ 95%
|
|
312
338
|
14:04:09 Claude Code alice@example.com (type:Max) (5h:21%) (w:4%) ok
|
|
313
339
|
14:04:10 Codex carol@example.com (type:Pro) (5h:4%) (w:12%) ok
|
|
314
340
|
```
|
|
@@ -481,13 +507,13 @@ Codex
|
|
|
481
507
|
│ Plus, unverified │ 2026-09-14 11:32 │ │ │ │
|
|
482
508
|
└───────────────────┴──────────────────┴────────────────────┴─────────────┴───────────────┘
|
|
483
509
|
|
|
484
|
-
|
|
485
|
-
│ Account │ Limit type
|
|
486
|
-
|
|
487
|
-
│ alice@example.com │ Codex
|
|
488
|
-
│ Pro │ Codex
|
|
489
|
-
│ │ Code review
|
|
490
|
-
|
|
510
|
+
┌───────────────────┬────────────────────┬────────┬───────────┐
|
|
511
|
+
│ Account │ Limit type │ Used % │ Resets in │
|
|
512
|
+
├───────────────────┼────────────────────┼────────┼───────────┤
|
|
513
|
+
│ alice@example.com │ Codex weekly │ 12% │ 3d 4h │
|
|
514
|
+
│ Pro │ Codex five-hour │ 37% │ 2h 13m │
|
|
515
|
+
│ │ Code review weekly │ 100% │ 3h 59m │
|
|
516
|
+
└───────────────────┴────────────────────┴────────┴───────────┘
|
|
491
517
|
|
|
492
518
|
┌────────────────┬───────────────────┐
|
|
493
519
|
│ Metric │ alice@example.com │
|
|
@@ -549,10 +575,18 @@ has the same period; a feature limit, or a general window sharing its period, is
|
|
|
549
575
|
named by its label (`Fable weekly 100% · 2d`). Feature limits such as Spark and code
|
|
550
576
|
review appear in the Limits table and lead the usage cell only when the provider
|
|
551
577
|
picks one as its headline. The TUI uses the same selection and names. Window
|
|
552
|
-
durations come from the provider, not from the plan name or
|
|
553
|
-
|
|
578
|
+
durations come from the provider, not from the plan name or the slot a window
|
|
579
|
+
arrives in; a Pro account with only a general weekly window gets no invented 5-hour
|
|
554
580
|
limit.
|
|
555
581
|
|
|
582
|
+
Every window label names the window by how long it is -- `Codex weekly`, `Codex
|
|
583
|
+
five-hour`, `Claude weekly`, `Code review weekly` -- so the same length reads the
|
|
584
|
+
same in every harness. The named lengths are `five-hour`, `daily`, `weekly`,
|
|
585
|
+
`monthly` and `annual`, the same set Codex names its own windows with, and its
|
|
586
|
+
primary/secondary slot is shown (`Codex weekly (secondary)`) only where two windows
|
|
587
|
+
of one length would otherwise share a label. A length with no name of its own keeps
|
|
588
|
+
its compact duration (`Codex 90m`) instead of reading as a period it is not.
|
|
589
|
+
|
|
556
590
|
When accounts of one harness share a label, every table numbers the later ones
|
|
557
591
|
(`alice@example.com (2)`), so each account keeps its own column in the Details
|
|
558
592
|
comparison and reads the same everywhere. Missing data stays `n/a`; known zeroes
|
|
@@ -585,10 +619,10 @@ Account limits
|
|
|
585
619
|
┃ │ Max │ Claude five-hour │ 12% │ 1h 2m │ │
|
|
586
620
|
┃ │ │ Fable weekly │ 88% │ 2d 5h │ │
|
|
587
621
|
├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
|
|
588
|
-
┃ Codex │ alice@example.com │ Codex
|
|
589
|
-
┃ │ Pro │ Codex
|
|
590
|
-
┃ │ │ Code review
|
|
591
|
-
┃ │ │
|
|
622
|
+
┃ Codex │ alice@example.com │ Codex weekly │ 12% │ 3d 4h │ 2026-10-08 │
|
|
623
|
+
┃ │ Pro │ Codex five-hour │ 37% │ 2h 13m │ auto-renews │
|
|
624
|
+
┃ │ │ Code review weekl │ 100% │ 3h 59m │ │
|
|
625
|
+
┃ │ │ y │ │ │ │
|
|
592
626
|
┃ ├───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
|
|
593
627
|
┃ │ bob@example.com │ n/a │ n/a │ n/a │ n/a │
|
|
594
628
|
┃ │ Plus, unverified │ │ │ │ │
|
|
@@ -597,7 +631,7 @@ Account limits
|
|
|
597
631
|
┃ opic │ 20e751c3b707 │ │ │ │ │
|
|
598
632
|
┃ │ n/a │ │ │ │ │
|
|
599
633
|
├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
|
|
600
|
-
┃ OpenCode / opena │ alice@example.com │ Codex
|
|
634
|
+
┃ OpenCode / opena │ alice@example.com │ Codex five-hour │ 0% │ not scheduled │ 2026-10-02 │
|
|
601
635
|
┃ i │ Pro │ │ │ │ cancels │
|
|
602
636
|
└──────────────────┴───────────────────┴───────────────────┴────────┴───────────────┴─────────────┘
|
|
603
637
|
|
|
@@ -705,8 +739,9 @@ unsaved current login adds how to keep it (`authswitch <harness> stash --keep sa
|
|
|
705
739
|
it`).
|
|
706
740
|
|
|
707
741
|
`authswitch <harness> current` prints the same sentence. The same account with a
|
|
708
|
-
rotated token is that account refreshing itself and is not reported;
|
|
709
|
-
|
|
742
|
+
rotated token is that account refreshing itself and is not reported; for Claude Code the
|
|
743
|
+
rotation is copied into that account's saved record, and re-running the switch re-arms the
|
|
744
|
+
check against the file's current contents.
|
|
710
745
|
|
|
711
746
|
Both commands accept a harness qualifier (`authswitch claude limits`) and `--json`.
|
|
712
747
|
Neither changes which account is in use: they never activate, switch or clear a login and
|
|
@@ -765,13 +800,13 @@ accept schema version 2 and handle null reset timestamps. Scripts should qualify
|
|
|
765
800
|
mutation commands with `codex`, `opencode` or `claude`, since all three now register
|
|
766
801
|
by default. Saved Codex credentials retain their existing format.
|
|
767
802
|
|
|
768
|
-
For each ChatGPT login, Codex status includes the live plan,
|
|
769
|
-
|
|
803
|
+
For each ChatGPT login, Codex status includes the live plan, every usage window the
|
|
804
|
+
service reports with remaining percentages and reset times, additional limits
|
|
770
805
|
such as code review, credit availability and balance, spend limits when returned,
|
|
771
806
|
earned reset availability and expiry details, lifetime and peak daily tokens,
|
|
772
807
|
activity streaks, and the latest seven reported daily token totals.
|
|
773
808
|
|
|
774
|
-
The Codex adapter implements the read-only GET requests used by Codex 0.
|
|
809
|
+
The Codex adapter implements the read-only GET requests used by Codex 0.155.0 at
|
|
775
810
|
`chatgpt.com/backend-api/wham`: `usage`, `rate-limit-reset-credits`, and `profiles/me`.
|
|
776
811
|
Billing uses `chatgpt.com/backend-api/accounts/check/v4-2023-04-27`, verified against
|
|
777
812
|
the official desktop bundle 26.908.40834. It selects the exact credential account;
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/accounts.ts
CHANGED
|
@@ -11,6 +11,18 @@ export const duration = (secondsArg: number): string => secondsArg % 86400 === 0
|
|
|
11
11
|
/** The same for a chosen duration that need not be whole minutes: `90s` reads `1m 30s`, never `1.5m`. */
|
|
12
12
|
export const durationText = (secondsArg: number): string => secondsArg % 60 === 0 ? duration(secondsArg)
|
|
13
13
|
: secondsArg < 60 ? `${secondsArg}s` : `${Math.floor(secondsArg / 60)}m ${secondsArg % 60}s`;
|
|
14
|
+
/** The window lengths that have a name of their own; every other length is named by its compact duration. */
|
|
15
|
+
const WINDOW_PERIODS: ReadonlyMap<number, string> = new Map([[18000, 'five-hour'], [86400, 'daily'], [604800, 'weekly'], [2592000, 'monthly'], [31536000, 'annual']]);
|
|
16
|
+
/**
|
|
17
|
+
* How every adapter names a usage window in a label: by the window's length alone, so `Codex weekly` and
|
|
18
|
+
* `Claude weekly` mean the same thing and no label repeats a provider's internal slot name. These are the
|
|
19
|
+
* lengths Codex names in 0.155.0 too (`five-hour`, `daily`, `weekly`, `monthly`, `annual`), and a slot a
|
|
20
|
+
* window arrives in says nothing about how long it is. A length with no name of its own keeps its compact
|
|
21
|
+
* form (`90m`), which is honest about a window this vocabulary does not know rather than rounding it.
|
|
22
|
+
*
|
|
23
|
+
* The narrow table cells stay on the compact form throughout (`5h 37% · 2h 13m`); a column is not a label.
|
|
24
|
+
*/
|
|
25
|
+
export const usageWindowPeriod = (secondsArg: number): string => WINDOW_PERIODS.get(secondsArg) ?? duration(secondsArg);
|
|
14
26
|
/** A countdown is a snapshot of the provider's deadline, not proof a quota has reset. */
|
|
15
27
|
export const until = (timestampArg: string | null, nowArg: number): string => {
|
|
16
28
|
if (timestampArg === null) return 'Not scheduled';
|
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
28
|
-
['seven_day_oauth_apps', 'OAuth apps
|
|
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,
|
|
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
|
|
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,
|
|
45
|
-
*
|
|
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;
|
|
48
|
-
['session', { durationSeconds: 18000,
|
|
49
|
-
['weekly', { durationSeconds: 604800,
|
|
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 ${
|
|
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
|
@@ -4,12 +4,12 @@ import { CodexHarness } from './classes.codexharness.js';
|
|
|
4
4
|
import { OpenCodeHarness } from './classes.opencodeharness.js';
|
|
5
5
|
import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
|
|
6
6
|
import { AuthSwitchTui } from './classes.tui.js';
|
|
7
|
-
import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
|
|
7
|
+
import { AglAuthSwitchCoordinator, AuthSwitchOperations, authSwitchMutationReplacesLogin, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
|
|
8
8
|
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,
|
|
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';
|
|
@@ -247,10 +247,9 @@ ${bold('Environment')}
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
private mutate(harness: IAuthHarness, mutation: TAuthSwitchMutation, stopModeArg?: TStopMode): Promise<IHarnessOutcome> {
|
|
250
|
-
const replacesNativeLogin = mutation.action === 'switch' || (mutation.action === 'save' && !mutation.keepActive);
|
|
251
250
|
return this.operations.run(harness, mutation, canPrompt() ? async message => await this.out.prompts.ask({
|
|
252
251
|
name: 'waitForIdle', type: 'confirm', message, default: false,
|
|
253
|
-
}) === true : undefined,
|
|
252
|
+
}) === true : undefined, authSwitchMutationReplacesLogin(mutation) ? () => this.settleRunningInstances(harness, stopModeArg) : undefined);
|
|
254
253
|
}
|
|
255
254
|
|
|
256
255
|
/**
|
|
@@ -383,7 +382,8 @@ ${bold('Environment')}
|
|
|
383
382
|
const now = Date.now();
|
|
384
383
|
process.stdout.write('Reset schedule reported after the prompt:\n');
|
|
385
384
|
await consoleTable(this.out, windows, [
|
|
386
|
-
|
|
385
|
+
// The label already names the window by its length, so the column never repeats it.
|
|
386
|
+
{ key: 'window', title: 'Window', value: row => plainText(row.label) },
|
|
387
387
|
{ key: 'usage', title: 'Used', value: row => usagePercentText(row.usedPercent) },
|
|
388
388
|
{ key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
|
|
389
389
|
]);
|
|
@@ -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.
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|