@modelprofile.com/authswitch 6.5.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +302 -6
  3. package/dist_ts/authority-contract.js +1 -1
  4. package/dist_ts/authority-runtime-contract.d.ts +13 -0
  5. package/dist_ts/authorityrehearsal.child.d.ts +1 -0
  6. package/dist_ts/authorityrehearsal.child.js +395 -0
  7. package/dist_ts/classes.accountlist.js +4 -3
  8. package/dist_ts/classes.authoritybackup.d.ts +134 -0
  9. package/dist_ts/classes.authoritybackup.js +769 -0
  10. package/dist_ts/classes.authoritybroker.d.ts +25 -5
  11. package/dist_ts/classes.authoritybroker.js +392 -123
  12. package/dist_ts/classes.authoritycli.d.ts +24 -0
  13. package/dist_ts/classes.authoritycli.js +675 -0
  14. package/dist_ts/classes.authorityclient.d.ts +19 -7
  15. package/dist_ts/classes.authorityclient.js +51 -14
  16. package/dist_ts/classes.authoritydaemon.d.ts +28 -1
  17. package/dist_ts/classes.authoritydaemon.js +482 -36
  18. package/dist_ts/classes.authoritydatabase.d.ts +175 -10
  19. package/dist_ts/classes.authoritydatabase.js +1100 -52
  20. package/dist_ts/classes.authoritymodels.d.ts +223 -1
  21. package/dist_ts/classes.authoritymodels.js +798 -9
  22. package/dist_ts/classes.authoritypreuse.d.ts +47 -0
  23. package/dist_ts/classes.authoritypreuse.js +278 -0
  24. package/dist_ts/classes.authorityregistry.d.ts +24 -0
  25. package/dist_ts/classes.authorityregistry.js +88 -0
  26. package/dist_ts/classes.authorityrehearsal.d.ts +127 -0
  27. package/dist_ts/classes.authorityrehearsal.js +632 -0
  28. package/dist_ts/classes.authorityusage.d.ts +160 -0
  29. package/dist_ts/classes.authorityusage.js +429 -0
  30. package/dist_ts/classes.claudeauthority.d.ts +66 -0
  31. package/dist_ts/classes.claudeauthority.js +532 -0
  32. package/dist_ts/classes.claudecodeharness.js +3 -2
  33. package/dist_ts/classes.claudecodelocks.d.ts +13 -0
  34. package/dist_ts/classes.claudecodelocks.js +75 -3
  35. package/dist_ts/classes.claudenative.d.ts +137 -0
  36. package/dist_ts/classes.claudenative.js +731 -0
  37. package/dist_ts/classes.claudestatus.d.ts +9 -0
  38. package/dist_ts/classes.claudestatus.js +26 -9
  39. package/dist_ts/classes.claudetokenrefresh.d.ts +15 -1
  40. package/dist_ts/classes.claudetokenrefresh.js +74 -21
  41. package/dist_ts/classes.codexharness.js +4 -2
  42. package/dist_ts/classes.codexmanaged.d.ts +11 -0
  43. package/dist_ts/classes.codexmanaged.js +75 -14
  44. package/dist_ts/classes.codexpreuse.d.ts +8 -0
  45. package/dist_ts/classes.codexpreuse.js +24 -6
  46. package/dist_ts/classes.fileharness.d.ts +2 -0
  47. package/dist_ts/classes.fileharness.js +6 -4
  48. package/dist_ts/classes.limits.js +12 -7
  49. package/dist_ts/classes.opencodeharness.js +2 -2
  50. package/dist_ts/claudehttp.d.ts +10 -0
  51. package/dist_ts/claudehttp.js +29 -3
  52. package/dist_ts/index.d.ts +1 -0
  53. package/dist_ts/index.js +7 -1
  54. package/dist_ts/interfaces.harness.d.ts +6 -0
  55. package/dist_ts/interfaces.list.d.ts +9 -3
  56. package/dist_ts/plugins.d.ts +5 -2
  57. package/dist_ts/plugins.js +7 -3
  58. package/dist_ts/ts_migration/0001_authority_meta.d.ts +3 -0
  59. package/dist_ts/ts_migration/0001_authority_meta.js +32 -0
  60. package/dist_ts/ts_migration/index.d.ts +3 -0
  61. package/dist_ts/ts_migration/index.js +6 -0
  62. package/package.json +4 -1
  63. package/readme.md +181 -14
  64. package/ts/00_commitinfo_data.ts +1 -1
  65. package/ts/authority-contract.ts +264 -9
  66. package/ts/authority-runtime-contract.ts +14 -0
  67. package/ts/authorityrehearsal.child.ts +330 -0
  68. package/ts/classes.accountlist.ts +3 -2
  69. package/ts/classes.authoritybackup.ts +780 -0
  70. package/ts/classes.authoritybroker.ts +389 -121
  71. package/ts/classes.authoritycli.ts +719 -0
  72. package/ts/classes.authorityclient.ts +75 -18
  73. package/ts/classes.authoritydaemon.ts +449 -36
  74. package/ts/classes.authoritydatabase.ts +1147 -55
  75. package/ts/classes.authoritymodels.ts +541 -6
  76. package/ts/classes.authoritypreuse.ts +297 -0
  77. package/ts/classes.authorityregistry.ts +118 -0
  78. package/ts/classes.authorityrehearsal.ts +625 -0
  79. package/ts/classes.authorityusage.ts +527 -0
  80. package/ts/classes.claudeauthority.ts +541 -0
  81. package/ts/classes.claudecodeharness.ts +2 -1
  82. package/ts/classes.claudecodelocks.ts +70 -1
  83. package/ts/classes.claudenative.ts +816 -0
  84. package/ts/classes.claudestatus.ts +38 -9
  85. package/ts/classes.claudetokenrefresh.ts +82 -19
  86. package/ts/classes.codexharness.ts +3 -1
  87. package/ts/classes.codexmanaged.ts +75 -12
  88. package/ts/classes.codexpreuse.ts +33 -5
  89. package/ts/classes.fileharness.ts +7 -3
  90. package/ts/classes.limits.ts +11 -6
  91. package/ts/classes.opencodeharness.ts +1 -1
  92. package/ts/claudehttp.ts +31 -2
  93. package/ts/index.ts +6 -0
  94. package/ts/interfaces.harness.ts +6 -0
  95. package/ts/interfaces.list.ts +9 -3
  96. package/ts/plugins.ts +7 -2
  97. package/ts/ts_migration/0001_authority_meta.ts +33 -0
  98. package/ts/ts_migration/index.ts +7 -0
