@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/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@moxxy/plugin-provider-claude-code",
3
+ "version": "0.21.1",
4
+ "description": "Claude subscription (Pro/Max) LLMProvider plugin for moxxy. Talks to the standard Anthropic Messages API using a Claude Code OAuth token from `claude setup-token` or an in-app sign-in.",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "provider",
9
+ "anthropic",
10
+ "claude",
11
+ "oauth",
12
+ "llm"
13
+ ],
14
+ "homepage": "https://moxxy.ai",
15
+ "bugs": {
16
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
21
+ "directory": "packages/plugin-provider-claude-code"
22
+ },
23
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
24
+ "license": "MIT",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "type": "module",
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src"
40
+ ],
41
+ "moxxy": {
42
+ "plugin": {
43
+ "entry": "./dist/index.js",
44
+ "kind": "provider"
45
+ }
46
+ },
47
+ "dependencies": {
48
+ "@moxxy/sdk": "0.21.1",
49
+ "@moxxy/plugin-oauth": "0.0.33",
50
+ "@moxxy/plugin-provider-anthropic": "0.21.1"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.10.0",
54
+ "typescript": "^5.7.3",
55
+ "vitest": "^2.1.8",
56
+ "zod": "^3.24.0",
57
+ "@moxxy/tsconfig": "0.0.0",
58
+ "@moxxy/vitest-preset": "0.0.0"
59
+ },
60
+ "scripts": {
61
+ "build": "tsc -p tsconfig.json",
62
+ "typecheck": "tsc -p tsconfig.json --noEmit",
63
+ "test": "vitest run",
64
+ "clean": "rm -rf dist .turbo"
65
+ }
66
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Constants for the Claude subscription (Pro/Max) provider.
3
+ *
4
+ * These are the public Claude Code OAuth client parameters — the same values
5
+ * the `claude` CLI uses — so a token minted here interoperates with one from
6
+ * `claude setup-token`, and vice-versa. We never embed a client secret; the
7
+ * flow is PKCE + an out-of-band (manual paste) authorization code.
8
+ */
9
+
10
+ /** Provider name in the registry / `/model` picker / `provider.name` config. */
11
+ export const CLAUDE_CODE_PROVIDER_ID = 'claude-code';
12
+
13
+ /** Human-readable upstream service, shown in `moxxy login` and the init wizard. */
14
+ export const CLAUDE_CODE_SERVICE_NAME = 'Claude (Pro/Max subscription)';
15
+
16
+ export const CLAUDE_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
17
+ export const CLAUDE_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
18
+ export const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
19
+ /**
20
+ * Out-of-band redirect: after consent, Anthropic shows the user a
21
+ * `code#state` string to paste back into the terminal (no loopback server).
22
+ */
23
+ export const CLAUDE_REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback';
24
+ export const CLAUDE_SCOPES = ['org:create_api_key', 'user:profile', 'user:inference'] as const;
25
+
26
+ /**
27
+ * `anthropic-beta` values sent with every request. `oauth-2025-04-20` is the
28
+ * flag that makes the Messages API accept a subscription bearer token at all;
29
+ * `claude-code-20250219` matches what the real CLI sends. Sent comma-joined.
30
+ */
31
+ export const CLAUDE_OAUTH_BETA = ['oauth-2025-04-20', 'claude-code-20250219'] as const;
32
+
33
+ /**
34
+ * Required first system block. Claude rejects a subscription token unless the
35
+ * system prompt leads with this exact identity line; moxxy's real system
36
+ * prompt is appended as the following block. (Verified against the working
37
+ * opencode / claude-code-router behaviour.)
38
+ */
39
+ export const CLAUDE_CODE_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude.";
40
+
41
+ /**
42
+ * Env vars a pasted/CI token can arrive through. `CLAUDE_CODE_OAUTH_TOKEN` is
43
+ * what `claude setup-token` documents; `ANTHROPIC_AUTH_TOKEN` is the
44
+ * SDK-native bearer var. Checked in this order.
45
+ */
46
+ export const CLAUDE_TOKEN_ENV_VARS = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN'] as const;
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { defineProvider, definePlugin } from '@moxxy/sdk';
2
+ import { anthropicModels } from '@moxxy/plugin-provider-anthropic';
3
+ import { CLAUDE_CODE_PROVIDER_ID, CLAUDE_CODE_SERVICE_NAME } from './constants.js';
4
+ import { createClaudeCodeClient, type ClaudeCodeProviderConfig } from './provider.js';
5
+ import { claudeLogin, claudeLogout, claudeStatus } from './login.js';
6
+
7
+ export const claudeCodeProviderDef = defineProvider({
8
+ name: CLAUDE_CODE_PROVIDER_ID,
9
+ // Same Claude models as the API-key `anthropic` provider — it's the same
10
+ // Messages API, only the credential differs.
11
+ models: [...anthropicModels],
12
+ createClient: (config) => createClaudeCodeClient(config as ClaudeCodeProviderConfig),
13
+ // No validateKey: an OAuth bearer is validated by the request itself, and
14
+ // an interactive paste/sign-in already proves the token round-trips.
15
+ auth: {
16
+ kind: 'oauth',
17
+ serviceName: CLAUDE_CODE_SERVICE_NAME,
18
+ login: claudeLogin,
19
+ logout: claudeLogout,
20
+ status: claudeStatus,
21
+ },
22
+ });
23
+
24
+ export const claudeCodePlugin = definePlugin({
25
+ name: '@moxxy/plugin-provider-claude-code',
26
+ version: '0.0.0',
27
+ providers: [claudeCodeProviderDef],
28
+ });
29
+
30
+ export default claudeCodePlugin;
31
+
32
+ export {
33
+ CLAUDE_CODE_PROVIDER_ID,
34
+ CLAUDE_CODE_SERVICE_NAME,
35
+ CLAUDE_CODE_SYSTEM,
36
+ CLAUDE_OAUTH_BETA,
37
+ CLAUDE_TOKEN_ENV_VARS,
38
+ } from './constants.js';
39
+ export {
40
+ claudeLogin,
41
+ claudeLogout,
42
+ claudeStatus,
43
+ ensureFreshClaudeTokens,
44
+ refreshClaudeAccessToken,
45
+ type FreshClaudeTokens,
46
+ } from './login.js';
47
+ export { createClaudeCodeClient, type ClaudeCodeProviderConfig } from './provider.js';
@@ -0,0 +1,441 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { storeTokenSet, type OAuthVault } from '@moxxy/plugin-oauth';
6
+ import type { ProviderAuthContext } from '@moxxy/sdk';
7
+ import { CLAUDE_CLIENT_ID, CLAUDE_TOKEN_URL } from './constants.js';
8
+ import {
9
+ claudeLogin,
10
+ claudeLogout,
11
+ claudeStatus,
12
+ ensureFreshClaudeTokens,
13
+ refreshClaudeAccessToken,
14
+ __setClaudeFetch,
15
+ __setClaudeOpenBrowser,
16
+ __setClaudeSleep,
17
+ __setClaudeTimeoutMs,
18
+ } from './login.js';
19
+
20
+ interface FakeVault extends OAuthVault {
21
+ readonly store: Map<string, string>;
22
+ }
23
+
24
+ function makeVault(): FakeVault {
25
+ const store = new Map<string, string>();
26
+ return {
27
+ store,
28
+ get: async (k) => store.get(k) ?? null,
29
+ set: async (k, v) => {
30
+ store.set(k, v);
31
+ },
32
+ delete: async (k) => store.delete(k),
33
+ };
34
+ }
35
+
36
+ function makeCtx(vault: OAuthVault, answers: string[]): ProviderAuthContext {
37
+ const queue = [...answers];
38
+ return {
39
+ vault,
40
+ headless: false,
41
+ write: () => {},
42
+ prompt: async () => queue.shift() ?? '',
43
+ };
44
+ }
45
+
46
+ function jsonResponse(obj: unknown, status = 200): Response {
47
+ return {
48
+ ok: status >= 200 && status < 300,
49
+ status,
50
+ json: async () => obj,
51
+ text: async () => JSON.stringify(obj),
52
+ } as unknown as Response;
53
+ }
54
+
55
+ const META = { clientId: CLAUDE_CLIENT_ID, tokenUrl: CLAUDE_TOKEN_URL };
56
+
57
+ // The refresh path takes a cross-process lockfile under `<moxxy home>/locks`;
58
+ // point MOXXY_HOME at a temp dir so tests never touch the real ~/.moxxy.
59
+ let moxxyHomeTmp: string;
60
+ const priorMoxxyHome = process.env.MOXXY_HOME;
61
+ beforeAll(async () => {
62
+ moxxyHomeTmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-claude-login-'));
63
+ process.env.MOXXY_HOME = moxxyHomeTmp;
64
+ });
65
+ afterAll(async () => {
66
+ if (priorMoxxyHome === undefined) delete process.env.MOXXY_HOME;
67
+ else process.env.MOXXY_HOME = priorMoxxyHome;
68
+ await fs.rm(moxxyHomeTmp, { recursive: true, force: true });
69
+ });
70
+
71
+ beforeEach(() => {
72
+ __setClaudeOpenBrowser(async () => {});
73
+ __setClaudeSleep(async () => {}); // don't actually back off under test
74
+ __setClaudeTimeoutMs(50); // bound the hung-connection tests; default is 30s
75
+ __setClaudeFetch(async () => {
76
+ throw new Error('unexpected fetch — a test forgot to stub __setClaudeFetch');
77
+ });
78
+ });
79
+
80
+ /**
81
+ * A fetch that respects the `AbortSignal` exactly like the real one: it never
82
+ * resolves on its own, so it only settles when the per-attempt deadline aborts
83
+ * it (mirroring a half-open socket / black-holing proxy).
84
+ */
85
+ function hangingFetch(): typeof fetch {
86
+ return ((_url, init) =>
87
+ new Promise((_resolve, reject) => {
88
+ const signal = (init as { signal?: AbortSignal } | undefined)?.signal;
89
+ if (signal?.aborted) {
90
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
91
+ return;
92
+ }
93
+ signal?.addEventListener('abort', () => {
94
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
95
+ });
96
+ })) as typeof fetch;
97
+ }
98
+
99
+ afterAll(() => {
100
+ __setClaudeFetch(fetch);
101
+ __setClaudeTimeoutMs(30_000);
102
+ });
103
+
104
+ describe('claudeLogin', () => {
105
+ it('stores a pasted `setup-token` verbatim, with no refresh token', async () => {
106
+ const vault = makeVault();
107
+ const res = await claudeLogin(makeCtx(vault, ['sk-ant-oat-PASTED']));
108
+ expect(vault.store.get('oauth/claude-code/access_token')).toBe('sk-ant-oat-PASTED');
109
+ expect(vault.store.has('oauth/claude-code/refresh_token')).toBe(false);
110
+ expect(res).toEqual({});
111
+ });
112
+
113
+ it('runs the out-of-band flow and exchanges the pasted code for tokens', async () => {
114
+ const vault = makeVault();
115
+ const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
116
+ __setClaudeFetch(async (url, init) => {
117
+ calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? '{}')) });
118
+ return jsonResponse({
119
+ access_token: 'access-1',
120
+ refresh_token: 'refresh-1',
121
+ expires_in: 3600,
122
+ token_type: 'Bearer',
123
+ account: { email_address: 'me@example.com' },
124
+ });
125
+ });
126
+
127
+ // First prompt empty => browser flow; second prompt => the auth code.
128
+ const res = await claudeLogin(makeCtx(vault, ['', 'AUTHCODE']));
129
+
130
+ expect(calls).toHaveLength(1);
131
+ expect(calls[0]!.url).toBe(CLAUDE_TOKEN_URL);
132
+ expect(calls[0]!.body).toMatchObject({
133
+ grant_type: 'authorization_code',
134
+ code: 'AUTHCODE',
135
+ client_id: CLAUDE_CLIENT_ID,
136
+ redirect_uri: 'https://console.anthropic.com/oauth/code/callback',
137
+ });
138
+ expect(typeof calls[0]!.body.code_verifier).toBe('string');
139
+ expect(vault.store.get('oauth/claude-code/access_token')).toBe('access-1');
140
+ expect(vault.store.get('oauth/claude-code/refresh_token')).toBe('refresh-1');
141
+ expect(vault.store.get('oauth/claude-code/extras')).toContain('me@example.com');
142
+ expect(res.accountId).toBe('me@example.com');
143
+ });
144
+
145
+ it('retries a transient 5xx from the token endpoint and then succeeds', async () => {
146
+ const vault = makeVault();
147
+ let calls = 0;
148
+ __setClaudeFetch(async () => {
149
+ calls++;
150
+ if (calls < 3) return jsonResponse({ error: { type: 'api_error', message: 'Internal server error' } }, 500);
151
+ return jsonResponse({ access_token: 'access-after-retry', token_type: 'Bearer', expires_in: 3600 });
152
+ });
153
+
154
+ const res = await claudeLogin(makeCtx(vault, ['', 'AUTHCODE']));
155
+
156
+ expect(calls).toBe(3); // two 500s, third attempt wins
157
+ expect(vault.store.get('oauth/claude-code/access_token')).toBe('access-after-retry');
158
+ expect(res.expiresAt).toBeGreaterThan(0);
159
+ });
160
+
161
+ it('does NOT retry a deterministic 4xx — surfaces it immediately', async () => {
162
+ const vault = makeVault();
163
+ let calls = 0;
164
+ __setClaudeFetch(async () => {
165
+ calls++;
166
+ return jsonResponse({ error: { type: 'invalid_request', message: 'invalid_grant' } }, 400);
167
+ });
168
+
169
+ await expect(claudeLogin(makeCtx(vault, ['', 'AUTHCODE']))).rejects.toThrow(/HTTP 400/);
170
+ expect(calls).toBe(1);
171
+ });
172
+
173
+ it('gives a transient-retry hint after exhausting attempts on persistent 5xx', async () => {
174
+ const vault = makeVault();
175
+ let calls = 0;
176
+ __setClaudeFetch(async () => {
177
+ calls++;
178
+ return jsonResponse({ error: { type: 'api_error', message: 'Internal server error' } }, 500);
179
+ });
180
+
181
+ await expect(claudeLogin(makeCtx(vault, ['', 'AUTHCODE']))).rejects.toThrow(/after 3 attempts/);
182
+ expect(calls).toBe(3);
183
+ });
184
+
185
+ it('splits a pasted `code#state` and rejects a mismatched state', async () => {
186
+ const vault = makeVault();
187
+ // The internally-generated state will never equal "WRONG", so this must throw.
188
+ await expect(claudeLogin(makeCtx(vault, ['', 'THECODE#WRONGSTATE']))).rejects.toThrow(/state/i);
189
+ });
190
+
191
+ it('omits expiresAt entirely for a pasted setup-token (no false "expired")', async () => {
192
+ const vault = makeVault();
193
+ const res = await claudeLogin(makeCtx(vault, ['sk-ant-oat-PASTED']));
194
+ // Absent expiry must be omitted, not collapsed to 0 (which reads as expired).
195
+ expect('expiresAt' in res).toBe(false);
196
+ });
197
+
198
+ it('omits expiresAt when the token response has no expires_in', async () => {
199
+ const vault = makeVault();
200
+ __setClaudeFetch(async () =>
201
+ jsonResponse({ access_token: 'access-noexp', refresh_token: 'r', token_type: 'Bearer' }),
202
+ );
203
+ const res = await claudeLogin(makeCtx(vault, ['', 'AUTHCODE']));
204
+ expect(res.accountId).toBeUndefined();
205
+ expect('expiresAt' in res).toBe(false);
206
+ });
207
+
208
+ it('treats a 200 with a non-JSON body as transient and retries', async () => {
209
+ const vault = makeVault();
210
+ let calls = 0;
211
+ __setClaudeFetch(async () => {
212
+ calls++;
213
+ if (calls < 2) {
214
+ return {
215
+ ok: true,
216
+ status: 200,
217
+ json: async () => {
218
+ throw new SyntaxError('Unexpected token < in JSON');
219
+ },
220
+ text: async () => '<html>captive portal</html>',
221
+ } as unknown as Response;
222
+ }
223
+ return jsonResponse({ access_token: 'recovered', token_type: 'Bearer', expires_in: 3600 });
224
+ });
225
+
226
+ const res = await claudeLogin(makeCtx(vault, ['', 'AUTHCODE']));
227
+ expect(calls).toBe(2); // bad 200 retried, second attempt wins
228
+ expect(vault.store.get('oauth/claude-code/access_token')).toBe('recovered');
229
+ expect(res.expiresAt).toBeGreaterThan(0);
230
+ });
231
+
232
+ it('surfaces a transient-retry hint when every 200 body is non-JSON', async () => {
233
+ const vault = makeVault();
234
+ let calls = 0;
235
+ __setClaudeFetch(async () => {
236
+ calls++;
237
+ return {
238
+ ok: true,
239
+ status: 200,
240
+ json: async () => {
241
+ throw new SyntaxError('Unexpected token < in JSON');
242
+ },
243
+ text: async () => '',
244
+ } as unknown as Response;
245
+ });
246
+ await expect(claudeLogin(makeCtx(vault, ['', 'AUTHCODE']))).rejects.toThrow(/after 3 attempts/);
247
+ expect(calls).toBe(3);
248
+ });
249
+
250
+ it('treats a 200 JSON primitive/array body as transient and retries', async () => {
251
+ const vault = makeVault();
252
+ let calls = 0;
253
+ __setClaudeFetch(async () => {
254
+ calls++;
255
+ if (calls < 2) return jsonResponse([] as unknown, 200); // array, not an object
256
+ return jsonResponse({ access_token: 'after-array', token_type: 'Bearer', expires_in: 3600 });
257
+ });
258
+ const res = await claudeLogin(makeCtx(vault, ['', 'AUTHCODE']));
259
+ expect(calls).toBe(2);
260
+ expect(res.expiresAt).toBeGreaterThan(0);
261
+ });
262
+
263
+ it('aborts a hung connection on the per-attempt deadline and retries', async () => {
264
+ const vault = makeVault();
265
+ const hang = hangingFetch();
266
+ let calls = 0;
267
+ __setClaudeFetch(async (url, init) => {
268
+ calls++;
269
+ // First attempt: a half-open socket that never responds — must be aborted
270
+ // by the deadline (not hang forever), reclassified as a transient, retried.
271
+ if (calls < 2) return hang(url, init);
272
+ return jsonResponse({ access_token: 'after-timeout', token_type: 'Bearer', expires_in: 3600 });
273
+ });
274
+
275
+ const res = await claudeLogin(makeCtx(vault, ['', 'AUTHCODE']));
276
+ expect(calls).toBe(2);
277
+ expect(vault.store.get('oauth/claude-code/access_token')).toBe('after-timeout');
278
+ expect(res.expiresAt).toBeGreaterThan(0);
279
+ });
280
+
281
+ it('surfaces the transient-retry hint when every attempt hangs past the deadline', async () => {
282
+ const vault = makeVault();
283
+ const hang = hangingFetch();
284
+ let calls = 0;
285
+ __setClaudeFetch((url, init) => {
286
+ calls++;
287
+ return hang(url, init);
288
+ });
289
+ await expect(claudeLogin(makeCtx(vault, ['', 'AUTHCODE']))).rejects.toThrow(/after 3 attempts/);
290
+ expect(calls).toBe(3);
291
+ });
292
+
293
+ it('refuses without a TTY prompt', async () => {
294
+ const vault = makeVault();
295
+ const ctx: ProviderAuthContext = { vault, headless: true, write: () => {} };
296
+ await expect(claudeLogin(ctx)).rejects.toThrow(/interactive terminal/i);
297
+ });
298
+ });
299
+
300
+ describe('token lifecycle', () => {
301
+ it('ensureFreshClaudeTokens returns null when nothing is stored', async () => {
302
+ expect(await ensureFreshClaudeTokens(makeVault())).toBeNull();
303
+ });
304
+
305
+ it('ensureFreshClaudeTokens returns a non-expired token without refreshing', async () => {
306
+ const vault = makeVault();
307
+ await storeTokenSet(
308
+ vault,
309
+ 'claude-code',
310
+ { accessToken: 'a', refreshToken: 'r', expiresAt: Date.now() + 3_600_000, tokenType: 'Bearer' },
311
+ META,
312
+ );
313
+ // __setClaudeFetch is the throwing default — proving no refresh happens.
314
+ const fresh = await ensureFreshClaudeTokens(vault);
315
+ expect(fresh).toMatchObject({ accessToken: 'a', canRefresh: true });
316
+ });
317
+
318
+ it('ensureFreshClaudeTokens refreshes + persists when expired', async () => {
319
+ const vault = makeVault();
320
+ await storeTokenSet(
321
+ vault,
322
+ 'claude-code',
323
+ { accessToken: 'old', refreshToken: 'r-old', expiresAt: Date.now() - 1000, tokenType: 'Bearer' },
324
+ META,
325
+ );
326
+ __setClaudeFetch(async (_url, init) => {
327
+ expect(JSON.parse(String(init?.body ?? '{}'))).toMatchObject({
328
+ grant_type: 'refresh_token',
329
+ refresh_token: 'r-old',
330
+ });
331
+ return jsonResponse({ access_token: 'new', refresh_token: 'r-new', expires_in: 3600, token_type: 'Bearer' });
332
+ });
333
+ const fresh = await ensureFreshClaudeTokens(vault);
334
+ expect(fresh?.accessToken).toBe('new');
335
+ // Rotated refresh token persisted before returning.
336
+ expect(vault.store.get('oauth/claude-code/refresh_token')).toBe('r-new');
337
+ });
338
+
339
+ it('coalesces concurrent refreshes — one IdP call, both consumers get the rotated token', async () => {
340
+ const vault = makeVault();
341
+ await storeTokenSet(
342
+ vault,
343
+ 'claude-code',
344
+ { accessToken: 'old', refreshToken: 'r-old', expiresAt: Date.now() - 1000, tokenType: 'Bearer' },
345
+ META,
346
+ );
347
+ let refreshCalls = 0;
348
+ __setClaudeFetch(async () => {
349
+ refreshCalls++;
350
+ return jsonResponse({ access_token: 'new', refresh_token: 'r-new', expires_in: 3600, token_type: 'Bearer' });
351
+ });
352
+
353
+ const [a, b] = await Promise.all([
354
+ ensureFreshClaudeTokens(vault),
355
+ ensureFreshClaudeTokens(vault),
356
+ ]);
357
+
358
+ // The single-use refresh_token must be burned exactly once; the loser of
359
+ // the race re-reads the winner's rotated bundle under the lock.
360
+ expect(refreshCalls).toBe(1);
361
+ expect(a?.accessToken).toBe('new');
362
+ expect(b?.accessToken).toBe('new');
363
+ expect(vault.store.get('oauth/claude-code/refresh_token')).toBe('r-new');
364
+ });
365
+
366
+ it('recovers from invalid_grant by retrying once with the refresh_token another process rotated in', async () => {
367
+ const vault = makeVault();
368
+ await storeTokenSet(
369
+ vault,
370
+ 'claude-code',
371
+ { accessToken: 'old', refreshToken: 'r-dead', expiresAt: Date.now() - 1000, tokenType: 'Bearer' },
372
+ META,
373
+ );
374
+ const attempted: string[] = [];
375
+ __setClaudeFetch(async (_url, init) => {
376
+ const body = JSON.parse(String(init?.body ?? '{}')) as { refresh_token?: string };
377
+ attempted.push(body.refresh_token ?? '');
378
+ if (body.refresh_token === 'r-dead') {
379
+ // Simulate the cross-process race: another moxxy already rotated the
380
+ // stored bundle, so OUR copy of the refresh token is single-use-spent.
381
+ vault.store.set('oauth/claude-code/refresh_token', 'r-fresh');
382
+ return jsonResponse({ error: 'invalid_grant' }, 400);
383
+ }
384
+ return jsonResponse({ access_token: 'new-2', refresh_token: 'r-next', expires_in: 3600, token_type: 'Bearer' });
385
+ });
386
+
387
+ const res = await refreshClaudeAccessToken(vault);
388
+
389
+ expect(attempted).toEqual(['r-dead', 'r-fresh']);
390
+ expect(res.token).toBe('new-2');
391
+ expect(vault.store.get('oauth/claude-code/refresh_token')).toBe('r-next');
392
+ });
393
+
394
+ it('does NOT retry an invalid_grant when the stored refresh_token is unchanged (true re-auth case)', async () => {
395
+ const vault = makeVault();
396
+ await storeTokenSet(
397
+ vault,
398
+ 'claude-code',
399
+ { accessToken: 'old', refreshToken: 'r-revoked', expiresAt: Date.now() - 1000, tokenType: 'Bearer' },
400
+ META,
401
+ );
402
+ let calls = 0;
403
+ __setClaudeFetch(async () => {
404
+ calls++;
405
+ return jsonResponse({ error: 'invalid_grant' }, 400);
406
+ });
407
+ await expect(refreshClaudeAccessToken(vault)).rejects.toThrow(/HTTP 400/);
408
+ expect(calls).toBe(1);
409
+ });
410
+
411
+ it('refreshClaudeAccessToken throws when no refresh_token is stored', async () => {
412
+ const vault = makeVault();
413
+ await storeTokenSet(vault, 'claude-code', { accessToken: 'a', tokenType: 'Bearer' }, META);
414
+ await expect(refreshClaudeAccessToken(vault)).rejects.toThrow(/refresh/i);
415
+ });
416
+
417
+ it('claudeStatus / claudeLogout report and clear stored creds', async () => {
418
+ const vault = makeVault();
419
+ const ctx = makeCtx(vault, []);
420
+ await storeTokenSet(
421
+ vault,
422
+ 'claude-code',
423
+ { accessToken: 'a', expiresAt: Date.now() + 1000, tokenType: 'Bearer' },
424
+ { ...META, extras: { account_email: 'me@example.com' } },
425
+ );
426
+ const status = await claudeStatus(ctx);
427
+ expect(status).toMatchObject({ accountId: 'me@example.com' });
428
+ expect(await claudeLogout(ctx)).toBe(true);
429
+ expect(await claudeStatus(ctx)).toBeNull();
430
+ });
431
+
432
+ it('claudeStatus omits expiresAt for a stored setup-token (no false "expired")', async () => {
433
+ const vault = makeVault();
434
+ const ctx = makeCtx(vault, []);
435
+ // A pasted setup-token has no expiry — status must not surface 0/epoch.
436
+ await storeTokenSet(vault, 'claude-code', { accessToken: 'a', tokenType: 'Bearer' }, META);
437
+ const status = await claudeStatus(ctx);
438
+ expect(status).not.toBeNull();
439
+ expect('expiresAt' in (status as object)).toBe(false);
440
+ });
441
+ });