@modelprofile.com/authswitch 5.2.0 → 6.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 +3 -3
- package/dist_ts/accounts.js +3 -2
- package/dist_ts/classes.claudecodeharness.d.ts +13 -4
- package/dist_ts/classes.claudecodeharness.js +36 -11
- package/dist_ts/classes.claudestatus.d.ts +25 -5
- package/dist_ts/classes.claudestatus.js +25 -20
- package/dist_ts/classes.claudetokenrefresh.d.ts +38 -3
- package/dist_ts/classes.claudetokenrefresh.js +80 -21
- package/dist_ts/classes.cli.js +3 -3
- package/dist_ts/classes.credentialstore.d.ts +3 -1
- package/dist_ts/classes.credentialstore.js +2 -2
- package/dist_ts/classes.fileharness.d.ts +16 -5
- package/dist_ts/classes.fileharness.js +31 -9
- package/dist_ts/classes.opencodeanthropicrefresh.d.ts +29 -0
- package/dist_ts/classes.opencodeanthropicrefresh.js +59 -0
- package/dist_ts/classes.opencodeharness.d.ts +40 -0
- package/dist_ts/classes.opencodeharness.js +100 -3
- package/dist_ts/claudehttp.d.ts +14 -0
- package/dist_ts/claudehttp.js +21 -1
- package/dist_ts/interfaces.harness.d.ts +7 -3
- package/package.json +1 -1
- package/readme.md +89 -23
- package/ts/00_commitinfo_data.ts +3 -3
- package/ts/accounts.ts +2 -1
- package/ts/classes.claudecodeharness.ts +37 -12
- package/ts/classes.claudestatus.ts +39 -19
- package/ts/classes.claudetokenrefresh.ts +96 -20
- package/ts/classes.cli.ts +3 -3
- package/ts/classes.credentialstore.ts +2 -2
- package/ts/classes.fileharness.ts +28 -8
- package/ts/classes.opencodeanthropicrefresh.ts +64 -0
- package/ts/classes.opencodeharness.ts +97 -2
- package/ts/claudehttp.ts +22 -0
- package/ts/interfaces.harness.ts +7 -3
|
@@ -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
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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(
|
|
143
|
-
const lookup = () => this.lookupProfile(
|
|
144
|
-
return optionsArg.session ? optionsArg.session.reuse(`claude:profile:${credentialHash(
|
|
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(
|
|
166
|
+
private async lookupProfile(loginArg: IClaudeLogin, signal?: AbortSignal): Promise<TClaudeProfile> {
|
|
149
167
|
let body: Record<string, unknown>;
|
|
150
|
-
try { body = await this.get('profile',
|
|
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
|
-
|
|
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(
|
|
169
|
-
|
|
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(
|
|
175
|
-
return { facts: [], problems: [problem], ...this.storedSummary(
|
|
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(
|
|
179
|
-
const result: IHarnessAccountStatus = { facts: [], problems: [], ...this.storedSummary(
|
|
180
|
-
|
|
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(
|
|
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(
|
|
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 @
|
|
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`, @
|
|
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`, @
|
|
9
|
-
const
|
|
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
|
-
*
|
|
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() +
|
|
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`,
|
|
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
|
|
55
|
-
if (!refreshToken || !
|
|
56
|
-
throw new ClaudeRequestError('This saved login has no refresh token or
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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);
|
package/ts/classes.cli.ts
CHANGED
|
@@ -108,9 +108,9 @@ ${bold('Options')}
|
|
|
108
108
|
|
|
109
109
|
A switch works while OpenCode or Claude Code is running. Claude Code picks the new login
|
|
110
110
|
up on its next request, so its sessions are stopped only on --stop or --force-stop.
|
|
111
|
-
OpenCode
|
|
112
|
-
|
|
113
|
-
it running and says so.
|
|
111
|
+
OpenCode caches no login either, but a request already in flight can write the previous
|
|
112
|
+
login's own rotation back, so an OpenCode switch offers to stop it first; without a
|
|
113
|
+
terminal it leaves it running and says so.
|
|
114
114
|
|
|
115
115
|
Watch covers the harnesses that support automatic switching (${[...this.harnesses.values()].filter(harness => harness.autoSwitch === true).map(harness => harness.id).join(', ') || 'none registered'}).
|
|
116
116
|
--interval takes seconds or a unit (120, 90s, 2m; from 1m to 1d); --threshold takes 50 to 100.
|
|
@@ -164,8 +164,8 @@ export class CredentialStore {
|
|
|
164
164
|
&& typeof record.switchedAt === 'string' && Number.isFinite(Date.parse(record.switchedAt));
|
|
165
165
|
});
|
|
166
166
|
}
|
|
167
|
-
public recordSwitch(entry: Omit<ISwitchRecord, 'switchedAt'>): void {
|
|
168
|
-
this.writeSwitchRecords([...this.switchRecords().filter(item => item.slotId !== entry.slotId), { ...entry, switchedAt: new Date().toISOString() }]);
|
|
167
|
+
public recordSwitch(entry: Omit<ISwitchRecord, 'switchedAt'> & { switchedAt?: string }): void {
|
|
168
|
+
this.writeSwitchRecords([...this.switchRecords().filter(item => item.slotId !== entry.slotId), { ...entry, switchedAt: entry.switchedAt ?? new Date().toISOString() }]);
|
|
169
169
|
}
|
|
170
170
|
public clearSwitch(slotId: string): void {
|
|
171
171
|
const remaining = this.switchRecords().filter(item => item.slotId !== slotId);
|
|
@@ -99,9 +99,10 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
99
99
|
* Credential slots that no longer hold the account the last switch wrote.
|
|
100
100
|
*
|
|
101
101
|
* The login can change outside authswitch -- a new native login, or, for a harness that does not pick
|
|
102
|
-
* up swaps live, a
|
|
102
|
+
* up swaps live, a request already in flight writing the previous login's own rotation back. The recorded
|
|
103
103
|
* hash detects that the file changed at all; comparing the account distinguishes the benign case -- the
|
|
104
|
-
* switched-in account rotating its own token -- from a different account.
|
|
104
|
+
* switched-in account rotating its own token -- from a different account. A rotation authswitch performed
|
|
105
|
+
* itself is not drift at all: `recordRenewal` moves the record onto it. Detection is advisory and
|
|
105
106
|
* never turns a read into a failure.
|
|
106
107
|
*/
|
|
107
108
|
private credentialDrift(snapshotArg: IFileHarnessSnapshot): IHarnessCredentialDrift[] {
|
|
@@ -127,14 +128,18 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
127
128
|
private savedLabel(accountIdArg: string): string | null {
|
|
128
129
|
try { return this.store.read(accountIdArg).label; } catch { return null; }
|
|
129
130
|
}
|
|
130
|
-
/**
|
|
131
|
+
/** How a running instance still holds the previous login and can write it back; stated per harness. */
|
|
132
|
+
protected runningRetention(): string {
|
|
133
|
+
return 'They keep the previous login in memory until they are restarted, and a token refresh by one of them can ';
|
|
134
|
+
}
|
|
135
|
+
/** Instances that can still write a replaced or cleared login back; none for a live-swap harness. */
|
|
131
136
|
private runningCaveat(kindArg: 'replaced' | 'cleared'): string[] {
|
|
132
137
|
if (this.liveSwap) return [];
|
|
133
138
|
let count: number;
|
|
134
139
|
try { count = this.processes.list().length; }
|
|
135
140
|
catch { return [`Could not check for running ${this.label} processes. If any are running, restart them: an unrestarted instance can write its previous login back.`]; }
|
|
136
141
|
if (!count) return [];
|
|
137
|
-
return [`${count} ${this.label} process(es) are still running.
|
|
142
|
+
return [`${count} ${this.label} process(es) are still running. ${this.runningRetention()}`
|
|
138
143
|
+ `${kindArg === 'replaced' ? 'overwrite the credential file with the previous account' : 'write the cleared login back into the credential file'}. `
|
|
139
144
|
+ `Restart them, then check authswitch ${this.id} active.`];
|
|
140
145
|
}
|
|
@@ -154,6 +159,25 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
154
159
|
return [];
|
|
155
160
|
} catch { return [`The login was written, but this switch could not be recorded for later credential checks.`]; }
|
|
156
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Let a slot's switch record follow a credential authswitch itself renewed in place.
|
|
164
|
+
*
|
|
165
|
+
* The record must name what authswitch last wrote, or the next read reports authswitch's own rotation as a
|
|
166
|
+
* login someone else put there -- an account whose identity is its refresh token changes id on every rotation.
|
|
167
|
+
* A slot no switch armed keeps no record: a renewal never starts watching one. The record is advisory, so an
|
|
168
|
+
* update that fails costs at most the drift note the next read would have shown anyway; a status read has
|
|
169
|
+
* nowhere to report it.
|
|
170
|
+
*/
|
|
171
|
+
protected recordRenewal(slotIdArg: string): void {
|
|
172
|
+
try {
|
|
173
|
+
const record = this.store.switchRecords().find(item => item.slotId === slotIdArg);
|
|
174
|
+
const active = this.snapshot().accounts.find(account => account.slotId === slotIdArg);
|
|
175
|
+
if (!record || !active) return;
|
|
176
|
+
// The switch time stays the switch time: a renewal changes what the record names, not when the login was put there.
|
|
177
|
+
this.store.recordSwitch({ slotId: slotIdArg, accountId: this.store.id(active.slotId, active.identity),
|
|
178
|
+
credentialHash: credentialHash(JSON.stringify(active.credential)), switchedAt: record.switchedAt });
|
|
179
|
+
} catch { /* Advisory state only. */ }
|
|
180
|
+
}
|
|
157
181
|
/** The active login with this account id, read from the native files; undefined when the account is not active. */
|
|
158
182
|
protected activeCredential(id: string): IFileAccount | undefined {
|
|
159
183
|
return this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
|
|
@@ -162,10 +186,6 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
162
186
|
protected savedCredential(id: string): IFileAccount {
|
|
163
187
|
return this.saved(this.store.read(id));
|
|
164
188
|
}
|
|
165
|
-
/** The account's current credential: the active login when it is active, which may be newer than its saved copy. */
|
|
166
|
-
protected readCredential(id: string): IFileAccount {
|
|
167
|
-
return this.activeCredential(id) ?? this.savedCredential(id);
|
|
168
|
-
}
|
|
169
189
|
/** Save a newly authenticated credential without changing a native harness's login. */
|
|
170
190
|
public importCredential(slotId: string, credential: Record<string, unknown>): Promise<{ accountId: string }> {
|
|
171
191
|
return this.store.locked(() => ({ accountId: this.store.save(this.inspect(credential, slotId)).id }));
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { credentialText } from './classes.credentialstore.js';
|
|
2
|
+
import { claudeRequest, ClaudeLoginRejectedError, ClaudeRequestError, oauthErrorCode } from './claudehttp.js';
|
|
3
|
+
|
|
4
|
+
/** The token endpoint `opencode-anthropic-auth` 0.0.13 renews an OpenCode `anthropic` login at (`index.mjs`). */
|
|
5
|
+
const TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
|
|
6
|
+
/** The OAuth client that plugin sends, Claude Code's public one (`CLIENT_ID`, `index.mjs`). */
|
|
7
|
+
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
8
|
+
/**
|
|
9
|
+
* How long one renewal may take. A renewal is never cut shorter than that, nor cancelled once sent: the service may
|
|
10
|
+
* already have replaced the refresh token the request carries.
|
|
11
|
+
*/
|
|
12
|
+
const REFRESH_TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
/** The token fields OpenCode keeps for an OAuth login, as one renewal replaces them. */
|
|
15
|
+
export interface IOpenCodeAnthropicTokens {
|
|
16
|
+
access: string;
|
|
17
|
+
refresh: string;
|
|
18
|
+
/** Milliseconds since the epoch, as OpenCode stores them. */
|
|
19
|
+
expires: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const seconds = (valueArg: unknown): number | undefined =>
|
|
23
|
+
typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0 ? valueArg : undefined;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Renews an OpenCode `anthropic` OAuth login with the request OpenCode's own Anthropic provider sends for it.
|
|
27
|
+
*
|
|
28
|
+
* `opencode-anthropic-auth` 0.0.13 -- the plugin that gives OpenCode 1.18.x its `anthropic` OAuth provider; the
|
|
29
|
+
* binary carries no Anthropic OAuth code of its own -- posts a JSON body of `grant_type`, `refresh_token` and
|
|
30
|
+
* Claude Code's public client to `console.anthropic.com`, with no scope, and stores `{access, refresh, expires}`
|
|
31
|
+
* from the answer. This sends exactly that request, so a login renewed here is renewed the way OpenCode renews it.
|
|
32
|
+
*
|
|
33
|
+
* It only talks to the token endpoint; which login is renewed, and where the rotated pair is written, belongs to
|
|
34
|
+
* the harness.
|
|
35
|
+
*/
|
|
36
|
+
export class OpenCodeAnthropicRefresh {
|
|
37
|
+
constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* New tokens for the login. A response without a refresh token keeps the one that was sent, as the service's own
|
|
41
|
+
* answer to a grant it did not rotate. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
|
|
42
|
+
* rejects is `ClaudeLoginRejectedError`, and a rate limit is `ClaudeRateLimitError`.
|
|
43
|
+
*/
|
|
44
|
+
public async refresh(refreshTokenArg: string): Promise<IOpenCodeAnthropicTokens> {
|
|
45
|
+
const { status, body } = await claudeRequest(this.fetcher, {
|
|
46
|
+
url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
|
|
47
|
+
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshTokenArg, client_id: CLIENT_ID }),
|
|
48
|
+
timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
|
|
49
|
+
});
|
|
50
|
+
if ((status === 400 || status === 401) && oauthErrorCode(body) === 'invalid_grant') throw new ClaudeLoginRejectedError();
|
|
51
|
+
if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
|
|
52
|
+
const receivedAt = this.now();
|
|
53
|
+
const access = credentialText(body?.access_token);
|
|
54
|
+
const tokenType = body?.token_type;
|
|
55
|
+
const expiresIn = seconds(body?.expires_in);
|
|
56
|
+
const rotated = body?.refresh_token == null ? null : credentialText(body.refresh_token);
|
|
57
|
+
// Only a response every field of the stored entry can be taken from is written back into a file OpenCode owns.
|
|
58
|
+
if (!access || (tokenType != null && (typeof tokenType !== 'string' || tokenType.toLowerCase() !== 'bearer'))
|
|
59
|
+
|| expiresIn === undefined || rotated === undefined) {
|
|
60
|
+
throw new ClaudeRequestError('The Claude sign-in service returned an unsupported token response; the stored login was not changed.');
|
|
61
|
+
}
|
|
62
|
+
return { access, refresh: rotated ?? refreshTokenArg, expires: receivedAt + expiresIn * 1000 };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -2,7 +2,11 @@ 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';
|
|
7
|
+
import { ClaudeLoginRejectedError, ClaudeRateLimitError } from './claudehttp.js';
|
|
5
8
|
import { CodexAccountStatus } from './classes.codexstatus.js';
|
|
9
|
+
import { OpenCodeAnthropicRefresh } from './classes.opencodeanthropicrefresh.js';
|
|
6
10
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
7
11
|
import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
|
|
8
12
|
import { beginSavedOpenAiLogin, openCodeCredentialFromLogin } from './classes.login.js';
|
|
@@ -15,8 +19,15 @@ export interface IOpenCodeHarnessOptions {
|
|
|
15
19
|
fetch?: typeof fetch;
|
|
16
20
|
/** Running-instance inspection, injectable for isolated installations and tests. */
|
|
17
21
|
processes?: IHarnessProcessControl;
|
|
22
|
+
/** The clock that decides whether a provider's stored access token is still usable; `Date.now` by default. */
|
|
23
|
+
now?: () => number;
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
/** Anthropic refused the grant OpenCode's entry holds; no retry and no other account changes that answer. */
|
|
27
|
+
const ANTHROPIC_LOGIN_REJECTED = 'Login expired or was rejected. Log in again with opencode auth login.';
|
|
28
|
+
/** A saved copy is not the login OpenCode uses, so renewing it would rotate a token OpenCode's own entry still holds. */
|
|
29
|
+
const SAVED_ANTHROPIC_EXPIRED = 'This saved Anthropic login\'s access token has expired. authswitch renews the Anthropic login OpenCode has active; activate this one to have it renewed, or log in again with opencode auth login.';
|
|
30
|
+
|
|
20
31
|
/** OpenCode owns one login per provider. Replacing a slot never replaces the whole provider set. */
|
|
21
32
|
export class OpenCodeHarness extends FileHarness {
|
|
22
33
|
public readonly id = 'opencode';
|
|
@@ -26,13 +37,20 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
26
37
|
public readonly liveSwap = false;
|
|
27
38
|
/** Only its OpenAI login reports usage, and a supervisor (AGL) restarts it around a switch, so a watch leaves it alone. */
|
|
28
39
|
public readonly autoSwitch = false;
|
|
40
|
+
/** OpenCode re-reads its credential file on every request; only a request already in flight still holds the previous login. */
|
|
41
|
+
protected override runningRetention(): string {
|
|
42
|
+
return 'They re-read the credential file on every request, but a request already in flight still holds the previous login, and its token refresh can ';
|
|
43
|
+
}
|
|
29
44
|
public readonly loginProviders: IHarnessLoginProvider[] = [{ providerId: 'openai', label: 'OpenAI', flows: ['device'] }];
|
|
30
45
|
protected readonly store: CredentialStore;
|
|
31
46
|
private readonly file: string;
|
|
32
47
|
private readonly env: NodeJS.ProcessEnv;
|
|
33
48
|
private readonly status: CodexAccountStatus;
|
|
49
|
+
private readonly claudeStatus: ClaudeAccountStatus;
|
|
50
|
+
private readonly anthropicRefresh: OpenCodeAnthropicRefresh;
|
|
34
51
|
public readonly processes: IHarnessProcessControl;
|
|
35
52
|
private readonly fetcher?: typeof fetch;
|
|
53
|
+
private readonly now: () => number;
|
|
36
54
|
|
|
37
55
|
constructor(options: IOpenCodeHarnessOptions = {}) {
|
|
38
56
|
super();
|
|
@@ -40,7 +58,10 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
40
58
|
this.file = plugins.path.join(options.dataHome ?? (this.env.XDG_DATA_HOME || plugins.path.join(plugins.os.homedir(), '.local', 'share')), 'opencode', 'auth.json');
|
|
41
59
|
this.store = new CredentialStore(this.id, options.stashRoot);
|
|
42
60
|
this.status = new CodexAccountStatus(options.fetch);
|
|
61
|
+
this.claudeStatus = new ClaudeAccountStatus(options.fetch, options.now);
|
|
62
|
+
this.anthropicRefresh = new OpenCodeAnthropicRefresh(options.fetch, options.now);
|
|
43
63
|
this.fetcher = options.fetch;
|
|
64
|
+
this.now = options.now ?? Date.now;
|
|
44
65
|
this.processes = options.processes ?? new HarnessProcesses('opencode');
|
|
45
66
|
}
|
|
46
67
|
/** Every provider entry lives in one file, so its bytes are the whole stability proof. */
|
|
@@ -104,13 +125,87 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
104
125
|
writeSecretFileAtomically(this.file, raw);
|
|
105
126
|
if (readCredentialDocument(this.file).raw !== raw) throw new Error('Active login verification failed. The outgoing login remains saved.');
|
|
106
127
|
}
|
|
107
|
-
|
|
108
|
-
|
|
128
|
+
/** The login OpenCode has in a provider slot, read from its file; undefined where the file holds none it can read. */
|
|
129
|
+
private activeSlot(slotIdArg: string): IFileAccount | undefined {
|
|
130
|
+
return this.snapshot().accounts.find(account => account.slotId === slotIdArg);
|
|
131
|
+
}
|
|
132
|
+
/** Whether this login's access token is inside Claude Code's five-minute margin, the margin Claude's own logins use. */
|
|
133
|
+
private renewalDue(credentialArg: Record<string, unknown>): boolean {
|
|
134
|
+
const expires = credentialArg.expires;
|
|
135
|
+
return typeof expires === 'number' && Number.isFinite(expires) && this.now() + CLAUDE_REFRESH_MARGIN_MS >= expires;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Renews OpenCode's active Anthropic login and writes the rotated pair back into OpenCode's own credential file.
|
|
140
|
+
*
|
|
141
|
+
* Nothing is stranded by that write. OpenCode's auth service reads `auth.json` on every `Auth.all()`, `Auth.get()`
|
|
142
|
+
* is that read (1.18.15 @99100961, 1.18.31 @103733929), and its Anthropic provider -- `opencode-anthropic-auth`
|
|
143
|
+
* 0.0.13, since the binary carries no Anthropic OAuth code -- calls the `getAuth()` it was initialised with
|
|
144
|
+
* (@98017331) inside the `fetch` wrapper of every single request. A running OpenCode therefore holds no refresh
|
|
145
|
+
* token in memory and picks up this rotation on its next request, exactly as it picks up its own.
|
|
146
|
+
*
|
|
147
|
+
* The renewal is the only authswitch operation on the store while it runs, and the rotated pair replaces the
|
|
148
|
+
* entry only while OpenCode still holds the refresh token that was sent: a login OpenCode renewed meanwhile is
|
|
149
|
+
* returned as it now stands. The write itself is `apply`'s verified compare-and-swap, which preserves every other
|
|
150
|
+
* provider and replaces the file atomically -- OpenCode writes the same file in place. The slot's switch record
|
|
151
|
+
* follows that write, because an `anthropic` login's identity is its refresh token and this rotation is
|
|
152
|
+
* authswitch's own: without that, the next read would report this write as a different account.
|
|
153
|
+
*/
|
|
154
|
+
private renewAnthropic(accountArg: IFileAccount, signalArg?: AbortSignal): Promise<IFileAccount> {
|
|
155
|
+
return this.store.locked(async () => {
|
|
156
|
+
const current = this.activeSlot(accountArg.slotId) ?? accountArg;
|
|
157
|
+
if (!this.renewalDue(current.credential)) return current;
|
|
158
|
+
const refreshToken = credentialText(current.credential.refresh);
|
|
159
|
+
if (refreshToken === undefined) throw new Error('This Anthropic login holds no refresh token; log in again with opencode auth login.');
|
|
160
|
+
signalArg?.throwIfAborted();
|
|
161
|
+
const tokens = await this.anthropicRefresh.refresh(refreshToken);
|
|
162
|
+
const latest = this.activeSlot(accountArg.slotId);
|
|
163
|
+
if (!latest || credentialText(latest.credential.refresh) !== refreshToken) return latest ?? current;
|
|
164
|
+
const renewed = this.inspect({ ...latest.credential, access: tokens.access, refresh: tokens.refresh, expires: tokens.expires }, latest.slotId);
|
|
165
|
+
this.apply(latest.slotId, latest, renewed);
|
|
166
|
+
this.recordRenewal(latest.slotId);
|
|
167
|
+
return renewed;
|
|
168
|
+
}, { signal: signalArg });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The subscription and usage of an OpenCode `anthropic` OAuth login.
|
|
173
|
+
*
|
|
174
|
+
* That entry holds the same Claude subscriber grant Claude Code stores, so Claude's own account endpoints answer
|
|
175
|
+
* for it and the account reports real windows instead of no quota API at all. OpenCode keeps no scopes and no
|
|
176
|
+
* account identity with it, so the service decides both. A due access token is renewed first, with OpenCode's own
|
|
177
|
+
* request; a refusal is reported and nothing is written, so the entry OpenCode owns is left exactly as it was.
|
|
178
|
+
*
|
|
179
|
+
* A saved copy is never renewed: OpenCode's entry holds the same refresh token, and rotating it here would leave
|
|
180
|
+
* the login OpenCode actually uses holding a token the service has replaced.
|
|
181
|
+
*/
|
|
182
|
+
private async anthropicStatus(accountArg: IFileAccount, isActiveArg: boolean, optionsArg: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
|
|
183
|
+
let account = accountArg;
|
|
184
|
+
if (this.renewalDue(account.credential)) {
|
|
185
|
+
if (!isActiveArg) return this.claudeStatus.unavailable({}, SAVED_ANTHROPIC_EXPIRED);
|
|
186
|
+
try { account = await this.renewAnthropic(account, optionsArg.signal); }
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (optionsArg.signal?.aborted) throw error;
|
|
189
|
+
const problem = error instanceof ClaudeLoginRejectedError ? ANTHROPIC_LOGIN_REJECTED
|
|
190
|
+
: error instanceof Error ? error.message : 'the login could not be renewed.';
|
|
191
|
+
return this.claudeStatus.unavailable({}, `Login refresh: ${problem}`,
|
|
192
|
+
error instanceof ClaudeRateLimitError ? { retryAt: error.retryAt } : undefined);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const accessToken = credentialText(account.credential.access);
|
|
196
|
+
if (accessToken === undefined) return this.claudeStatus.unavailable({}, 'This Anthropic login holds no usable access token; log in again with opencode auth login.');
|
|
197
|
+
return this.claudeStatus.read({ accessToken, scopes: [] }, optionsArg);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
public async readAccountStatus(id: string, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
|
|
201
|
+
const active = this.activeCredential(id);
|
|
202
|
+
const account = active ?? this.savedCredential(id);
|
|
109
203
|
if (account.slotId === 'openai' && account.credential.type === 'oauth') {
|
|
110
204
|
const info = this.openAiInfo(account.credential);
|
|
111
205
|
if (!info.accountId) return { facts: [], problems: ['The OpenAI login has no account ID for a status lookup.'] };
|
|
112
206
|
return this.status.readCredential(credentialText(account.credential.access)!, info.accountId, info.plan, optionsArg);
|
|
113
207
|
}
|
|
208
|
+
if (account.slotId === 'anthropic' && account.credential.type === 'oauth') return this.anthropicStatus(account, active !== undefined, optionsArg);
|
|
114
209
|
return { facts: [
|
|
115
210
|
{ section: 'Authentication', label: 'Provider', value: account.slotId },
|
|
116
211
|
{ section: 'Authentication', label: 'Login type', value: String(account.credential.type) },
|
package/ts/claudehttp.ts
CHANGED
|
@@ -13,6 +13,28 @@ export class ClaudeLoginRejectedError extends ClaudeRequestError {
|
|
|
13
13
|
constructor() { super(CLAUDE_LOGIN_REJECTED); }
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Where Claude Code 2.1.273 sends an account the service put on hold (`T5e`, bundle offset @190070402). A hold is
|
|
18
|
+
* not a dead login: the service refuses the grant while it lasts and accepts it again once it is lifted.
|
|
19
|
+
*/
|
|
20
|
+
export const CLAUDE_ACCOUNT_ON_HOLD_URL = 'https://claude.ai/restricted';
|
|
21
|
+
|
|
22
|
+
/** The service put this account on hold. The login itself is intact, so it is never remembered as refused. */
|
|
23
|
+
export class ClaudeAccountOnHoldError extends ClaudeRequestError {
|
|
24
|
+
constructor() { super(`This Claude account is on hold and cannot sign in. View details or appeal: ${CLAUDE_ACCOUNT_ON_HOLD_URL}`); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The OAuth error code a response names, read as Claude Code 2.1.273 reads it (`xc`, @191165290): the `error` field
|
|
29
|
+
* as a string, or the `type` of the object some Anthropic endpoints send it as.
|
|
30
|
+
*/
|
|
31
|
+
export const oauthErrorCode = (bodyArg: Record<string, unknown> | null): string | undefined => {
|
|
32
|
+
const error = bodyArg?.error;
|
|
33
|
+
if (typeof error === 'string') return error;
|
|
34
|
+
const type = typeof error === 'object' && error !== null ? (error as Record<string, unknown>).type : undefined;
|
|
35
|
+
return typeof type === 'string' ? type : undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
16
38
|
/** The service refused a request for too many requests. That says nothing about the account's usage or login. */
|
|
17
39
|
export class ClaudeRateLimitError extends ClaudeRequestError {
|
|
18
40
|
constructor(subjectArg: string, public readonly retryAt: string | null) {
|
package/ts/interfaces.harness.ts
CHANGED
|
@@ -239,9 +239,13 @@ export interface IAuthHarness {
|
|
|
239
239
|
*/
|
|
240
240
|
readonly autoSwitch?: boolean;
|
|
241
241
|
/**
|
|
242
|
-
* The account's live status. It must never activate, switch or clear a login.
|
|
243
|
-
*
|
|
244
|
-
*
|
|
242
|
+
* The account's live status. It must never activate, switch or clear a login.
|
|
243
|
+
*
|
|
244
|
+
* It may renew the tokens of the credential it reads when the provider leaves it no other way to answer, under
|
|
245
|
+
* its store's lock and with a compare-and-swap on the refresh token it sent. A saved copy is always its own to
|
|
246
|
+
* renew -- the Claude Code adapter renews one, because Claude Code's active login is Claude Code's. The active
|
|
247
|
+
* login may only be renewed where the owning harness re-reads its credential file on every request, so that the
|
|
248
|
+
* rotation strands nothing: OpenCode's Anthropic provider does, and authswitch renews that entry.
|
|
245
249
|
*/
|
|
246
250
|
readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
|
|
247
251
|
readonly loginProviders?: IHarnessLoginProvider[];
|