@@ -108,7 +108,7 @@ const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows
108
108
  type TClaudeProfile =
109
109
  | { kind: 'found'; plan: string | undefined; facts: IHarnessStatusFact[] }
110
110
  | { kind: 'mismatch' }
111
- | { kind: 'unavailable'; problem: string };
111
+ | { kind: 'unavailable'; problem: string; credentialRejected: boolean };
112
112
 
113
113
  /**
114
114
  * One Claude OAuth login as a status lookup needs it: the token to ask with, and what the store that keeps it knows
@@ -125,6 +125,14 @@ export interface IClaudeLogin {
125
125
  plan?: string;
126
126
  }
127
127
 
128
+ /** Structured proof from the same profile and usage requests used for harness status. */
129
+ export interface IClaudeStatusRead {
130
+ status: IHarnessAccountStatus;
131
+ profile: 'verified' | 'unverified' | 'mismatch' | 'unavailable' | 'unsupported_scope';
132
+ usage: 'available' | 'unavailable' | 'unsupported_scope';
133
+ credentialRejected: boolean;
134
+ }
135
+
128
136
  /**
129
137
  * How long a session reuses an account's profile. The watch polls every few minutes, and the profile (plan,
130
138
  * organization, billing type) changes far more rarely, so it is read at most hourly there.
@@ -167,7 +175,8 @@ export class ClaudeAccountStatus {
167
175
  let body: Record<string, unknown>;
168
176
  try { body = await this.get('profile', loginArg.accessToken, signal); }
169
177
  catch (error) {
170
- if (error instanceof ClaudeLoginRejectedError) return { kind: 'unavailable', problem: `Profile: ${error.message}` };
178
+ if (error instanceof ClaudeLoginRejectedError) return { kind: 'unavailable',
179
+ problem: `Profile: ${error.message}`, credentialRejected: true };
171
180
  throw error;
172
181
  }
173
182
  try {
@@ -181,7 +190,8 @@ export class ClaudeAccountStatus {
181
190
  if (credentialText(value)) facts.push({ section: 'Account', label: String(label), value: String(value) });
182
191
  }
183
192
  return { kind: 'found', plan: credentialText(organization.organization_type)?.replace(/^claude_/, ''), facts };
184
- } catch { return { kind: 'unavailable', problem: 'Claude profile response is unsupported; no profile values were inferred.' }; }
193
+ } catch { return { kind: 'unavailable',
194
+ problem: 'Claude profile response is unsupported; no profile values were inferred.', credentialRejected: false }; }
185
195
  }
186
196
 
187
197
  /** The plan the login was saved with, the only one known while the service cannot be asked. */
@@ -195,25 +205,43 @@ export class ClaudeAccountStatus {
195
205
  }
196
206
 
197
207
  public async read(loginArg: IClaudeLogin, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
208
+ return (await this.readDetailed(loginArg, optionsArg)).status;
209
+ }
210
+
211
+ /** One request pair; callers requiring immutable identity use the proof rather than display text. */
212
+ public async readDetailed(loginArg: IClaudeLogin,
213
+ optionsArg: IHarnessStatusOptions = {}): Promise<IClaudeStatusRead> {
198
214
  const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(loginArg) };
199
215
  // Only a store that keeps the login's scopes can tell beforehand that the service will not answer about the account.
200
216
  if (loginArg.scopes.length && !loginArg.scopes.includes('user:profile')) {
201
217
  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.' });
202
- return result;
218
+ return { status: result, profile: 'unsupported_scope', usage: 'unsupported_scope', credentialRejected: false };
203
219
  }
204
220
  const token = credentialText(loginArg.accessToken);
205
- if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
221
+ if (!token) return { status: { facts: [], problems: ['No usable Claude Code access token is available.'] },
222
+ profile: 'unavailable', usage: 'unavailable', credentialRejected: false };
206
223
  const [profile, usage] = await Promise.allSettled([this.profile(loginArg, optionsArg), this.get('usage', token, optionsArg.signal)]);
207
224
  const failure = (reason: unknown): string => reason instanceof ClaudeRequestError ? reason.message : 'Lookup failed.';
