@modelprofile.com/authswitch 5.1.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -193,14 +193,36 @@ concerns the instances running on it. The command line, the dashboard and a host
193
193
  such as AGL all read this one rule; `remove` and a save that keeps the login
194
194
  active leave the loaded login in place.
195
195
 
196
+ That rule, `assertAuthSwitchMutation` and the `TAuthSwitchMutation` command are also
197
+ published on their own at `@modelprofile.com/authswitch/mutation`, which is the one
198
+ import of this package a browser can bundle:
199
+
200
+ ```ts
201
+ import { authSwitchMutationReplacesLogin, type TAuthSwitchMutation } from '@modelprofile.com/authswitch/mutation';
202
+ ```
203
+
204
+ That module imports nothing at all -- no Node built-in, no package, no other module of
205
+ this one -- so a web client decides what a mutation means to the instances it shows
206
+ without pulling a credential store into the bundle. The package root stays Node-only:
207
+ it reaches the stash, and with it `node:fs`, `node:child_process` and `node:crypto`.
208
+ Both entry points export the same three names from the same source, so a Node caller
209
+ can keep importing them from the package root.
210
+
196
211
  OpenCode credentials live in `${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json`.
197
212
  Several providers can be active at once. Saving or switching `openai` preserves
198
213
  every other provider, and `stash openai` without `--keep` clears only OpenAI.
199
214
  When several logins are active, the guide asks which to save; scripts must name
200
215
  the provider or account. In the TUI, select an active row before pressing `a` or `c`.
201
216
  ChatGPT OAuth accounts display their email and use the same direct status APIs as
202
- Codex. API keys and opaque provider tokens have no reliable email, so their labels
203
- contain the provider and a SHA-256 fingerprint. Their quota/billing information is
217
+ Codex. An `anthropic` OAuth login holds the same Claude subscriber grant Claude Code
218
+ stores, so it reports Claude's own plan, organization and usage windows through the
219
+ account endpoints described under **Claude Code**; OpenCode keeps no scopes and no
220
+ account identity with that entry, so both are left to the service. Renewing that token
221
+ stays OpenCode's -- the renewal rotates the refresh token a running OpenCode holds in
222
+ memory, which authswitch cannot write into an instance it does not manage -- so an access
223
+ token inside Claude Code's five-minute margin is reported as expired instead, with the
224
+ two ways to renew it. API keys and opaque provider tokens have no reliable email, so their
225
+ labels contain the provider and a SHA-256 fingerprint. Their quota/billing information is
204
226
  explicitly unavailable. `OPENCODE_AUTH_CONTENT` overrides the file and blocks file
205
227
  management. Provider configuration and environment credentials remain managed by OpenCode.
206
228
 
@@ -239,19 +261,27 @@ authswitch does not infer them from account creation or token expiry dates. The
239
261
  Claude Code adapter declares that, so `limits` explains it once for the provider
240
262
  instead of per account.
241
263
 
242
- Nothing but Claude Code refreshes a Claude login, so a saved one stops being readable
243
- a few hours after it was saved. Reading a **saved, inactive** account's status therefore
264
+ Nothing but Claude Code refreshes a Claude login, so a saved one stops being readable a
265
+ few hours after it was saved. Reading a **saved, inactive** account's status therefore
244
266
  refreshes it first, exactly as Claude Code would: when its access token expires within
245
267
  five minutes, authswitch exchanges the stored refresh token at Claude's token endpoint,
246
268
  keeps every other field of the login, and writes the result only while the saved record
247
- still holds the refresh token it sent. The refresh holds the account store's lock, so a
248
- switch started at the same time waits for it and then activates the refreshed login. The
249
- **active** login is never refreshed -- Claude Code owns it, and two parties refreshing one
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.
269
+ still holds the refresh token it sent. The request is the one Claude Code sends, down to
270
+ the scopes: a login of Claude Code's own OAuth client is renewed with Claude Code's
271
+ current scope set plus the project scopes the login carries -- not with the set the
272
+ login happens to be stored under, which can be an older or wider one the service no
273
+ longer accepts -- and a set the service refuses as `invalid_scope` is sent once more
274
+ with the stored scopes. A login that names its own OAuth client keeps its stored scopes,
275
+ because only that client's grant knows them. A login that is neither one of Claude
276
+ Code's subscriber logins nor stored with a plan is not sent at all. The refresh holds
277
+ the account store's lock, so a switch started at the same time waits for it and then
278
+ activates the refreshed login. The **active** login is never refreshed -- Claude Code
279
+ owns it, and two parties refreshing one login can invalidate the grant. A refusal is
280
+ reported, never worked around -- a rejected grant says so and says what fixes it
281
+ (below), and a service failure, an unreachable service or an unsupported response leave
282
+ the saved login untouched. Requests carry `User-Agent: authswitch/<version>`, and HTTP
283
+ 429 is reported as rate limiting with the service's retry time -- never as an exhausted
284
+ or expired account.
255
285
 
