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:
@@ -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