225
+ let profileOutcome: IClaudeStatusRead['profile'] = 'unavailable';
226
+ let usageOutcome: IClaudeStatusRead['usage'] = 'unavailable';
227
+ let credentialRejected = usage.status === 'rejected' && usage.reason instanceof ClaudeLoginRejectedError;
208
228
  if (profile.status === 'fulfilled') {
209
229
  const found = profile.value;
210
- if (found.kind === 'mismatch') return { facts: [], problems: [MISMATCH] };
211
- if (found.kind === 'unavailable') result.problems.push(found.problem);
230
+ if (found.kind === 'mismatch') return { status: { facts: [], problems: [MISMATCH] },
231
+ profile: 'mismatch', usage: 'unavailable', credentialRejected: false };
232
+ if (found.kind === 'unavailable') {
233
+ result.problems.push(found.problem);
234
+ credentialRejected ||= found.credentialRejected;
235
+ }
212
236
  else {
237
+ profileOutcome = loginArg.identity ? 'verified' : 'unverified';
213
238
  if (found.plan) result.summary = { ...result.summary, subscription: { plan: found.plan, source: 'live' } };
214
239
  result.facts.push(...found.facts.map(fact => ({ ...fact })));
215
240
  }
216
- } else result.problems.push(`Profile: ${failure(profile.reason)}`);
241
+ } else {
242
+ result.problems.push(`Profile: ${failure(profile.reason)}`);
243
+ credentialRejected ||= profile.reason instanceof ClaudeLoginRejectedError;
244
+ }
217
245
  if (usage.status === 'fulfilled') {
218
246
  try {
219
247
  const { windows, unsupported } = claudeUsageWindows(usage.value);
@@ -232,12 +260,13 @@ export class ClaudeAccountStatus {
232
260
  if (limit !== undefined && limit > 0 && used !== undefined) result.facts.push({ section: 'Extra usage', label: 'Monthly budget used', value: `${Math.round(used / limit * 10000) / 100}%` });
233
261
  }
234
262
  if (unsupported.length) result.facts.push({ section: 'Availability', label: 'Unsupported limits', value: `The service returned limits without a supported reset window, which are not shown: ${unsupported.join(', ')}.` });
263
+ usageOutcome = 'available';
235
264
  } catch { result.problems.push('Claude usage response is unsupported; missing values were not inferred.'); }
236
265
  } else result.problems.push(`Usage: ${failure(usage.reason)}`);
237
266
  const limited = [profile, usage].flatMap(item => item.status === 'rejected' && item.reason instanceof ClaudeRateLimitError ? [item.reason] : []);
238
267
  if (limited.length) result.rateLimit = { retryAt: latestRetryAt(limited.map(error => error.retryAt)) };
239
268
  // The missing renewal dates are the harness's `renewalUnavailableReason`, stated once by the views that show them.
240
269
  result.facts.push({ section: 'Availability', label: 'Earned resets', value: 'authswitch does not read earned reset credits for Claude Code logins.' });
241
- return result;
270
+ return { status: result, profile: profileOutcome, usage: usageOutcome, credentialRejected };
242
271
  }
243
272
  }
@@ -1,5 +1,8 @@
1
- import { credentialText } from './classes.credentialstore.js';
2
- import { claudeRequest, ClaudeAccountOnHoldError, ClaudeLoginRejectedError, ClaudeRequestError, oauthErrorCode } from './claudehttp.js';
1
+ import { claudeRequest, claudeRotatingRequest, ClaudeAccountOnHoldError, ClaudeLoginRejectedError,
2
+ ClaudeRequestError, ClaudeRotatingRequestError, oauthErrorCode } from './claudehttp.js';
3
+
4
+ const credentialText = (value: unknown): string | undefined =>
5
+ typeof value === 'string' && value.length > 0 ? value : undefined;
3
6
 
4
7
  /** Claude Code 2.1.273's token endpoint (`TOKEN_URL`, bundle offset @188533776). */
5
8
  const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
@@ -36,6 +39,13 @@ export interface IClaudeTokens {
36
39
  scopes?: string[];
37
40
  }
38
41
 
42
+ /** Authority callers persist a pre-send marker, then classify every failed rotating request. */
43
+ export type TClaudeAuthorityRefreshResult =
44
+ | { success: true; tokens: IClaudeTokens }
45
+ | { success: false; requestOutcome: 'notSent' | 'outcomeUnknown';
46
+ reason: 'not_refreshable' | 'provider_rejected' | 'account_on_hold' | 'provider_unavailable' | 'unsupported_response';
47
+ responseStatus?: number };
48
+
39
49
  /** How Claude Code 2.1.273 builds one refresh request for a stored login. */
40
50
  export interface IClaudeGrant {
41
51
  /** The login's own OAuth client, or Claude Code's public one. */
@@ -104,10 +114,10 @@ export class ClaudeTokenRefresh {
104
114
  * Whether Claude Code would refresh this login before using it: its access token expires within five minutes. A
105
115
  * login without a numeric expiry or without a refresh token is used as it is, exactly as Claude Code does.
106
116
  */
107
- public due(oauthArg: Record<string, unknown>): boolean {
117
+ public due(oauthArg: Record<string, unknown>, minValidityMs = CLAUDE_REFRESH_MARGIN_MS): boolean {
108
118
  const expiresAt = oauthArg.expiresAt;
109
119
  return typeof expiresAt === 'number' && Number.isFinite(expiresAt) && credentialText(oauthArg.refreshToken) !== undefined
110
- && this.now() + CLAUDE_REFRESH_MARGIN_MS >= expiresAt;
120
+ && this.now() + Math.max(CLAUDE_REFRESH_MARGIN_MS, minValidityMs) >= expiresAt;
111
121
  }
112
122
 
113
123
  /** One refresh request, as Claude Code sends it: a JSON body, the login's client, and the scopes of this attempt. */
@@ -121,26 +131,56 @@ export class ClaudeTokenRefresh {
121
131
  });
122
132
  }
123
133
 
124
- /**
125
- * New tokens for the login. A response without a refresh token keeps the one sent, and one without a scope keeps the
126
- * stored scopes, as Claude Code merges them. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
127
- * rejects is `ClaudeLoginRejectedError`, an account the service put on hold is `ClaudeAccountOnHoldError`, and a rate
128
- * limit is `ClaudeRateLimitError`.
129
- */
130
- public async refresh(oauthArg: Record<string, unknown>): Promise<IClaudeTokens> {
134
+ private attemptForAuthority(refreshTokenArg: string, grantArg: IClaudeGrant, scopesArg: readonly string[]) {
135
+ return claudeRotatingRequest(this.fetcher, {
136
+ url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
137
+ body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshTokenArg,
138
+ client_id: grantArg.clientId, scope: grantScope(scopesArg) }),
139
+ timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
140
+ });
141
+ }
142
+
143
+ /** The authority must already have durably marked this grant as possibly sent. */
144
+ public async refreshForAuthority(oauthArg: Record<string, unknown>): Promise<TClaudeAuthorityRefreshResult> {
131
145
  const refreshToken = credentialText(oauthArg.refreshToken);
132
146
  const grant = claudeGrant(oauthArg);
133
147
  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.');
148
+ return { success: false, requestOutcome: 'notSent', reason: 'not_refreshable' };
135
149
  }
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));
150
+ let response: Awaited<ReturnType<typeof this.attemptForAuthority>>;
151
+ try {
152
+ response = await this.attemptForAuthority(refreshToken, grant, grant.scopes);
153
+ // A definitive invalid_scope response is the one safe condition in which Claude retries the same grant.
154
+ if (response.status === 400 && oauthErrorCode(response.body) === 'invalid_scope'
155
+ && grant.fallbackScopes !== null) {
156
+ response = await this.attemptForAuthority(refreshToken, grant, grant.fallbackScopes);
157
+ }
158
+ } catch (error) {
159
+ return { success: false,
160
+ requestOutcome: error instanceof ClaudeRotatingRequestError ? error.requestOutcome : 'outcomeUnknown',
161
+ reason: 'provider_unavailable' };
140
162
  }