256
286
  Claude Code also rotates the refresh token of the login it **is** running on, so reading the
257
287
  accounts (`list`, `limits`, `active`, `current`, `watch`, the dashboard) mirrors that login
@@ -264,14 +294,55 @@ saved implicitly; `stash` is what saves a login. An installation whose native lo
264
294
  authswitch cannot read mirrors nothing, and a read waits at most a second for a lock a
265
295
  switch or Claude Code holds before leaving the saved copy to the next read.
266
296
 
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.`
297
+ An account the service has put **on hold** is not a dead login: the hold refuses the grant
298
+ while it lasts and the same login works again once it is lifted, so such an account keeps
299
+ its stored plan, says that it is on hold and where to appeal, and is asked again on the
300
+ next read.
301
+
302
+ A grant the sign-in service refused is remembered in the saved record itself, as a hash
303
+ of the request that was refused -- the refresh token, the client and the scopes, never
304
+ the token itself -- so a new authswitch run, a restarted watch or a second dashboard
305
+ does not replay a dead login. Because the hash covers the request and not only the
306
+ token, a release that changes how authswitch asks gives every such login one honest
307
+ attempt under the new derivation, while a refusal of the request authswitch still sends
308
+ stays remembered. Saving that login again clears it, as does any refresh that replaces
309
+ the saved credential. Such a login reports `Login expired or was rejected. Log in again
310
+ with Claude Code and save it. Claude Code rotates a login's refresh token as it uses it,
311
+ and the service can refuse the older one a saved copy still holds once that login has
312
+ been replaced outside authswitch; authswitch claude stash --keep saves the new one.`
313
+
314
+ Each `n/a` in a Claude row has a reason in the footnote under the table, and each
315
+ reason names what to do:
316
+
317
+ - `Login refresh: Login expired or was rejected.` -- the sign-in service refused the
318
+ saved refresh token itself. Log in again in Claude Code for that account and run
319
+ `authswitch claude stash --keep`; nothing else fixes it.
320
+ - `Login refresh: This Claude account is on hold and cannot sign in.` -- the account,
321
+ not the login. Appeal at the address in the message; the saved login is kept and is
322
+ asked again on the next read.
323
+ - `Login refresh: This saved login has no refresh token, and no subscriber scope or
324
+ plan to refresh with.` -- the saved record is missing what a refresh is made of.
325
+ Save the login again.
326
+ - `Login refresh: this saved login's access token has expired and cannot be renewed
327
+ for this installation.` -- authswitch cannot read Claude Code's own files here (the
328
+ macOS Keychain, or an incomplete native login), so it cannot prove this login is not
329
+ the active one, and never refreshes a login that might be.
330
+ - `Login refresh: The Claude sign-in service returned HTTP ...`, `... could not be
331
+ reached.`, `... timed out.`, `... is rate limiting requests (HTTP 429)` -- the
332
+ service, not the login. The saved login is untouched; read again later.
333
+ - `Profile: ...` or `Usage: ...` -- the account endpoints, asked with a token the
334
+ refresh had just accepted. `Profile: Login expired or was rejected.` means the access
335
+ token itself was refused; the other wordings are the service, as above.
336
+ - `Claude returned a different account or organization` -- the answer was about another
337
+ account and was discarded whole, rather than shown under this account's name.
338
+ - `Claude profile response is unsupported` or `Claude usage response is unsupported` --
339
+ the service answered in a shape authswitch does not read. Nothing is guessed from a
340
+ response it does not understand.
341
+
342
+ An OpenCode `anthropic` row reads the same endpoints and so shows the same reasons,
343
+ plus two of its own: `This Anthropic login's access token has expired.`, which OpenCode
344
+ renews by running or by `opencode auth login`, and `This Anthropic login holds no usable
345
+ access token`, which needs a new `opencode auth login`.
275
346
 
276
347
  OpenCode and Claude saved records are separate owner-only files under
277
348
  `~/.authswitch/opencode` and `~/.authswitch/claude`. Emails are accepted as account
@@ -2,7 +2,7 @@
2
2
  * autocreated commitinfo by @push.rocks/commitinfo
3
3
  */
4
4
  export const commitinfo = {
5
- name: "@modelprofile.com/authswitch",
6
- version: "5.1.0",
7
- description: "Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status"
5
+ name: '@modelprofile.com/authswitch',
6
+ version: '6.0.0',
7
+ description: 'Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status'
8
8
  }
@@ -2,9 +2,9 @@ import * as plugins from './plugins.js';
2
2
  import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
3
3
  import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw, type IStoredCredential } from './classes.credentialstore.js';
4
4
  import { HarnessProcesses } from './classes.harnessprocesses.js';
5
- import { ClaudeAccountStatus } from './classes.claudestatus.js';
5
+ import { ClaudeAccountStatus, type IClaudeLogin } from './classes.claudestatus.js';
6
6
  import { ClaudeCodeLocks } from './classes.claudecodelocks.js';
