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:
5
.changeset/fix-claude-keychain-credentials.md
Normal file
5
.changeset/fix-claude-keychain-credentials.md
Normal 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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user