141
- if (accountOnHold(status, body)) throw new ClaudeAccountOnHoldError();
142
- if ((status === 400 || status === 401) && oauthErrorCode(body) === 'invalid_grant') throw new ClaudeLoginRejectedError();
143
- if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
163
+ if (accountOnHold(response.status, response.body)) {
164
+ return { success: false, requestOutcome: 'outcomeUnknown', reason: 'account_on_hold',
165
+ responseStatus: response.status };
166
+ }
167
+ if ((response.status === 400 || response.status === 401)
168
+ && oauthErrorCode(response.body) === 'invalid_grant') {
169
+ return { success: false, requestOutcome: 'outcomeUnknown', reason: 'provider_rejected',
170
+ responseStatus: response.status };
171
+ }
172
+ if (response.status < 200 || response.status > 299) {
173
+ return { success: false, requestOutcome: 'outcomeUnknown', reason: 'provider_unavailable',
174
+ responseStatus: response.status };
175
+ }
176
+ try { return { success: true, tokens: this.tokensFromResponse(response.body, refreshToken) }; }
177
+ catch {
178
+ return { success: false, requestOutcome: 'outcomeUnknown', reason: 'unsupported_response',
179
+ responseStatus: response.status };
180
+ }
181
+ }
182
+
183
+ private tokensFromResponse(body: Record<string, unknown> | null, refreshToken: string): IClaudeTokens {
144
184
  const receivedAt = this.now();
145
185
  const accessToken = credentialText(body?.access_token);
146
186
  const tokenType = body?.token_type;
@@ -158,4 +198,27 @@ export class ClaudeTokenRefresh {
158
198
  ...(scope === null || !scope.length ? {} : { scopes: scope }),
159
199
  };
160
200
  }
201
+
202
+ /**
203
+ * New tokens for the login. A response without a refresh token keeps the one sent, and one without a scope keeps the
204
+ * stored scopes, as Claude Code merges them. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
205
+ * rejects is `ClaudeLoginRejectedError`, an account the service put on hold is `ClaudeAccountOnHoldError`, and a rate
206
+ * limit is `ClaudeRateLimitError`.
207
+ */
208
+ public async refresh(oauthArg: Record<string, unknown>): Promise<IClaudeTokens> {
209
+ const refreshToken = credentialText(oauthArg.refreshToken);
210
+ const grant = claudeGrant(oauthArg);
211
+ if (!refreshToken || !grant) {
212
+ 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.');
213
+ }
214
+ let { status, body } = await this.attempt(refreshToken, grant, grant.scopes);
215
+ // The service refusing Claude Code's current scope set is the one failure it sends a second request for.
216
+ if (status === 400 && oauthErrorCode(body) === 'invalid_scope' && grant.fallbackScopes !== null) {
217
+ ({ status, body } = await this.attempt(refreshToken, grant, grant.fallbackScopes));
218
+ }
219
+ if (accountOnHold(status, body)) throw new ClaudeAccountOnHoldError();
220
+ if ((status === 400 || status === 401) && oauthErrorCode(body) === 'invalid_grant') throw new ClaudeLoginRejectedError();
221
+ if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
222
+ return this.tokensFromResponse(body, refreshToken);
223
+ }
161
224
  }
@@ -69,6 +69,7 @@ export class CodexHarness implements IAuthHarness {
69
69
  const isActive = this.recordOf(entryArg, active.identity);
70
70
  return {
71
71
  id: entryArg.email,
72
+ providerAccountId: entryArg.accountId,
72
73
  label: entryArg.email,
73
74
  isActive,
74
75
  isStashed,
@@ -77,7 +78,8 @@ export class CodexHarness implements IAuthHarness {
77
78
  };
78
79
  });