7
- import { ClaudeTokenRefresh, type IClaudeTokens } from './classes.claudetokenrefresh.js';
7
+ import { claudeGrant, ClaudeTokenRefresh, grantScope, 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
10
  import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
@@ -160,8 +160,33 @@ export class ClaudeCodeHarness extends FileHarness {
160
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.');
161
161
  }
162
162
  }
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) ?? ''); }
163
+ /**
164
+ * The hash that identifies a refused sign-in, so it is remembered without keeping anything of the login.
165
+ *
166
+ * A refusal is about one request: the refresh token it carried, and the client and scopes authswitch derived for
167
+ * it. Binding all three means a login keeps its refusal for as long as authswitch would send the same request
168
+ * again, and that a release which changes the derivation -- adopting a scope set Claude Code changed, say -- gives
169
+ * every such login one honest attempt under the new one instead of repeating a verdict its request no longer earns.
170
+ */
171
+ private grantKey(oauthArg: Record<string, unknown>): string {
172
+ const grant = claudeGrant(oauthArg);
173
+ return credentialHash(JSON.stringify([credentialText(oauthArg.refreshToken) ?? '', grant?.clientId ?? '', grant === null ? '' : grantScope(grant.scopes)]));
174
+ }
175
+
176
+ /** The login a status lookup asks with: its token, and what this store knows about it. */
177
+ private login(accountArg: IFileAccount): IClaudeLogin {
178
+ const oauth = credentialRecord(accountArg.credential.claudeAiOauth);
179
+ const account = credentialRecord(accountArg.credential.oauthAccount);
180
+ const accountUuid = credentialText(account.accountUuid);
181
+ const organizationUuid = credentialText(account.organizationUuid);
182
+ const plan = credentialText(oauth.subscriptionType);
183
+ return {
184
+ accessToken: credentialText(oauth.accessToken) ?? '',
185
+ scopes: Array.isArray(oauth.scopes) ? oauth.scopes.filter((scope): scope is string => typeof scope === 'string') : [],
186
+ ...(accountUuid !== undefined && organizationUuid !== undefined ? { identity: { accountUuid, organizationUuid } } : {}),
187
+ ...(plan === undefined ? {} : { plan }),
188
+ };
189
+ }
165
190
 
166
191
  /**
167
192
  * The harness's accounts, with the saved copy of the active login brought up to date first.
@@ -226,28 +251,28 @@ export class ClaudeCodeHarness extends FileHarness {
226
251
  * is due is refreshed first; when that fails, the status says why and keeps the stored plan, and the saved login is
227
252
  * left exactly as it was.
228
253
  *
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.
254
+ * A refresh is only ever sent for a login the native files prove is not the active one (`nativeView`), and a request
255
+ * the service already refused is not sent again while the saved record would produce the same one (`grantKey`).
231
256
  */
232
257
  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);
258
+ const read = (accountArg: IFileAccount) => this.status.read(this.login(accountArg), optionsArg);
235
259
  const view = this.nativeView(id);
236
260
  if (view.active) return read(view.active);
237
261
  const saved = this.savedCredential(id);
262
+ const login = this.login(saved);
238
263
  const oauth = credentialRecord(saved.credential.claudeAiOauth);
239
264
  if (!this.tokens.due(oauth)) return read(saved);
240
265
  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}`);
266
+ return this.status.unavailable(login, `Login refresh: this saved login's access token has expired and cannot be renewed for this installation. ${view.unavailable}`);
242
267
  }
243
- if (this.store.read(id).rejectedGrant === this.grantKey(oauth)) return this.status.unavailable(oauth, `Login refresh: ${SAVED_LOGIN_REJECTED}`);
268
+ if (this.store.read(id).rejectedGrant === this.grantKey(oauth)) return this.status.unavailable(login, `Login refresh: ${SAVED_LOGIN_REJECTED}`);
244
269
  let refreshed: IFileAccount;
245
270
  try { refreshed = await this.refreshSaved(id, optionsArg.signal); }
