@moxxy/plugin-provider-claude-code 0.21.1
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/LICENSE +21 -0
- package/dist/constants.d.ts +41 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +41 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/login.d.ts +49 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +342 -0
- package/dist/login.js.map +1 -0
- package/dist/provider.d.ts +24 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +22 -0
- package/dist/provider.js.map +1 -0
- package/package.json +66 -0
- package/src/constants.ts +46 -0
- package/src/index.ts +47 -0
- package/src/login.test.ts +441 -0
- package/src/login.ts +426 -0
- package/src/provider.test.ts +36 -0
- package/src/provider.ts +34 -0
package/src/login.ts
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude subscription login + token lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Claude's OAuth is an out-of-band (manual code-paste) authorization-code +
|
|
5
|
+
* PKCE flow whose token endpoint speaks JSON (not the form-encoded dialect
|
|
6
|
+
* the framework's `exchangeCodeForToken` / `refreshAccessToken` assume), so
|
|
7
|
+
* the HTTP exchange + refresh are implemented here. Everything else is reused
|
|
8
|
+
* from `@moxxy/plugin-oauth`: PKCE, the auth-URL builder, the browser opener,
|
|
9
|
+
* vault storage layout, and `parseTokenResponse`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
buildAuthUrl,
|
|
14
|
+
clearStoredCreds,
|
|
15
|
+
computeCodeChallenge,
|
|
16
|
+
generateCodeVerifier,
|
|
17
|
+
generateState,
|
|
18
|
+
isAuthRejection,
|
|
19
|
+
isExpired,
|
|
20
|
+
openInBrowser,
|
|
21
|
+
parseTokenResponse,
|
|
22
|
+
readStoredCreds,
|
|
23
|
+
storeTokenSet,
|
|
24
|
+
withCredentialLock,
|
|
25
|
+
type OAuthVault,
|
|
26
|
+
type StoredCreds,
|
|
27
|
+
type TokenSet,
|
|
28
|
+
} from '@moxxy/plugin-oauth';
|
|
29
|
+
import { MoxxyError } from '@moxxy/sdk';
|
|
30
|
+
import type {
|
|
31
|
+
ProviderAuthContext,
|
|
32
|
+
ProviderOAuthResult,
|
|
33
|
+
ProviderOAuthStatus,
|
|
34
|
+
} from '@moxxy/sdk';
|
|
35
|
+
import {
|
|
36
|
+
CLAUDE_AUTHORIZE_URL,
|
|
37
|
+
CLAUDE_CLIENT_ID,
|
|
38
|
+
CLAUDE_CODE_PROVIDER_ID,
|
|
39
|
+
CLAUDE_CODE_SERVICE_NAME,
|
|
40
|
+
CLAUDE_REDIRECT_URI,
|
|
41
|
+
CLAUDE_SCOPES,
|
|
42
|
+
CLAUDE_TOKEN_URL,
|
|
43
|
+
} from './constants.js';
|
|
44
|
+
|
|
45
|
+
const PASTE_PROMPT =
|
|
46
|
+
'Paste a token from `claude setup-token` (or press Enter to sign in via browser): ';
|
|
47
|
+
const CODE_PROMPT = 'Paste the authorization code shown after you approve: ';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Drive an interactive Claude sign-in. Two ways in, both through one command:
|
|
51
|
+
* 1. paste an existing `claude setup-token` token (stored verbatim), or
|
|
52
|
+
* 2. press Enter to run the browser out-of-band authorization-code flow.
|
|
53
|
+
* Needs `ctx.prompt` (a TTY); headless callers use `CLAUDE_CODE_OAUTH_TOKEN`.
|
|
54
|
+
*/
|
|
55
|
+
export async function claudeLogin(ctx: ProviderAuthContext): Promise<ProviderOAuthResult> {
|
|
56
|
+
if (!ctx.prompt) {
|
|
57
|
+
throw new MoxxyError({
|
|
58
|
+
code: 'OAUTH_FLOW_NOT_SUPPORTED',
|
|
59
|
+
message: 'Claude sign-in needs an interactive terminal.',
|
|
60
|
+
hint:
|
|
61
|
+
'Run `claude setup-token` and set CLAUDE_CODE_OAUTH_TOKEN, or run ' +
|
|
62
|
+
'`moxxy login claude-code` in a terminal.',
|
|
63
|
+
context: { provider: CLAUDE_CODE_PROVIDER_ID },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const pasted = (await ctx.prompt(PASTE_PROMPT, { mask: true })).trim();
|
|
68
|
+
if (pasted) {
|
|
69
|
+
// A `setup-token` is a long-lived bearer with no refresh token; store it
|
|
70
|
+
// as-is. `isExpired` treats a missing expiry as non-expiring.
|
|
71
|
+
await storeTokenSet(
|
|
72
|
+
ctx.vault,
|
|
73
|
+
CLAUDE_CODE_PROVIDER_ID,
|
|
74
|
+
{ accessToken: pasted, tokenType: 'Bearer' },
|
|
75
|
+
{ clientId: CLAUDE_CLIENT_ID, tokenUrl: CLAUDE_TOKEN_URL },
|
|
76
|
+
);
|
|
77
|
+
ctx.write('\nStored your Claude token.\n');
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const verifier = generateCodeVerifier();
|
|
82
|
+
const challenge = computeCodeChallenge(verifier);
|
|
83
|
+
const state = generateState();
|
|
84
|
+
const authUrl = buildAuthUrl({
|
|
85
|
+
authUrl: CLAUDE_AUTHORIZE_URL,
|
|
86
|
+
clientId: CLAUDE_CLIENT_ID,
|
|
87
|
+
redirectUri: CLAUDE_REDIRECT_URI,
|
|
88
|
+
scopes: [...CLAUDE_SCOPES],
|
|
89
|
+
codeChallenge: challenge,
|
|
90
|
+
state,
|
|
91
|
+
extraAuthParams: { code: 'true' },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
ctx.write(
|
|
95
|
+
`\nSign in to ${CLAUDE_CODE_SERVICE_NAME} to authorize moxxy.\n\n` +
|
|
96
|
+
`If your browser doesn't open automatically, paste this URL:\n\n ${authUrl}\n\n` +
|
|
97
|
+
`After approving, copy the authorization code shown and paste it back here.\n` +
|
|
98
|
+
`(Anthropic's sign-in page sometimes shows "Internal server error" on the\n` +
|
|
99
|
+
` first attempt — just click "Try again" and it goes through.)\n\n`,
|
|
100
|
+
);
|
|
101
|
+
try {
|
|
102
|
+
await openBrowserImpl(authUrl);
|
|
103
|
+
} catch {
|
|
104
|
+
// Non-fatal — the user can open the URL surfaced above by hand.
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The authorization code is a single-use, exchangeable credential — mask it
|
|
108
|
+
// so it doesn't echo into scrollback / screen-share, matching the token paste.
|
|
109
|
+
const entered = (await ctx.prompt(CODE_PROMPT, { mask: true })).trim();
|
|
110
|
+
if (!entered) {
|
|
111
|
+
throw new MoxxyError({
|
|
112
|
+
code: 'AUTH_DENIED',
|
|
113
|
+
message: 'No authorization code entered — sign-in cancelled.',
|
|
114
|
+
context: { provider: CLAUDE_CODE_PROVIDER_ID },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
// Anthropic returns `code#state`, but often shows just the bare code, so the
|
|
118
|
+
// state check below is best-effort: it only fires when a state was pasted.
|
|
119
|
+
// The real CSRF/code-injection defense is PKCE — the `code_verifier` binds
|
|
120
|
+
// the code to this client session, so a foreign code fails the exchange.
|
|
121
|
+
const hash = entered.indexOf('#');
|
|
122
|
+
const code = hash >= 0 ? entered.slice(0, hash) : entered;
|
|
123
|
+
const returnedState = hash >= 0 ? entered.slice(hash + 1) : '';
|
|
124
|
+
if (returnedState && returnedState !== state) {
|
|
125
|
+
throw new MoxxyError({
|
|
126
|
+
code: 'AUTH_INVALID',
|
|
127
|
+
message: 'Authorization state mismatch — please run sign-in again.',
|
|
128
|
+
context: { provider: CLAUDE_CODE_PROVIDER_ID },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { tokenSet, accountEmail } = await exchangeClaudeCode(code, returnedState || state, verifier);
|
|
133
|
+
await persistClaudeTokens(ctx.vault, tokenSet, accountEmail);
|
|
134
|
+
ctx.write(`\nSigned in to Claude${accountEmail ? ` as ${accountEmail}` : ''}.\n`);
|
|
135
|
+
// Omit `expiresAt` when the credential never expires (setup-token paste, or a
|
|
136
|
+
// token response without `expires_in`). Surfacing `0` would read as "epoch =
|
|
137
|
+
// already expired" to the CLI status renderer, which only checks `!== undefined`.
|
|
138
|
+
return {
|
|
139
|
+
...(accountEmail ? { accountId: accountEmail } : {}),
|
|
140
|
+
...(tokenSet.expiresAt !== undefined ? { expiresAt: tokenSet.expiresAt } : {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function claudeLogout(ctx: ProviderAuthContext): Promise<boolean> {
|
|
145
|
+
try {
|
|
146
|
+
return (await clearStoredCreds(ctx.vault, CLAUDE_CODE_PROVIDER_ID)) > 0;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function claudeStatus(ctx: ProviderAuthContext): Promise<ProviderOAuthStatus | null> {
|
|
153
|
+
const stored = await readStoredCreds(ctx.vault, CLAUDE_CODE_PROVIDER_ID);
|
|
154
|
+
if (!stored) return null;
|
|
155
|
+
return {
|
|
156
|
+
accountId: stored.extras.account_email ?? null,
|
|
157
|
+
// Omit when absent — a stored setup-token has no expiry, and `0` would be
|
|
158
|
+
// mis-rendered as "expired" (the CLI only treats `!== undefined` as set).
|
|
159
|
+
...(stored.tokenSet.expiresAt !== undefined ? { expiresAt: stored.tokenSet.expiresAt } : {}),
|
|
160
|
+
vaultKey: `oauth/${CLAUDE_CODE_PROVIDER_ID}/*`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface FreshClaudeTokens {
|
|
165
|
+
readonly accessToken: string;
|
|
166
|
+
readonly expiresAt?: number;
|
|
167
|
+
/** True when a refresh_token is stored, so a 401 can be recovered. */
|
|
168
|
+
readonly canRefresh: boolean;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Read the stored Claude creds, refreshing first if the access token is near
|
|
173
|
+
* expiry and a refresh_token is available. Returns null when nothing is
|
|
174
|
+
* stored. Persists rotated tokens BEFORE returning so a crash can't strand a
|
|
175
|
+
* single-use refresh_token. The refresh itself is serialized per credential
|
|
176
|
+
* (in-process + cross-process) and coalesces with concurrent refreshers.
|
|
177
|
+
*/
|
|
178
|
+
export async function ensureFreshClaudeTokens(vault: OAuthVault): Promise<FreshClaudeTokens | null> {
|
|
179
|
+
const stored = await readStoredCreds(vault, CLAUDE_CODE_PROVIDER_ID);
|
|
180
|
+
if (!stored) return null;
|
|
181
|
+
const { tokenSet } = stored;
|
|
182
|
+
if (tokenSet.refreshToken && isExpired(tokenSet)) {
|
|
183
|
+
const refreshed = await refreshClaudeUnderLock(vault, stored);
|
|
184
|
+
return { accessToken: refreshed.accessToken, ...(refreshed.expiresAt !== undefined ? { expiresAt: refreshed.expiresAt } : {}), canRefresh: true };
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
accessToken: tokenSet.accessToken,
|
|
188
|
+
...(tokenSet.expiresAt !== undefined ? { expiresAt: tokenSet.expiresAt } : {}),
|
|
189
|
+
canRefresh: tokenSet.refreshToken !== undefined,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Force a refresh of the stored Claude tokens and persist the rotated bundle.
|
|
195
|
+
* Used as the provider's reactive 401 recovery. Throws if no refresh_token is
|
|
196
|
+
* stored (e.g. a pasted `setup-token`).
|
|
197
|
+
*/
|
|
198
|
+
export async function refreshClaudeAccessToken(
|
|
199
|
+
vault: OAuthVault,
|
|
200
|
+
): Promise<{ token: string; expiresAt?: number }> {
|
|
201
|
+
const stored = await readStoredCreds(vault, CLAUDE_CODE_PROVIDER_ID);
|
|
202
|
+
if (!stored?.tokenSet.refreshToken) {
|
|
203
|
+
throw new MoxxyError({
|
|
204
|
+
code: 'AUTH_EXPIRED',
|
|
205
|
+
message: 'Claude token expired and no refresh_token is stored.',
|
|
206
|
+
hint: 'Run `moxxy login claude-code` again, or refresh `CLAUDE_CODE_OAUTH_TOKEN` via `claude setup-token`.',
|
|
207
|
+
context: { provider: CLAUDE_CODE_PROVIDER_ID },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const refreshed = await refreshClaudeUnderLock(vault, stored);
|
|
211
|
+
return { token: refreshed.accessToken, ...(refreshed.expiresAt !== undefined ? { expiresAt: refreshed.expiresAt } : {}) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Refresh + persist under the per-credential lock. Anthropic ROTATES the
|
|
216
|
+
* refresh_token on every refresh and invalidates the previous one, so two
|
|
217
|
+
* concurrent refreshes (a second consumer in this process, or another moxxy
|
|
218
|
+
* process — TUI alongside a desktop runner) must not both burn the same
|
|
219
|
+
* stored token. Under the lock we:
|
|
220
|
+
* 1. re-read the vault — if someone else already rotated and the new access
|
|
221
|
+
* token is still fresh, reuse it (coalesce; no IdP call at all);
|
|
222
|
+
* 2. otherwise refresh with the freshest stored refresh_token;
|
|
223
|
+
* 3. on an invalid_grant-style rejection, re-read once more — if the
|
|
224
|
+
* on-disk refresh_token changed under us (another process won a race we
|
|
225
|
+
* couldn't see), retry ONCE with the fresher token before declaring
|
|
226
|
+
* re-auth necessary.
|
|
227
|
+
*/
|
|
228
|
+
async function refreshClaudeUnderLock(vault: OAuthVault, baseline: StoredCreds): Promise<TokenSet> {
|
|
229
|
+
return withCredentialLock(`oauth-${CLAUDE_CODE_PROVIDER_ID}`, async () => {
|
|
230
|
+
const current = (await readStoredCreds(vault, CLAUDE_CODE_PROVIDER_ID)) ?? baseline;
|
|
231
|
+
if (current.tokenSet.accessToken !== baseline.tokenSet.accessToken && !isExpired(current.tokenSet)) {
|
|
232
|
+
return current.tokenSet;
|
|
233
|
+
}
|
|
234
|
+
const refreshToken = current.tokenSet.refreshToken ?? baseline.tokenSet.refreshToken;
|
|
235
|
+
if (!refreshToken) {
|
|
236
|
+
throw new MoxxyError({
|
|
237
|
+
code: 'AUTH_EXPIRED',
|
|
238
|
+
message: 'Claude token expired and no refresh_token is stored.',
|
|
239
|
+
hint: 'Run `moxxy login claude-code` again, or refresh `CLAUDE_CODE_OAUTH_TOKEN` via `claude setup-token`.',
|
|
240
|
+
context: { provider: CLAUDE_CODE_PROVIDER_ID },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
const email = current.extras.account_email ?? baseline.extras.account_email;
|
|
244
|
+
try {
|
|
245
|
+
return await refreshAndPersist(vault, refreshToken, email);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (isAuthRejection(err)) {
|
|
248
|
+
const latest = await readStoredCreds(vault, CLAUDE_CODE_PROVIDER_ID);
|
|
249
|
+
const latestRefresh = latest?.tokenSet.refreshToken;
|
|
250
|
+
if (latestRefresh && latestRefresh !== refreshToken) {
|
|
251
|
+
return refreshAndPersist(vault, latestRefresh, latest.extras.account_email ?? email);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- internal HTTP (JSON dialect) -----------------------------------------
|
|
260
|
+
|
|
261
|
+
interface ClaudeExchangeResult {
|
|
262
|
+
readonly tokenSet: TokenSet;
|
|
263
|
+
readonly accountEmail?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Anthropic's OAuth endpoints (both `claude.ai/oauth/authorize` and the token
|
|
268
|
+
* endpoint) intermittently return a transient HTTP 500 — the *same* request
|
|
269
|
+
* then succeeds on retry. So retry 5xx/429/network failures with a short
|
|
270
|
+
* backoff before giving up. 4xx (bad/expired/already-used code, invalid_grant)
|
|
271
|
+
* is deterministic and surfaced immediately, never retried.
|
|
272
|
+
*/
|
|
273
|
+
const TOKEN_POST_MAX_ATTEMPTS = 3;
|
|
274
|
+
const TOKEN_POST_BACKOFF_MS = [600, 1800] as const;
|
|
275
|
+
/**
|
|
276
|
+
* Per-attempt deadline on the token POST. Node's `fetch` has NO default
|
|
277
|
+
* timeout, so a half-open TCP socket (server accepts but never responds — a
|
|
278
|
+
* stalled edge node, a black-holing proxy, a captive portal that keeps the
|
|
279
|
+
* connection alive) would hang the whole login/refresh indefinitely. Bound
|
|
280
|
+
* each attempt; a timeout aborts the request and is classed as a transient
|
|
281
|
+
* network error, so the retry loop tries again and ultimately surfaces the
|
|
282
|
+
* "endpoint kept failing" hint instead of wedging forever.
|
|
283
|
+
*/
|
|
284
|
+
const TOKEN_POST_TIMEOUT_MS = 30_000;
|
|
285
|
+
|
|
286
|
+
/** Test seam so the exchange/refresh can run against a fake fetch. */
|
|
287
|
+
let fetchImpl: typeof fetch = fetch;
|
|
288
|
+
export function __setClaudeFetch(f: typeof fetch): void {
|
|
289
|
+
fetchImpl = f;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Test seam so the OOB login can run without launching a real browser. */
|
|
293
|
+
let openBrowserImpl: (url: string) => Promise<void> = openInBrowser;
|
|
294
|
+
export function __setClaudeOpenBrowser(f: (url: string) => Promise<void>): void {
|
|
295
|
+
openBrowserImpl = f;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Test seam so the retry backoff doesn't actually sleep under test. */
|
|
299
|
+
let sleepImpl = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
300
|
+
export function __setClaudeSleep(f: (ms: number) => Promise<void>): void {
|
|
301
|
+
sleepImpl = f;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Test seam to shrink the per-attempt network deadline (default {@link TOKEN_POST_TIMEOUT_MS}). */
|
|
305
|
+
let timeoutMs = TOKEN_POST_TIMEOUT_MS;
|
|
306
|
+
export function __setClaudeTimeoutMs(ms: number): void {
|
|
307
|
+
timeoutMs = ms;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function exchangeClaudeCode(
|
|
311
|
+
code: string,
|
|
312
|
+
state: string,
|
|
313
|
+
verifier: string,
|
|
314
|
+
): Promise<ClaudeExchangeResult> {
|
|
315
|
+
return postClaudeToken({
|
|
316
|
+
grant_type: 'authorization_code',
|
|
317
|
+
code,
|
|
318
|
+
state,
|
|
319
|
+
client_id: CLAUDE_CLIENT_ID,
|
|
320
|
+
redirect_uri: CLAUDE_REDIRECT_URI,
|
|
321
|
+
code_verifier: verifier,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function refreshAndPersist(
|
|
326
|
+
vault: OAuthVault,
|
|
327
|
+
refreshToken: string,
|
|
328
|
+
priorEmail: string | undefined,
|
|
329
|
+
): Promise<TokenSet> {
|
|
330
|
+
const { tokenSet, accountEmail } = await postClaudeToken({
|
|
331
|
+
grant_type: 'refresh_token',
|
|
332
|
+
refresh_token: refreshToken,
|
|
333
|
+
client_id: CLAUDE_CLIENT_ID,
|
|
334
|
+
});
|
|
335
|
+
// Claude rotates the refresh_token on every refresh; if a response ever
|
|
336
|
+
// omits one, keep the prior so we don't lock ourselves out.
|
|
337
|
+
const merged: TokenSet = tokenSet.refreshToken
|
|
338
|
+
? tokenSet
|
|
339
|
+
: { ...tokenSet, refreshToken };
|
|
340
|
+
await persistClaudeTokens(vault, merged, accountEmail ?? priorEmail);
|
|
341
|
+
return merged;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function persistClaudeTokens(
|
|
345
|
+
vault: OAuthVault,
|
|
346
|
+
tokenSet: TokenSet,
|
|
347
|
+
accountEmail: string | undefined,
|
|
348
|
+
): Promise<void> {
|
|
349
|
+
await storeTokenSet(vault, CLAUDE_CODE_PROVIDER_ID, tokenSet, {
|
|
350
|
+
clientId: CLAUDE_CLIENT_ID,
|
|
351
|
+
tokenUrl: CLAUDE_TOKEN_URL,
|
|
352
|
+
...(accountEmail ? { extras: { account_email: accountEmail } } : {}),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function postClaudeToken(body: Record<string, string>): Promise<ClaudeExchangeResult> {
|
|
357
|
+
let transient = '';
|
|
358
|
+
for (let attempt = 1; attempt <= TOKEN_POST_MAX_ATTEMPTS; attempt++) {
|
|
359
|
+
if (attempt > 1) await sleepImpl(TOKEN_POST_BACKOFF_MS[attempt - 2] ?? 1800);
|
|
360
|
+
|
|
361
|
+
let res: Response;
|
|
362
|
+
try {
|
|
363
|
+
res = await fetchImpl(CLAUDE_TOKEN_URL, {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
366
|
+
body: JSON.stringify(body),
|
|
367
|
+
// Bound a hung connection so it surfaces as a retryable network error
|
|
368
|
+
// (TimeoutError) rather than blocking the login/refresh forever.
|
|
369
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
370
|
+
});
|
|
371
|
+
} catch (err) {
|
|
372
|
+
transient = `network error (${err instanceof Error ? err.message : String(err)})`;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (res.ok) {
|
|
377
|
+
// A captive portal / proxy / misbehaving edge can return 200 with an HTML
|
|
378
|
+
// or empty body, or a JSON primitive/array. Treat any of these as a
|
|
379
|
+
// transient and retry rather than letting a raw SyntaxError escape the
|
|
380
|
+
// retry/classify path.
|
|
381
|
+
let parsed: unknown;
|
|
382
|
+
try {
|
|
383
|
+
parsed = await res.json();
|
|
384
|
+
} catch {
|
|
385
|
+
transient = 'HTTP 200 with non-JSON body';
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
389
|
+
transient = 'HTTP 200 with malformed token response';
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const json = parsed as Record<string, unknown>;
|
|
393
|
+
return { tokenSet: parseTokenResponse(json), ...extractAccountEmail(json) };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const text = await res.text().catch(() => '');
|
|
397
|
+
// Deterministic client errors: surface immediately, don't burn retries.
|
|
398
|
+
if (res.status < 500 && res.status !== 429) {
|
|
399
|
+
throw new MoxxyError({
|
|
400
|
+
code: res.status === 401 || res.status === 403 ? 'AUTH_DENIED' : 'AUTH_INVALID',
|
|
401
|
+
message: `Claude token endpoint returned HTTP ${res.status}: ${text.slice(0, 300)}`,
|
|
402
|
+
context: { provider: CLAUDE_CODE_PROVIDER_ID, status: res.status },
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// 5xx / 429 — Anthropic-side flake; loop and try the same request again.
|
|
406
|
+
transient = `HTTP ${res.status}: ${text.slice(0, 200)}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
throw new MoxxyError({
|
|
410
|
+
code: 'AUTH_INVALID',
|
|
411
|
+
message: `Claude token endpoint kept failing after ${TOKEN_POST_MAX_ATTEMPTS} attempts (last: ${transient}).`,
|
|
412
|
+
hint:
|
|
413
|
+
"Anthropic's OAuth endpoint is returning transient errors right now — " +
|
|
414
|
+
'wait a few seconds and run `moxxy login claude-code` again.',
|
|
415
|
+
context: { provider: CLAUDE_CODE_PROVIDER_ID },
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function extractAccountEmail(json: Record<string, unknown>): { accountEmail?: string } {
|
|
420
|
+
const account = json.account;
|
|
421
|
+
if (account && typeof account === 'object') {
|
|
422
|
+
const email = (account as { email_address?: unknown }).email_address;
|
|
423
|
+
if (typeof email === 'string' && email) return { accountEmail: email };
|
|
424
|
+
}
|
|
425
|
+
return {};
|
|
426
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CLAUDE_CODE_SYSTEM, CLAUDE_OAUTH_BETA } from './constants.js';
|
|
3
|
+
import { claudeCodeProviderDef, createClaudeCodeClient } from './index.js';
|
|
4
|
+
|
|
5
|
+
describe('claude-code provider definition', () => {
|
|
6
|
+
it('registers as an OAuth provider named claude-code with Claude models', () => {
|
|
7
|
+
expect(claudeCodeProviderDef.name).toBe('claude-code');
|
|
8
|
+
expect(claudeCodeProviderDef.auth?.kind).toBe('oauth');
|
|
9
|
+
expect(claudeCodeProviderDef.models.length).toBeGreaterThan(0);
|
|
10
|
+
expect(claudeCodeProviderDef.models.map((m) => m.id)).toContain('claude-sonnet-4-6');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('builds an OAuth-mode client that reports the claude-code name', () => {
|
|
14
|
+
const client = createClaudeCodeClient({ oauthToken: 'tok' });
|
|
15
|
+
expect(client.name).toBe('claude-code');
|
|
16
|
+
const inner = (client as unknown as { client: { apiKey: unknown; authToken: unknown } }).client;
|
|
17
|
+
expect(inner.apiKey).toBeNull();
|
|
18
|
+
expect(inner.authToken).toBe('tok');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('forwards the OAuth beta headers and identity preamble to AnthropicProvider', () => {
|
|
22
|
+
// These two are load-bearing: a subscription token is rejected by the
|
|
23
|
+
// Messages API unless `anthropic-beta: oauth-2025-04-20` is sent and the
|
|
24
|
+
// system prompt leads with the exact CLAUDE_CODE_SYSTEM line. A silent
|
|
25
|
+
// drop/typo in the forwarding object compiles fine and only surfaces as a
|
|
26
|
+
// runtime 401/400 — pin the contract here.
|
|
27
|
+
const client = createClaudeCodeClient({ oauthToken: 'tok' });
|
|
28
|
+
const oauth = (
|
|
29
|
+
client as unknown as { oauth: { beta: ReadonlyArray<string>; systemPreamble?: string } }
|
|
30
|
+
).oauth;
|
|
31
|
+
expect(oauth.beta).toEqual([...CLAUDE_OAUTH_BETA]);
|
|
32
|
+
expect(oauth.beta).toContain('oauth-2025-04-20');
|
|
33
|
+
expect(oauth.beta).toContain('claude-code-20250219');
|
|
34
|
+
expect(oauth.systemPreamble).toBe(CLAUDE_CODE_SYSTEM);
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude subscription provider is the standard Anthropic Messages API
|
|
3
|
+
* with a bearer (OAuth) credential instead of an API key, so it reuses
|
|
4
|
+
* `AnthropicProvider` in OAuth mode rather than duplicating the streaming /
|
|
5
|
+
* tool-call event handling. This module just stamps in the Claude-specific
|
|
6
|
+
* constants (beta headers + the required identity preamble) and forwards the
|
|
7
|
+
* resolved token + refresh callback.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { AnthropicProvider } from '@moxxy/plugin-provider-anthropic';
|
|
11
|
+
import { CLAUDE_CODE_PROVIDER_ID, CLAUDE_CODE_SYSTEM, CLAUDE_OAUTH_BETA } from './constants.js';
|
|
12
|
+
|
|
13
|
+
export interface ClaudeCodeProviderConfig {
|
|
14
|
+
/** Claude Code OAuth access token (bearer). Resolved by the host. */
|
|
15
|
+
readonly oauthToken?: string;
|
|
16
|
+
/** Epoch-ms expiry of `oauthToken`, when known (drives proactive refresh). */
|
|
17
|
+
readonly oauthExpiresAt?: number;
|
|
18
|
+
/** Refresh callback wired by the host; omitted for non-refreshable tokens. */
|
|
19
|
+
readonly oauthRefresh?: () => Promise<{ readonly token: string; readonly expiresAt?: number }>;
|
|
20
|
+
/** Optional default model override. */
|
|
21
|
+
readonly defaultModel?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createClaudeCodeClient(config: ClaudeCodeProviderConfig = {}): AnthropicProvider {
|
|
25
|
+
return new AnthropicProvider({
|
|
26
|
+
name: CLAUDE_CODE_PROVIDER_ID,
|
|
27
|
+
oauthBeta: [...CLAUDE_OAUTH_BETA],
|
|
28
|
+
systemPreamble: CLAUDE_CODE_SYSTEM,
|
|
29
|
+
...(config.oauthToken ? { oauthToken: config.oauthToken } : {}),
|
|
30
|
+
...(config.oauthExpiresAt !== undefined ? { oauthExpiresAt: config.oauthExpiresAt } : {}),
|
|
31
|
+
...(config.oauthRefresh ? { oauthRefresh: config.oauthRefresh } : {}),
|
|
32
|
+
...(config.defaultModel ? { defaultModel: config.defaultModel } : {}),
|
|
33
|
+
});
|
|
34
|
+
}
|