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

@@ -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";