fix(FN-668): add macOS keychain support for Claude credentials

- Implement readClaudeKeychainCredentials() to read from macOS keychain\n- Add fallback to keychain when legacy credential files don't exist\n- Add tests for keychain credential reading success and failure paths\n- Create changeset for usage dropdown credential detection fix
This commit is contained in:
gsxdsm
2026-03-31 23:29:05 -07:00
parent fb65d4bf0b
commit 2a0c660aa8
3 changed files with 177 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Fix Claude CLI credentials detection in usage dropdown. Added support for reading credentials from macOS keychain when legacy credential files don't exist, ensuring the usage dropdown correctly detects authenticated users for modern Claude Code installations.

View File

@@ -18,11 +18,18 @@ vi.mock("node:fs", () => ({
readFileSync: (...args: any[]) => mockReadFileSync(...args),
}));
// Mock child_process
const mockExecFileSync = vi.fn();
vi.mock("node:child_process", () => ({
execFileSync: (...args: any[]) => mockExecFileSync(...args),
}));
describe("usage", () => {
beforeEach(() => {
clearUsageCache();
mockRequest.mockClear();
mockReadFileSync.mockClear();
mockExecFileSync.mockClear();
vi.stubEnv("HOME", "/home/testuser");
});
@@ -88,6 +95,9 @@ describe("usage", () => {
mockReadFileSync.mockImplementation(() => {
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude");
@@ -97,6 +107,141 @@ describe("usage", () => {
expect(claude!.error).toContain("No Claude CLI credentials");
});
it("reads credentials from macOS keychain when file paths fail", async () => {
const mockResponse = {
five_hour: {
utilization: 30.0,
resets_at: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
},
seven_day: {
utilization: 15.0,
resets_at: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(),
},
};
// File paths fail
mockReadFileSync.mockImplementation(() => {
throw new Error("File not found");
});
// Keychain succeeds
mockExecFileSync.mockImplementation(() => {
return JSON.stringify({
claudeAiOauth: {
accessToken: "keychain-token",
scopes: ["user:profile"],
subscriptionType: "pro",
},
});
});
// Mock https request
const mockReq = {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
mockRequest.mockImplementation((options: any, callback: any) => {
const mockRes = {
statusCode: 200,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from(JSON.stringify(mockResponse)));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
expect(claude.plan).toBe("Pro");
expect(claude.windows).toHaveLength(2);
// Verify keychain command was called with correct arguments
expect(mockExecFileSync).toHaveBeenCalledWith(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf-8", timeout: 5000 }
);
});
it("falls back to no-auth when both file and keychain fail", async () => {
mockReadFileSync.mockImplementation(() => {
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("no-auth");
expect(claude.error).toContain("No Claude CLI credentials");
});
it("parses keychain credentials with rateLimitTier for plan detection", async () => {
const mockResponse = {
five_hour: {
utilization: 25.0,
resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
},
};
mockReadFileSync.mockImplementation(() => {
throw new Error("File not found");
});
// Keychain with rateLimitTier instead of subscriptionType
mockExecFileSync.mockImplementation(() => {
return JSON.stringify({
claudeAiOauth: {
accessToken: "keychain-token",
scopes: ["user:profile"],
rateLimitTier: "default_claude_max_20x",
},
});
});
const mockReq = {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
mockRequest.mockImplementation((options: any, callback: any) => {
const mockRes = {
statusCode: 200,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from(JSON.stringify(mockResponse)));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
expect(claude.plan).toBe("Max"); // Should detect "max" from rateLimitTier
});
it("detects missing scope error", async () => {
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes("claude")) {
@@ -107,6 +252,9 @@ describe("usage", () => {
}
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude");

View File

@@ -1,6 +1,8 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as https from "node:https";
import * as child_process from "node:child_process";
import { promisify } from "node:util";
/**
* Pace information for weekly usage windows
@@ -209,6 +211,23 @@ function decodeJwtPayload(token: string): any {
// ── Claude fetcher ─────────────────────────────────────────────────────────
/**
* Read Claude credentials from macOS keychain.
* Returns the parsed credentials object or null if not found/error.
*/
function readClaudeKeychainCredentials(): any | null {
try {
const result = child_process.execFileSync(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf-8", timeout: 5000 }
);
return JSON.parse(result.trim());
} catch {
return null;
}
}
async function fetchClaudeUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Claude",
@@ -231,6 +250,11 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
} catch {}
}
// Fallback to macOS keychain if file credentials not found
if (!creds) {
creds = readClaudeKeychainCredentials();
}
const oauthCreds = creds?.claudeAiOauth || creds;
if (!oauthCreds?.accessToken) {
usage.error = "No Claude CLI credentials — run 'claude' to login";