79
80
  if (active.identity && !accounts.some((accountArg) => accountArg.isActive)) {
80
- accounts.unshift({ id: this.activeAccountId(active.identity.email, active.identity.accountId), label: active.identity.email, isActive: true,
81
+ accounts.unshift({ id: this.activeAccountId(active.identity.email, active.identity.accountId),
82
+ providerAccountId: active.identity.accountId, label: active.identity.email, isActive: true,
81
83
  isStashed: false, savedAt: null, details: [] });
82
84
  }
83
85
  return {
@@ -33,28 +33,91 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
33
33
  const isAlive = (child: plugins.childProcess.ChildProcess): boolean => child.exitCode === null && child.signalCode === null;
34
34
  const wait = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
35
35
 
36
- /** Same-user process inspection: a unique private --listen socket is the pre-PID crash identity. */
37
- export const findManagedCodexProcess = async (socketPath: string): Promise<{ pid: number; startedAt: string } | null> => {
38
- if (process.platform === 'win32') throw new Error('Managed Codex Unix socket process inspection is unavailable on Windows.');
36
+ export type TManagedCodexProcessObservation =
37
+ | { state: 'absent' }
38
+ | { state: 'observed'; pid: number; startedAt: string }
39
+ | { state: 'multiple_claimants' };
40
+
41
+ const linuxArgv = async (pid: number): Promise<Buffer[] | null> => {
42
+ let raw: Buffer;
43
+ try {
44
+ raw = await plugins.fs.promises.readFile(`/proc/${pid}/cmdline`);
45
+ } catch (error) {
46
+ if (isRecord(error) && error.code === 'ENOENT') return null;
47
+ throw new Error('Managed Codex process argument inspection failed.');
48
+ }
49
+ if (raw.length === 0) return [];
50
+ const argv: Buffer[] = [];
51
+ let start = 0;
52
+ for (let index = 0; index < raw.length; index++) {
53
+ if (raw[index] !== 0) continue;
54
+ argv.push(raw.subarray(start, index));
55
+ start = index + 1;
56
+ }
57
+ if (start < raw.length) argv.push(raw.subarray(start));
58
+ return argv;
59
+ };
60
+
61
+ const claimsManagedCodexSocket = (argv: readonly Buffer[], socketPath: string): boolean => {
62
+ const command = Buffer.from('app-server');
63
+ const option = Buffer.from('--listen');
64
+ const value = Buffer.from(`unix://${socketPath}`);
65
+ for (let index = 0; index + 2 < argv.length; index++) {
66
+ if (argv[index].equals(command) && argv[index + 1].equals(option) && argv[index + 2].equals(value)) return true;
67
+ }
68
+ return false;
69
+ };
70
+
71
+ /** Same-user Linux process inspection for a bounded socket set, using exact /proc argv boundaries. */
72
+ export const observeManagedCodexProcesses = async (
73
+ socketPaths: readonly string[],
74
+ ): Promise<Map<string, TManagedCodexProcessObservation>> => {
75
+ const unique = [...new Set(socketPaths)];
76
+ if (unique.some(value => typeof value !== 'string' || value.length === 0)) {
77
+ throw new Error('Managed Codex process selector is invalid.');
78
+ }
79
+ if (unique.length === 0) return new Map();
80
+ if (process.platform !== 'linux') {
81
+ throw new Error('Exact managed Codex process inspection is available only on Linux.');
82
+ }
83
+ const uid = process.getuid?.();
84
+ if (uid === undefined) throw new Error('Managed Codex process owner inspection is unavailable.');
39
85
  const output = await new Promise<string>((resolve, reject) => {
40
- plugins.childProcess.execFile('ps', ['-ww', '-u', String(process.getuid?.()), '-o', 'pid=,lstart=,args='],
86
+ plugins.childProcess.execFile('ps', ['-ww', '-u', String(uid), '-o', 'pid=,lstart='],
41
87
  { timeout: 5_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } },
42
88
  (error, stdout) => error ? reject(new Error('Managed Codex process inspection failed.')) : resolve(stdout));
43
89
  });
44
- const marker = `app-server --listen unix://${socketPath}`;
45
- const matches: Array<{ pid: number; startedAt: string }> = [];
90
+ const processes: Array<{ pid: number; startedAt: string; argv: Buffer[] }> = [];
46
91
  for (const line of output.split('\n')) {
47
92
  const parts = line.trim().split(/\s+/);
48
- if (parts.length < 8) continue;
93
+ if (parts.length !== 6) continue;
49
94
  const pid = Number(parts[0]);
50
95
  const date = Date.parse(parts.slice(1, 6).join(' '));
51
- const args = parts.slice(6).join(' ');
52
- if (Number.isSafeInteger(pid) && pid > 0 && Number.isFinite(date) && args.includes(marker)) {
53
- matches.push({ pid, startedAt: new Date(date).toISOString() });
96
+ if (Number.isSafeInteger(pid) && pid > 0 && Number.isFinite(date)) {
97
+ const argv = await linuxArgv(pid);
98
+ if (argv) processes.push({ pid, startedAt: new Date(date).toISOString(), argv });
54
99
  }
55
100
  }
56
- if (matches.length > 1) throw new Error('Multiple managed Codex processes claim one private socket.');
57
- return matches[0] ?? null;
101
+ const result = new Map<string, TManagedCodexProcessObservation>();
102
+ for (const socketPath of unique) {
103
+ const matches = processes.filter(process => claimsManagedCodexSocket(process.argv, socketPath));
104
+ result.set(socketPath, matches.length === 0
105
+ ? { state: 'absent' }
106
+ : matches.length === 1
107
+ ? { state: 'observed', pid: matches[0].pid, startedAt: matches[0].startedAt }
108
+ : { state: 'multiple_claimants' });
109
+ }
110
+ return result;
111
+ };
112
+
113
+ /** Same-user process inspection: a unique private --listen socket is the pre-PID crash identity. */
114
+ export const findManagedCodexProcess = async (socketPath: string): Promise<{ pid: number; startedAt: string } | null> => {
115
+ const observed = (await observeManagedCodexProcesses([socketPath])).get(socketPath);
116
+ if (!observed || observed.state === 'absent') return null;
117
+ if (observed.state === 'multiple_claimants') {
118
+ throw new Error('Multiple managed Codex processes claim one private socket.');
119
+ }
120
+ return { pid: observed.pid, startedAt: observed.startedAt };
58
121
  };
59
122
 
60
123
  /** Daemon-owned Codex app-server. Only access tokens, never refresh grants, enter Codex. */
@@ -3,6 +3,11 @@ import type { ICodexIdentity } from './interfaces.js';
3
3
  import type { IHarnessPreuseOptions, IHarnessPreuseResult } from './interfaces.harness.js';
4
4
  import { PreuseError, validatePreuseOptions } from './preuse.js';
5
5
 
6
+ export interface ICodexPreuseLifecycle {
7
+ /** Persist the may-have-started marker before the inference API is invoked. */
8
+ beforeRequest(model: string): Promise<void>;
9
+ }
10
+
6
11
  /** One tool-free request through the FlexHarness ChatGPT provider, with no credential writes. */
7
12
  export class CodexPreuse {
8
13
  constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
@@ -11,9 +16,6 @@ export class CodexPreuse {
11
16
  validatePreuseOptions(optionsArg);
12
17
  if (identityArg.kind !== 'chatgpt') throw new PreuseError('Codex preuse requires a ChatGPT subscription login; API-key logins have no subscription reset window.');
13
18
  if (!identityArg.accountId) throw new PreuseError('This login has no account ID for preuse.');
14
- const signal = optionsArg.signal
15
- ? AbortSignal.any([optionsArg.signal, AbortSignal.timeout(180_000)])
16
- : AbortSignal.timeout(180_000);
17
19
  let credentials: plugins.flexAuth.IOpenAiChatGptAuthCredentials;
18
20
  let oauth: plugins.flexAccounts.IOpenAiChatGptOAuthCredential;
19
21
  try {
@@ -24,12 +26,37 @@ export class CodexPreuse {
24
26
  } catch {
25
27
  throw new PreuseError('No complete, matching Codex login is available for preuse. Log in again with Codex and save the account.');
26
28
  }
29
+ return this.runCredential(oauth, credentials, optionsArg);
30
+ }
31
+
32
+ /** Authority-owned access grants contain no refresh token and are never serialized by this helper. */
33
+ public async runAccess(credentialArg: plugins.flexAccounts.IOpenAiChatGptAccessCredential,
34
+ optionsArg: IHarnessPreuseOptions, lifecycleArg: ICodexPreuseLifecycle): Promise<IHarnessPreuseResult> {
35
+ validatePreuseOptions(optionsArg);
36
+ if (!credentialArg || credentialArg.kind !== 'chatgptAccess' || credentialArg.providerId !== 'openai'
37
+ || typeof credentialArg.accessToken !== 'string' || !credentialArg.accessToken
38
+ || typeof credentialArg.accountId !== 'string' || !credentialArg.accountId
39
+ || typeof credentialArg.isFedrampAccount !== 'boolean') {
40
+ throw new PreuseError('No complete, matching Codex login is available for preuse. Log in again with Codex and save the account.');
41
+ }
42
+ return this.runCredential(credentialArg, {
43
+ accessToken: credentialArg.accessToken, accountId: credentialArg.accountId,
44
+ isFedrampAccount: credentialArg.isFedrampAccount,
45
+ }, optionsArg, lifecycleArg);
46
+ }
47
+
48
+ private async runCredential(readCredentialArg: plugins.flexAccounts.TOpenAiChatGptReadCredential,
49
+ credentialsArg: plugins.flexAuth.IOpenAiChatGptAuthCredentials, optionsArg: IHarnessPreuseOptions,
50
+ lifecycleArg?: ICodexPreuseLifecycle): Promise<IHarnessPreuseResult> {
51
+ const signal = optionsArg.signal
52
+ ? AbortSignal.any([optionsArg.signal, AbortSignal.timeout(180_000)])
53
+ : AbortSignal.timeout(180_000);
27
54
  let model = optionsArg.model;
28
55
  let reasoningEffort: string | undefined;
29
56
  if (!model) {
30
57
  const adapter = new plugins.flexAccounts.OpenAiProviderAdapter({ fetch: this.fetcher });
31
58
  try {
32
- const catalog = await adapter.listModels(oauth, { signal, timeoutMs: 10_000 });
59
+ const catalog = await adapter.listModels(readCredentialArg, { signal, timeoutMs: 10_000 });
33
60
  if (!catalog.success) throw new PreuseError(catalog.errorCode === 'CREDENTIAL_REFRESH_REQUIRED'
34
61
  ? 'The saved login has expired. Log in again with Codex and save it before preuse.'
35
62
  : 'Could not read the account model catalog. No preuse prompt was sent.');
@@ -45,9 +72,10 @@ export class CodexPreuse {
45
72
  let requestStarted = false;
46
73
  try {
47
74
  signal.throwIfAborted();
48
- const connection = plugins.flexAuth.createOpenAiChatGptModelConnection(credentials);
75
+ const connection = plugins.flexAuth.createOpenAiChatGptModelConnection(credentialsArg);
49
76
  connection.settings.fetch = this.fetcher;
50
77
  const languageModel = plugins.flexOpenAi.createOpenAiModelProvider().getModel({ provider: 'openai', model, connection });
78
+ await lifecycleArg?.beforeRequest(model);
51
79
  requestStarted = true;
52
80
  const stream = plugins.flexModels.streamText({
53
81
  model: languageModel,
@@ -6,6 +6,8 @@ import { plainText } from './formatting.js';
6
6
  export interface IFileAccount {
7
7
  slotId: string;
8
8
  identity: string;
9
+ /** Exact provider-native account identifier when the credential carries one. */
10
+ providerAccountId: string | null;
9
11
  label: string;
10
12
  credential: Record<string, unknown>;
11
13
  }
@@ -80,14 +82,16 @@ export abstract class FileHarness implements IAuthHarness {
80
82
  const snapshot = this.snapshot();
81
83
  const saved = this.store.list();
82
84
  const accounts: IHarnessState['accounts'] = saved.map(entry => {
83
- this.saved(entry);
84
- return { id: entry.id, slotId: entry.slotId, label: entry.label, isActive: false, isStashed: true, savedAt: entry.savedAt, details: [`Provider: ${entry.slotId}`] };
85
+ const account = this.saved(entry);
86
+ return { id: entry.id, providerAccountId: account.providerAccountId, slotId: entry.slotId, label: entry.label,
87
+ isActive: false, isStashed: true, savedAt: entry.savedAt, details: [`Provider: ${entry.slotId}`] };
85
88
  });
86
89
  for (const active of snapshot.accounts) {
87
90
  const id = this.store.id(active.slotId, active.identity);
88
91
  const existing = accounts.find(account => account.id === id);
89
92
  if (existing) { existing.isActive = true; existing.label = active.label; }
90
- else accounts.unshift({ id, slotId: active.slotId, label: active.label, isActive: true, isStashed: false, savedAt: null, details: [`Provider: ${active.slotId}`] });
93
+ else accounts.unshift({ id, providerAccountId: active.providerAccountId, slotId: active.slotId, label: active.label,
94
+ isActive: true, isStashed: false, savedAt: null, details: [`Provider: ${active.slotId}`] });
91
95
  }
92
96
  for (const account of accounts) {
93
97
  if (accounts.some(other => other.id !== account.id && other.label === account.label)) account.details.push(`Account ID: ${account.id}`);
@@ -52,6 +52,7 @@ const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount
52
52
  harnessId: harnessArg.id,
53
53
  provider: providerName(harnessArg, accountArg),
54
54
  account: plainText(accountArg.label),
55
+ providerAccountId: accountArg.providerAccountId,
55
56
  accountId: accountArg.id,
56
57
  ...(accountArg.slotId === undefined ? {} : { slotId: plainText(accountArg.slotId) }),
57
58
  });
@@ -164,11 +165,13 @@ export const accountLimits = (listArg: IAccountList, harnessesArg: readonly THar
164
165
  limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE, severity: null, headline: false,
165
166
  } as const;
166
167
  if (problem !== null) {
167
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, billing: null, ...empty, unavailableReason: problem });
168
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, providerAccountId: null,
169
+ accountId: null, isActive: false, accountType: null, billing: null, ...empty, unavailableReason: problem });
168
170
  continue;
169
171
  }
170
172
  if (!harness.accounts.length) {
171
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, billing: null, ...empty,
173
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, providerAccountId: null,
174
+ accountId: null, isActive: false, accountType: null, billing: null, ...empty,
172
175
  unavailableReason: harness.saveUnavailableReason === null ? 'No accounts are known for this harness.' : plainText(harness.saveUnavailableReason) });
173
176
  continue;
174
177
  }
@@ -192,7 +195,7 @@ export const accountLimits = (listArg: IAccountList, harnessesArg: readonly THar
192
195
  }
193
196
  }
194
197
  const limits = byProviderThenAccount(rows);
195
- return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, limits, notes: [...collectNotes(limits), ...renewalNotes] };
198
+ return { schemaVersion: 2, generatedAt: listArg.generatedAt, complete: listArg.complete, limits, notes: [...collectNotes(limits), ...renewalNotes] };
196
199
  };
197
200
 
198
201
  /**
@@ -214,14 +217,16 @@ export const activeAccounts = (listArg: IAccountList, harnessesArg: readonly THa
214
217
  return index < 0 ? null : activeDrift(traits, drifts.splice(index, 1)[0]);
215
218
  };
216
219
  if (problem !== null) {
217
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, accountType: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
220
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, providerAccountId: null,
221
+ accountId: null, accountType: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
218
222
  continue;
219
223
  }
220
224
  const active = harness.accounts.filter(account => account.isActive);
221
225
  if (!active.length) {
222
226
  const stashed = harness.accounts.filter(account => account.isStashed);
223
227
  rows.push({
224
- harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, accountType: null,
228
+ harnessId: harness.id, provider: providerName(harness), account: null, providerAccountId: null,
229
+ accountId: null, accountType: null,
225
230
  savedAt: null, savedAgo: UNAVAILABLE, source: stashed.length ? 'stash only' : 'none',
226
231
  unavailableReason: harness.saveUnavailableReason !== null ? plainText(harness.saveUnavailableReason)
227
232
  : stashed.length ? `No login is active; ${stashed.length} saved account(s) can be activated with authswitch ${plainText(harness.id)} use.`
@@ -245,7 +250,7 @@ export const activeAccounts = (listArg: IAccountList, harnessesArg: readonly THa
245
250
  const active = byProviderThenAccount(rows);
246
251
  const driftNotes = active.filter(row => row.drift !== null)
247
252
  .map(row => `${row.provider}${row.account === null ? '' : ` · ${row.account}`}: ${row.drift!.reason}`);
248
- return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, active, notes: [...collectNotes(active), ...driftNotes, ...unattached] };
253
+ return { schemaVersion: 2, generatedAt: listArg.generatedAt, complete: listArg.complete, active, notes: [...collectNotes(active), ...driftNotes, ...unattached] };
249
254
  };
250
255
 
251
256
  /**
@@ -100,7 +100,7 @@ export class OpenCodeHarness extends FileHarness {
100
100
  const fingerprint = credentialHash(secret);
101
101
  const identity = info.accountId ? `oauth:${info.accountId}` : `${type}:${fingerprint}`;
102
102
  const label = info.email ?? (info.accountId ? `openai:${info.accountId}` : `${slotId} ${type === 'api' ? 'API key' : type} ${fingerprint.slice(0, 12)}`);
103
- return { slotId, identity, label, credential };
103
+ return { slotId, identity, providerAccountId: info.accountId ?? null, label, credential };
104
104
  }
105
105
  protected snapshot(): IFileHarnessSnapshot {
106
106
  if (this.env.OPENCODE_AUTH_CONTENT !== undefined) return { accounts: [], unavailable: 'OPENCODE_AUTH_CONTENT overrides the credential file. Unset it before managing file logins.' };
package/ts/claudehttp.ts CHANGED
@@ -1,10 +1,17 @@
1
1
  import { commitinfo } from './00_commitinfo_data.js';
2
- import { credentialRecord } from './classes.credentialstore.js';
3
2
  import { retryAtFrom } from './ratelimit.js';
4
3
 
5
4
  /** A fixed, local diagnostic. Nothing a service sent (a body, a header) and no request header ever becomes one. */
6
5
  export class ClaudeRequestError extends Error {}
7
6
 
7
+ /** A rotating refresh POST is never assumed unsent after fetch has been invoked. */
8
+ export type TClaudeRotatingRequestOutcome = 'notSent' | 'outcomeUnknown';
9
+ export class ClaudeRotatingRequestError extends ClaudeRequestError {
10
+ constructor(message: string, public readonly requestOutcome: TClaudeRotatingRequestOutcome) {
11
+ super(message);
12
+ }
13
+ }
14
+
8
15
  /** How every Claude lookup reports a login the service does not accept (HTTP 401, or a refresh grant it rejected). */
9
16
  export const CLAUDE_LOGIN_REJECTED = 'Login expired or was rejected. Log in again with Claude Code and save it.';
10
17
 
@@ -80,7 +87,11 @@ const jsonBody = async (responseArg: Response, subjectArg: string): Promise<Reco
80
87
  chunks.push(part.value);
81
88
  }
82
89
  } finally { reader.releaseLock(); }
83
- try { return credentialRecord(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
90
+ try {
91
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'));
92
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
93
+ ? parsed as Record<string, unknown> : null;
94
+ }
84
95
  catch { return null; }
85
96
  };
86
97
 
@@ -112,3 +123,21 @@ export const claudeRequest = async (fetcherArg: typeof fetch, requestArg: IClaud
112
123
  throw new ClaudeRequestError(`The ${requestArg.subject} service could not be reached.`);
113
124
  }
114
125
  };
126
+
127
+ /** Use only after the authority has durably committed its pre-send attempt marker. */
128
+ export const claudeRotatingRequest = async (fetcherArg: typeof fetch,
129
+ requestArg: IClaudeRequest & { method: 'POST' }): Promise<IClaudeResponse> => {
130
+ if (requestArg.signal?.aborted) {
131
+ throw new ClaudeRotatingRequestError('The Claude sign-in request was cancelled before sending.', 'notSent');
132
+ }
133
+ let invoked = false;
134
+ const observedFetch: typeof fetch = (input, init) => {
135
+ invoked = true;
136
+ return fetcherArg(input, init);
137
+ };
138
+ try { return await claudeRequest(observedFetch, requestArg); }
139
+ catch (error) {
140
+ const message = error instanceof ClaudeRequestError ? error.message : 'The Claude sign-in request failed.';
141
+ throw new ClaudeRotatingRequestError(message, invoked ? 'outcomeUnknown' : 'notSent');
142
+ }
143
+ };
package/ts/index.ts CHANGED
@@ -24,13 +24,19 @@ export * from './classes.watch.js';
24
24
  export * from './classes.watchlock.js';
25
25
  export * from './watchpolicy.js';
26
26
  export * from './classes.authorityclient.js';
27
+ export * from './classes.authoritycli.js';
27
28
  export * from './classes.authoritydaemon.js';
28
29
  export * from './classes.authorityservice.js';
29
30
 
30
31
  import { AuthSwitchCli } from './classes.cli.js';
32
+ import { runAuthSwitchAuthorityCli } from './classes.authoritycli.js';
31
33
  import { AuthSwitchAuthorityService, resolveAuthSwitchAuthorityPaths, runAuthSwitchAuthorityDaemon } from './classes.authorityservice.js';
32
34
 
33
35
  export const runCli = async (argvArg: string[] = process.argv.slice(2)): Promise<void> => {
36
+ if (argvArg[0] === 'account' || (argvArg[0] === 'authority' && argvArg[1] === 'doctor')) {
37
+ process.exitCode = await runAuthSwitchAuthorityCli(argvArg);
38
+ return;
39
+ }
34
40
  if (argvArg[0] === 'authority') {
35
41
  const paths = resolveAuthSwitchAuthorityPaths();
36
42
  if (argvArg[1] === 'daemon' && argvArg.length === 2) {
@@ -73,7 +73,13 @@ export interface IHarnessCredentialDrift {
73
73
 
74
74
  /** Account IDs are opaque and scoped to one harness. Credentials never cross this interface. */
75
75
  export interface IHarnessAccount {
76
+ /** Opaque authswitch identifier used for harness operations. */
76
77
  id: string;
78
+ /**
79
+ * Stable account identifier reported by the provider, scoped by this harness and `slotId`.
80
+ * Null means the credential carries no exact provider identity. Never infer it from `id` or `label`.
81
+ */
82
+ providerAccountId: string | null;
77
83
  label: string;
78
84
  /** Accounts in different slots can be active together (for example OpenCode providers). */
79
85
  slotId?: string;