246
271
  catch (error) {
247
272
  if (optionsArg.signal?.aborted) throw error;
248
273
  const problem = error instanceof ClaudeLoginRejectedError ? SAVED_LOGIN_REJECTED
249
274
  : error instanceof Error ? error.message : 'the saved login could not be refreshed.';
250
- return this.status.unavailable(oauth, `Login refresh: ${problem}`,
275
+ return this.status.unavailable(login, `Login refresh: ${problem}`,
251
276
  error instanceof ClaudeRateLimitError ? { retryAt: error.retryAt } : undefined);
252
277
  }
253
278
  return read(refreshed);
@@ -260,7 +285,7 @@ export class ClaudeCodeHarness extends FileHarness {
260
285
  * became active meanwhile belongs to Claude Code and is returned as it is, one in an installation that can no
261
286
  * longer be read is returned unrefreshed, and one another refresh already renewed is not refreshed twice. The new
262
287
  * 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).
288
+ * compare-and-swap Claude Code 2.1.273 applies to its own store (`$fn`, @191212749).
264
289
  */
265
290
  private refreshSaved(id: string, signal?: AbortSignal): Promise<IFileAccount> {
266
291
  return this.store.locked(async () => {
@@ -110,6 +110,21 @@ type TClaudeProfile =
110
110
  | { kind: 'mismatch' }
111
111
  | { kind: 'unavailable'; problem: string };
112
112
 
113
+ /**
114
+ * One Claude OAuth login as a status lookup needs it: the token to ask with, and what the store that keeps it knows
115
+ * about it. Claude Code's own store keeps the scopes and the account the login belongs to; another harness holding
116
+ * the same kind of grant (OpenCode's `anthropic` provider entry) keeps neither, and leaves both to the service.
117
+ */
118
+ export interface IClaudeLogin {
119
+ accessToken: string;
120
+ /** The scopes the login is stored with. An empty list means the store keeps none, not that the login has none. */
121
+ scopes: readonly string[];
122
+ /** The account the login must belong to; an answer about another account is refused. Omitted where no store keeps it. */
123
+ identity?: { accountUuid: string; organizationUuid: string };
124
+ /** The plan the login was saved with, the only one known while the service cannot be asked. */
125
+ plan?: string;
126
+ }
127
+
113
128
  /**
114
129
  * How long a session reuses an account's profile. The watch polls every few minutes, and the profile (plan,
115
130
  * organization, billing type) changes far more rarely, so it is read at most hourly there.
@@ -119,9 +134,12 @@ const REQUEST_TIMEOUT_MS = 10_000;
119
134
  const MISMATCH = 'Claude returned a different account or organization; no account data was displayed.';
120
135
 
121
136
  /**
122
- * Claude Code's read-only OAuth account endpoints: the profile and usage of one login, with the access token it
123
- * carries. It never refreshes that token; `ClaudeCodeHarness` refreshes a saved login first, and leaves the active
124
- * login's tokens to Claude Code. No inference or browser session is used.
137
+ * Claude Code's read-only OAuth account endpoints: the profile and usage of one Claude OAuth login, asked with the
138
+ * access token that login carries and with the headers Claude Code 2.1.273 sends (`_ke`, bundle offset @191153494,
139
+ * and the `anthropic-beta` value `Vu`, @188533107). Any harness holding such a grant is served, not only Claude Code's own store.
140
+ *
141
+ * It never refreshes that token; `ClaudeCodeHarness` refreshes a saved login first, and leaves the active login's
142
+ * tokens to Claude Code. No inference or browser session is used.
125
143
  */
126
144
  export class ClaudeAccountStatus {
127
145
  constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
@@ -139,15 +157,15 @@ export class ClaudeAccountStatus {
139
157
  }
140
158
 
141
159
  /** The profile, from the session while it is younger than an hour; the session key is a hash of the token, never the token. */
142
- private profile(token: string, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions): Promise<TClaudeProfile> {
143
- const lookup = () => this.lookupProfile(token, account, optionsArg.signal);
144
- return optionsArg.session ? optionsArg.session.reuse(`claude:profile:${credentialHash(token)}`, PROFILE_REUSE_MS, lookup) : lookup();
160
+ private profile(loginArg: IClaudeLogin, optionsArg: IHarnessStatusOptions): Promise<TClaudeProfile> {
161
+ const lookup = () => this.lookupProfile(loginArg, optionsArg.signal);
162
+ return optionsArg.session ? optionsArg.session.reuse(`claude:profile:${credentialHash(loginArg.accessToken)}`, PROFILE_REUSE_MS, lookup) : lookup();
145
163
  }
146
164
 
147
165
  /** A rejected login and an unsupported or foreign profile are answers; a rate limit or a failed request is thrown. */
148
- private async lookupProfile(token: string, account: Record<string, unknown>, signal?: AbortSignal): Promise<TClaudeProfile> {
166
+ private async lookupProfile(loginArg: IClaudeLogin, signal?: AbortSignal): Promise<TClaudeProfile> {
149
167
  let body: Record<string, unknown>;
150
- try { body = await this.get('profile', token, signal); }
168
+ try { body = await this.get('profile', loginArg.accessToken, signal); }
151
169
  catch (error) {
152
170
  if (error instanceof ClaudeLoginRejectedError) return { kind: 'unavailable', problem: `Profile: ${error.message}` };
153
171
  throw error;
@@ -155,7 +173,9 @@ export class ClaudeAccountStatus {
155
173
  try {
156
174
  const user = credentialRecord(body.account);
157
175
  const organization = credentialRecord(body.organization);
158
- if (user.uuid !== account.accountUuid || organization.uuid !== account.organizationUuid) return { kind: 'mismatch' };
176
+ // A store that keeps no account identity has none to compare; the service's answer is about the token that was sent.
177
+ const identity = loginArg.identity;
178
+ if (identity !== undefined && (user.uuid !== identity.accountUuid || organization.uuid !== identity.organizationUuid)) return { kind: 'mismatch' };
159
179
  const facts: IHarnessStatusFact[] = [];
160
180
  for (const [label, value] of [['Email', user.email], ['Organization', organization.name], ['Billing type', organization.billing_type], ['Seat tier', organization.seat_tier]]) {
161
181
  if (credentialText(value)) facts.push({ section: 'Account', label: String(label), value: String(value) });
@@ -165,25 +185,25 @@ export class ClaudeAccountStatus {
165
185
  }
166
186
 
167
187
  /** The plan the login was saved with, the only one known while the service cannot be asked. */
168
- private storedSummary(oauth: Record<string, unknown>): Pick<IHarnessAccountStatus, 'summary'> {
169
- const plan = credentialText(oauth.subscriptionType);
170
- return plan ? { summary: { subscription: { plan, source: 'stored' } } } : {};
188
+ private storedSummary(loginArg: Pick<IClaudeLogin, 'plan'>): Pick<IHarnessAccountStatus, 'summary'> {
189
+ return loginArg.plan ? { summary: { subscription: { plan: loginArg.plan, source: 'stored' } } } : {};
171
190
  }
172
191
 
173
192
  /** A login that could not be made usable for a lookup: its stored plan and why, with the service's retry time for a rate limit. */
174
- public unavailable(oauth: Record<string, unknown>, problem: string, rateLimit?: IHarnessAccountStatus['rateLimit']): IHarnessAccountStatus {
175
- return { facts: [], problems: [problem], ...this.storedSummary(oauth), ...(rateLimit ? { rateLimit } : {}) };
193
+ public unavailable(loginArg: Pick<IClaudeLogin, 'plan'>, problem: string, rateLimit?: IHarnessAccountStatus['rateLimit']): IHarnessAccountStatus {
194
+ return { facts: [], problems: [problem], ...this.storedSummary(loginArg), ...(rateLimit ? { rateLimit } : {}) };
176
195
  }
177
196
 
178
- public async read(oauth: Record<string, unknown>, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
179
- const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(oauth) };
180
- if (!Array.isArray(oauth.scopes) || !oauth.scopes.includes('user:profile')) {
197
+ public async read(loginArg: IClaudeLogin, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
198
+ const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(loginArg) };
199
+ // Only a store that keeps the login's scopes can tell beforehand that the service will not answer about the account.
200
+ if (loginArg.scopes.length && !loginArg.scopes.includes('user:profile')) {
181
201
  result.facts.push({ section: 'Availability', label: 'Live account status', value: 'This login lacks the user:profile scope. Log in through Claude Code to obtain subscriber status.' });
182
202
  return result;
183
203
  }
184
- const token = credentialText(oauth.accessToken);
204
+ const token = credentialText(loginArg.accessToken);
185
205
  if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
186
- const [profile, usage] = await Promise.allSettled([this.profile(token, account, optionsArg), this.get('usage', token, optionsArg.signal)]);
206
+ const [profile, usage] = await Promise.allSettled([this.profile(loginArg, optionsArg), this.get('usage', token, optionsArg.signal)]);
187
207
  const failure = (reason: unknown): string => reason instanceof ClaudeRequestError ? reason.message : 'Lookup failed.';
188
208
  if (profile.status === 'fulfilled') {
189
209
  const found = profile.value;
@@ -1,18 +1,31 @@
1
1
  import { credentialText } from './classes.credentialstore.js';
2
- import { claudeRequest, ClaudeLoginRejectedError, ClaudeRequestError } from './claudehttp.js';
2
+ import { claudeRequest, ClaudeAccountOnHoldError, ClaudeLoginRejectedError, ClaudeRequestError, oauthErrorCode } from './claudehttp.js';
3
3
 
4
- /** Claude Code 2.1.273's token endpoint (`TOKEN_URL`, bundle offset @12733057; see hints.md). */
4
+ /** Claude Code 2.1.273's token endpoint (`TOKEN_URL`, bundle offset @188533776). */
5
5
  const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
6
- /** Claude Code 2.1.273's public OAuth client (`CLIENT_ID`, @12733057), used when a login does not name its own. */
6
+ /** Claude Code 2.1.273's public OAuth client (`CLIENT_ID`, @188534240), used when a login does not name its own. */
7
7
  export const CLAUDE_CODE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
8
- /** Claude Code 2.1.273 refreshes an access token that expires within five minutes (`cF`, @15359182). */
9
- const REFRESH_MARGIN_MS = 300_000;
8
+ /** Claude Code 2.1.273 refreshes an access token that expires within five minutes (`cF`, @191161107). */
9
+ export const CLAUDE_REFRESH_MARGIN_MS = 300_000;
10
10
  /**
11
11
  * Claude Code 2.1.273 allows a refresh 30 seconds. A refresh is never cut shorter than that, nor cancelled once sent:
12
12
  * the service may already have replaced the refresh token the request carries.
13
13
  */
14
14
  const REFRESH_TIMEOUT_MS = 30_000;
15
15
 
16
+ /**
17
+ * The scopes Claude Code 2.1.273 asks for when it refreshes a login of its own OAuth client (`bFe`, @188533214,
18
+ * with the plugins scope its production build registers). It sends this set whatever the login was stored with,
19
+ * so a login saved under an older or wider set is renewed exactly as Claude Code renews it.
20
+ */
21
+ export const CLAUDE_CODE_SCOPES: readonly string[] = [
22
+ 'user:profile', 'user:inference', 'user:sessions:claude_code', 'user:mcp_servers', 'user:file_upload', 'user:plugins',
23
+ ];
24
+ /** The only stored scopes Claude Code carries into that set (`Dyn`, @188533421). */
25
+ const CARRIED_SCOPES: readonly string[] = ['user:projects:read', 'user:projects:write'];
26
+ /** The scope that marks a login as one of Claude Code's own subscriber logins (`NS`/`YWt`, @188532967). */
27
+ const INFERENCE_SCOPE = 'user:inference';
28
+
16
29
  /** The token fields a refresh replaces in a stored `claudeAiOauth`; every other field is kept. */
17
30
  export interface IClaudeTokens {
18
31
  accessToken: string;
@@ -23,11 +36,64 @@ export interface IClaudeTokens {
23
36
  scopes?: string[];
24
37
  }
25
38
 
39
+ /** How Claude Code 2.1.273 builds one refresh request for a stored login. */
40
+ export interface IClaudeGrant {
41
+ /** The login's own OAuth client, or Claude Code's public one. */
42
+ clientId: string;
43
+ /** The scopes the request asks for. */
44
+ scopes: readonly string[];
45
+ /**
46
+ * The stored scopes the request falls back to once the service refuses the set above as `invalid_scope`
47
+ * (`hg`, @191228494); null where Claude Code sends no second request.
48
+ */
49
+ fallbackScopes: readonly string[] | null;
50
+ }
51
+
52
+ /** The scopes a stored login carries, or none when it carries nothing a scope list can be read from. */
53
+ const storedScopes = (oauthArg: Record<string, unknown>): string[] =>
54
+ Array.isArray(oauthArg.scopes) && oauthArg.scopes.every(scope => typeof scope === 'string') ? [...oauthArg.scopes as string[]] : [];
55
+
56
+ /**
57
+ * How Claude Code 2.1.273 would renew this login, or null where it would not send a refresh at all: a login without
58
+ * a refresh token, and one that is neither a subscriber login (`user:inference`) nor stored with a plan, which
59
+ * Claude Code reports as `not_refreshable` (`hg`, @191228494).
60
+ *
61
+ * A login of Claude Code's own client is renewed with Claude Code's current scope set plus the project scopes it
62
+ * carries (`DQn`, @188533511) -- not with the set it was stored under. A login that names its own OAuth client is
63
+ * renewed with exactly the scopes it was stored with, because only that client's grant knows them.
64
+ */
65
+ export const claudeGrant = (oauthArg: Record<string, unknown>): IClaudeGrant | null => {
66
+ if (!credentialText(oauthArg.refreshToken)) return null;
67
+ const stored = storedScopes(oauthArg);
68
+ const isSubscriberLogin = stored.includes(INFERENCE_SCOPE);
69
+ if (!isSubscriberLogin && !credentialText(oauthArg.subscriptionType)) return null;
70
+ const clientId = credentialText(oauthArg.clientId);
71
+ if (clientId !== undefined) return { clientId, scopes: stored, fallbackScopes: null };
72
+ return {
73
+ clientId: CLAUDE_CODE_CLIENT_ID,
74
+ scopes: [...new Set([...CLAUDE_CODE_SCOPES, ...stored.filter(scope => CARRIED_SCOPES.includes(scope))])],
75
+ fallbackScopes: isSubscriberLogin ? stored : null,
76
+ };
77
+ };
78
+
79
+ /** The `scope` value one request carries: Claude Code sends its own set for an empty scope list (`RQ`, @191156785). */
80
+ export const grantScope = (scopesArg: readonly string[]): string => (scopesArg.length ? scopesArg : CLAUDE_CODE_SCOPES).join(' ');
81
+
26
82
  const seconds = (valueArg: unknown): number | undefined =>
27
83
  typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0 ? valueArg : undefined;
28
84
 
29
85
  /**
30
- * Refreshes a saved Claude Code login with the refresh-token grant Claude Code 2.1.273 uses (`RQ`, @15355055).
86
+ * Whether the service refused this grant because the account is on hold (`Pfn`, @191165493, over `fgt`, @190071426):
87
+ * an `invalid_grant` or `access_denied` whose description is the service's `account_on_hold` marker. Claude Code keeps
88
+ * such a login and retries it later; only a refusal without that marker means the grant itself is dead.
89
+ */
90
+ const accountOnHold = (statusArg: number, bodyArg: Record<string, unknown> | null): boolean =>
91
+ (statusArg === 400 || statusArg === 401 || statusArg === 403)
92
+ && (oauthErrorCode(bodyArg) === 'invalid_grant' || oauthErrorCode(bodyArg) === 'access_denied')
93
+ && bodyArg?.error_description === 'account_on_hold';
94
+
95
+ /**
96
+ * Refreshes a saved Claude Code login with the refresh-token grant Claude Code 2.1.273 uses (`RQ`, @191156785).
31
97
  *
32
98
  * It only talks to the token endpoint; which login is refreshed, and how the result is stored, belongs to the harness.
33
99
  */
@@ -41,29 +107,39 @@ export class ClaudeTokenRefresh {
41
107
  public due(oauthArg: Record<string, unknown>): boolean {
42
108
  const expiresAt = oauthArg.expiresAt;
43
109
  return typeof expiresAt === 'number' && Number.isFinite(expiresAt) && credentialText(oauthArg.refreshToken) !== undefined
44
- && this.now() + REFRESH_MARGIN_MS >= expiresAt;
110
+ && this.now() + CLAUDE_REFRESH_MARGIN_MS >= expiresAt;
111
+ }
112
+
113
+ /** One refresh request, as Claude Code sends it: a JSON body, the login's client, and the scopes of this attempt. */
114
+ private attempt(refreshTokenArg: string, grantArg: IClaudeGrant, scopesArg: readonly string[]) {
115
+ return claudeRequest(this.fetcher, {
116
+ url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
117
+ body: JSON.stringify({
118
+ grant_type: 'refresh_token', refresh_token: refreshTokenArg, client_id: grantArg.clientId, scope: grantScope(scopesArg),
119
+ }),
120
+ timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
121
+ });
45
122
  }
46
123
 
47
124
  /**
48
125
  * New tokens for the login. A response without a refresh token keeps the one sent, and one without a scope keeps the
49
126
  * stored scopes, as Claude Code merges them. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
50
- * rejects is `ClaudeLoginRejectedError`, and a rate limit is `ClaudeRateLimitError`.
127
+ * rejects is `ClaudeLoginRejectedError`, an account the service put on hold is `ClaudeAccountOnHoldError`, and a rate
128
+ * limit is `ClaudeRateLimitError`.
51
129
  */
52
130
  public async refresh(oauthArg: Record<string, unknown>): Promise<IClaudeTokens> {
53
131
  const refreshToken = credentialText(oauthArg.refreshToken);
54
- const scopes = oauthArg.scopes;
55
- if (!refreshToken || !Array.isArray(scopes) || !scopes.every(scope => typeof scope === 'string')) {
56
- throw new ClaudeRequestError('This saved login has no refresh token or scopes to refresh with. Log in again with Claude Code and save it.');
132
+ const grant = claudeGrant(oauthArg);
133
+ if (!refreshToken || !grant) {
134
+ throw new ClaudeRequestError('This saved login has no refresh token, and no subscriber scope or plan to refresh with. Log in again with Claude Code and save it.');
57
135
  }
58
- const { status, body } = await claudeRequest(this.fetcher, {
59
- url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
60
- body: JSON.stringify({
61
- grant_type: 'refresh_token', refresh_token: refreshToken,
62
- client_id: credentialText(oauthArg.clientId) ?? CLAUDE_CODE_CLIENT_ID, scope: scopes.join(' '),
63
- }),
64
- timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
65
- });
66
- if ((status === 400 || status === 401) && body?.error === 'invalid_grant') throw new ClaudeLoginRejectedError();
136
+ let { status, body } = await this.attempt(refreshToken, grant, grant.scopes);
137
+ // The service refusing Claude Code's current scope set is the one failure it sends a second request for.
138
+ if (status === 400 && oauthErrorCode(body) === 'invalid_scope' && grant.fallbackScopes !== null) {
139
+ ({ status, body } = await this.attempt(refreshToken, grant, grant.fallbackScopes));
140
+ }
141
+ if (accountOnHold(status, body)) throw new ClaudeAccountOnHoldError();
142
+ if ((status === 400 || status === 401) && oauthErrorCode(body) === 'invalid_grant') throw new ClaudeLoginRejectedError();
67
143
  if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
68
144
  const receivedAt = this.now();
69
145
  const accessToken = credentialText(body?.access_token);
@@ -2,6 +2,8 @@ import * as plugins from './plugins.js';
2
2
  import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
3
3
  import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, readCredentialRaw } from './classes.credentialstore.js';
4
4
  import { HarnessProcesses } from './classes.harnessprocesses.js';
5
+ import { ClaudeAccountStatus } from './classes.claudestatus.js';
6
+ import { CLAUDE_REFRESH_MARGIN_MS } from './classes.claudetokenrefresh.js';
5
7
  import { CodexAccountStatus } from './classes.codexstatus.js';
6
8
  import { writeSecretFileAtomically } from './helpers.js';
7
9
  import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
@@ -15,6 +17,8 @@ export interface IOpenCodeHarnessOptions {
15
17
  fetch?: typeof fetch;
16
18
  /** Running-instance inspection, injectable for isolated installations and tests. */
17
19
  processes?: IHarnessProcessControl;
20
+ /** The clock that decides whether a provider's stored access token is still usable; `Date.now` by default. */
21
+ now?: () => number;
18
22
  }
19
23
 
20
24
  /** OpenCode owns one login per provider. Replacing a slot never replaces the whole provider set. */
@@ -31,8 +35,10 @@ export class OpenCodeHarness extends FileHarness {
31
35
  private readonly file: string;
32
36
  private readonly env: NodeJS.ProcessEnv;
33
37
  private readonly status: CodexAccountStatus;
38
+ private readonly claudeStatus: ClaudeAccountStatus;
34
39
  public readonly processes: IHarnessProcessControl;
35
40
  private readonly fetcher?: typeof fetch;
41
+ private readonly now: () => number;
36
42
 
37
43
  constructor(options: IOpenCodeHarnessOptions = {}) {
38
44
  super();
@@ -40,7 +46,9 @@ export class OpenCodeHarness extends FileHarness {
40
46
  this.file = plugins.path.join(options.dataHome ?? (this.env.XDG_DATA_HOME || plugins.path.join(plugins.os.homedir(), '.local', 'share')), 'opencode', 'auth.json');
41
47
  this.store = new CredentialStore(this.id, options.stashRoot);
42
48
  this.status = new CodexAccountStatus(options.fetch);
49
+ this.claudeStatus = new ClaudeAccountStatus(options.fetch, options.now);
43
50
  this.fetcher = options.fetch;
51
+ this.now = options.now ?? Date.now;
44
52
  this.processes = options.processes ?? new HarnessProcesses('opencode');
45
53
  }
46
54
  /** Every provider entry lives in one file, so its bytes are the whole stability proof. */
@@ -104,6 +112,27 @@ export class OpenCodeHarness extends FileHarness {
104
112
  writeSecretFileAtomically(this.file, raw);
105
113
  if (readCredentialDocument(this.file).raw !== raw) throw new Error('Active login verification failed. The outgoing login remains saved.');
106
114
  }
115
+ /**
116
+ * The subscription and usage of an OpenCode `anthropic` OAuth login.
117
+ *
118
+ * That entry holds the same Claude subscriber grant Claude Code stores, so Claude's own account endpoints answer
119
+ * for it and the account reports real windows instead of no quota API at all. OpenCode keeps no scopes and no
120
+ * account identity with it, so the service decides both; the token is used exactly as stored. Renewing it is
121
+ * OpenCode's, because the renewal rotates the refresh token a running OpenCode holds in memory, and authswitch
122
+ * cannot write that rotation into an instance it does not manage.
123
+ */
124
+ private anthropicStatus(credentialArg: Record<string, unknown>, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
125
+ const accessToken = credentialText(credentialArg.access);
126
+ const expires = credentialArg.expires;
127
+ if (accessToken === undefined || typeof expires !== 'number') {
128
+ return Promise.resolve(this.claudeStatus.unavailable({}, 'This Anthropic login holds no usable access token; log in again with opencode auth login.'));
129
+ }
130
+ if (this.now() + CLAUDE_REFRESH_MARGIN_MS >= expires) {
131
+ return Promise.resolve(this.claudeStatus.unavailable({}, 'This Anthropic login\'s access token has expired. OpenCode renews it while it runs; authswitch does not, because the renewal rotates the refresh token a running OpenCode keeps in memory. Start OpenCode, or log in again with opencode auth login.'));
132
+ }
133
+ return this.claudeStatus.read({ accessToken, scopes: [] }, optionsArg);
134
+ }
135
+
107
136
  public async readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
108
137
  const account = this.readCredential(id);
109
138
  if (account.slotId === 'openai' && account.credential.type === 'oauth') {
@@ -111,6 +140,7 @@ export class OpenCodeHarness extends FileHarness {
111
140
  if (!info.accountId) return { facts: [], problems: ['The OpenAI login has no account ID for a status lookup.'] };
112
141
  return this.status.readCredential(credentialText(account.credential.access)!, info.accountId, info.plan, optionsArg);
113
142
  }
143
+ if (account.slotId === 'anthropic' && account.credential.type === 'oauth') return this.anthropicStatus(account.credential, optionsArg);
114
144
  return { facts: [
115
145
  { section: 'Authentication', label: 'Provider', value: account.slotId },
116
146
  { section: 'Authentication', label: 'Login type', value: String(account.credential.type) },