diff --git a/.changeset/fix-claude-usage-login-timeout.md b/.changeset/fix-claude-usage-login-timeout.md new file mode 100644 index 0000000000..7964b62da9 --- /dev/null +++ b/.changeset/fix-claude-usage-login-timeout.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Report when the server Claude CLI needs login instead of waiting a minute and showing a false usage timeout. +category: fix +dev: Detects Claude Code 2.1.x unauthenticated and API-billing session-stat screens during the PTY quota fallback and exits immediately. diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index 247ecfb3a4..166180099a 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -1095,6 +1095,121 @@ describe("usage", () => { _resetSleepFn(); }); + it("reports the server login requirement immediately when the Claude CLI usage screen is unauthenticated", async () => { + setupClaudeMocks({ + credFileContent: { + accessToken: "test-token", + scopes: ["user:profile"], + }, + }); + + _setSleepFn(async () => {}); + nodePtyMocks.available = true; + const kill = vi.fn(); + nodePtyMocks.spawn.mockImplementation(() => ({ + write: vi.fn(), + kill, + onData: vi.fn((handler: (data: string) => void) => { + handler("Not logged in · Run /login\nSession\nTotal cost: $0.0000"); + }), + onExit: vi.fn((handler: () => void) => { + handler(); + }), + })); + + const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + mockRequest.mockImplementation((_options: any, callback: any) => { + const mockRes = { + statusCode: 429, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from('{"error":"rate_limited"}')); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + + /* + FNXC:UsageTesting 2026-07-21-21:30: + Claude usage probes must restore injected sleep behavior even when an assertion fails so later provider tests cannot inherit the mock. + */ + try { + const providers = await fetchAllProviderUsage(); + const claude = providers.find((provider) => provider.name === "Claude")!; + + expect(claude.status).toBe("error"); + expect(claude.error).toBe( + "Claude CLI has no subscription quota session on the Fusion server. Run `claude /login` there, then refresh Usage.", + ); + expect(kill).toHaveBeenCalledOnce(); + } finally { + _resetSleepFn(); + } + }); + + it("reports the server login requirement when Claude 2.1.x shows API billing session statistics", async () => { + setupClaudeMocks({ + credFileContent: { + accessToken: "test-token", + scopes: ["user:profile"], + }, + }); + + _setSleepFn(async () => {}); + nodePtyMocks.available = true; + const kill = vi.fn(); + nodePtyMocks.spawn.mockImplementation(() => { + let dataHandler: ((data: string) => void) | undefined; + const process = { + write: vi.fn((input: string) => { + if (input === "/usage\r") { + dataHandler?.("Settings Status Config Usage Stats\nSession\nTotal cost: $0.0000\nUsage: 0 input, 0 output"); + } + }), + kill, + onData: vi.fn((handler: (data: string) => void) => { + dataHandler = handler; + handler("Claude Code v2.1.215\nSonnet 5 · API Usage Billing\n❯ ? for shortcuts"); + }), + onExit: vi.fn(), + }; + return process; + }); + + const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + mockRequest.mockImplementation((_options: any, callback: any) => { + const mockRes = { + statusCode: 429, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from('{"error":"rate_limited"}')); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + + vi.useFakeTimers(); + try { + const providersPromise = fetchAllProviderUsage(); + await vi.advanceTimersByTimeAsync(1_500); + const providers = await providersPromise; + const claude = providers.find((provider) => provider.name === "Claude")!; + + expect(claude.status).toBe("error"); + expect(claude.error).toBe( + "Claude CLI has no subscription quota session on the Fusion server. Run `claude /login` there, then refresh Usage.", + ); + expect(kill).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + _resetSleepFn(); + } + }); + it("falls back to CLI parsing on 429 rate limit", async () => { setupClaudeMocks({ credFileContent: { diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index bf8e171bff..d9c41b2e47 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -707,6 +707,31 @@ async function fetchClaudeUsageViaCli(): Promise { return; } + /* + * FNXC:ClaudeUsage 2026-07-19-18:00: + * Claude Code 2.1.x can render its settings Usage tab while the CLI is + * unauthenticated. That screen contains ordinary session statistics but + * no subscription quota percentages, so the old detector waited the + * full minute and falsely described the result as a timeout. Surface the + * TUI's explicit login state immediately and tell remote-dashboard users + * where the login must happen. + */ + const isClaudeLoginRequired = /not logged in\s*[·•-]?\s*run\s*\/login/i.test(clean); + const isApiBillingSessionStats = + sentCommand && + clean.includes("API Usage Billing") && + clean.includes("Total cost:") && + /Usage:\s*\d+\s+input/i.test(clean); + if (isClaudeLoginRequired || isApiBillingSessionStats) { + settled = true; + clearTimeout(timeout); + try { ptyProcess.kill(); } catch { + // Kill may fail if process already exited - ignore + } + reject(new Error("Claude CLI has no subscription quota session on the Fusion server. Run `claude /login` there, then refresh Usage.")); + return; + } + // Auto-approve trust prompt if ( !approvedTrust &&