fix(server): show Claude usage again on macOS (#3597)
* fix(server): show Claude usage again on macOS Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(server): keep an incomplete Keychain item from hiding a working one Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(server): tighten Claude keychain fix --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5
Mohamed Boudra
parent
28a963795b
commit
efa25ad3fe
@@ -0,0 +1,87 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./claude.js";
|
||||||
|
|
||||||
|
const SERVICE = "Claude Code-credentials";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("claudeKeychainAccount", () => {
|
||||||
|
it("uses a supported username verbatim", () => {
|
||||||
|
expect(claudeKeychainAccount("thomas.benoit")).toBe("thomas.benoit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to claude-code-user when the username carries a rejected character", () => {
|
||||||
|
expect(claudeKeychainAccount("first.last@example.com")).toBe("claude-code-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the username from $USER when no account is given", () => {
|
||||||
|
vi.stubEnv("USER", "ci-runner");
|
||||||
|
expect(claudeKeychainAccount()).toBe("ci-runner");
|
||||||
|
|
||||||
|
vi.stubEnv("USER", "first.last@example.com");
|
||||||
|
expect(claudeKeychainAccount()).toBe("claude-code-user");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readClaudeKeychainCredentials", () => {
|
||||||
|
it("looks the item up by account and stops there when it exists", async () => {
|
||||||
|
const run = vi.fn(async () => JSON.stringify({ claudeAiOauth: { accessToken: "at_fresh" } }));
|
||||||
|
|
||||||
|
const credentials = await readClaudeKeychainCredentials(run, "claude-code-user");
|
||||||
|
|
||||||
|
expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_fresh" } });
|
||||||
|
expect(run).toHaveBeenCalledTimes(1);
|
||||||
|
expect(run).toHaveBeenCalledWith([
|
||||||
|
"find-generic-password",
|
||||||
|
"-a",
|
||||||
|
"claude-code-user",
|
||||||
|
"-w",
|
||||||
|
"-s",
|
||||||
|
SERVICE,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the account-less lookup when no item matches the account", async () => {
|
||||||
|
const run = vi.fn(async (args: string[]) =>
|
||||||
|
args.includes("-a") ? null : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const credentials = await readClaudeKeychainCredentials(run, "claude-code-user");
|
||||||
|
|
||||||
|
expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } });
|
||||||
|
expect(run).toHaveBeenNthCalledWith(2, ["find-generic-password", "-w", "-s", SERVICE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through when the account item carries no access token", async () => {
|
||||||
|
const run = vi.fn(async (args: string[]) =>
|
||||||
|
args.includes("-a")
|
||||||
|
? JSON.stringify({ claudeAiOauth: { subscriptionType: "team" } })
|
||||||
|
: JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const credentials = await readClaudeKeychainCredentials(run, "claude-code-user");
|
||||||
|
|
||||||
|
expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } });
|
||||||
|
expect(run).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null when the Keychain holds no item at all", async () => {
|
||||||
|
const run = vi.fn(async () => null);
|
||||||
|
|
||||||
|
expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tries the next lookup when the item does not parse", async () => {
|
||||||
|
const run = vi.fn(async (args: string[]) =>
|
||||||
|
args.includes("-a")
|
||||||
|
? "not-json"
|
||||||
|
: JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toEqual({
|
||||||
|
claudeAiOauth: { accessToken: "at_legacy" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { existsSync, promises as fs } from "node:fs";
|
import { existsSync, promises as fs } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir, userInfo } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
@@ -297,21 +297,54 @@ function scopedWindows(limits: ScopedLimit[]): ProviderUsageWindow[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readClaudeKeychainCredentials(): Promise<unknown | null> {
|
type ClaudeKeychainCommandRunner = (args: string[]) => Promise<string | null>;
|
||||||
|
|
||||||
|
// Keep this in sync with Claude Code's Keychain account derivation.
|
||||||
|
const CLAUDE_KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
||||||
|
const CLAUDE_KEYCHAIN_FALLBACK_ACCOUNT = "claude-code-user";
|
||||||
|
|
||||||
|
export function claudeKeychainAccount(
|
||||||
|
user: string = process.env["USER"] || userInfo().username,
|
||||||
|
): string {
|
||||||
|
return CLAUDE_KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_KEYCHAIN_FALLBACK_ACCOUNT;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSecurityCommand(args: string[]): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync("security", args, {
|
||||||
"security",
|
timeout: CLAUDE_KEYCHAIN_TIMEOUT_MS,
|
||||||
["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"],
|
});
|
||||||
{ timeout: CLAUDE_KEYCHAIN_TIMEOUT_MS },
|
return stdout.trim() || null;
|
||||||
);
|
|
||||||
const raw = stdout.trim();
|
|
||||||
if (!raw) return null;
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read Claude Code's account-specific Keychain item, then try the legacy lookup. */
|
||||||
|
export async function readClaudeKeychainCredentials(
|
||||||
|
run: ClaudeKeychainCommandRunner = runSecurityCommand,
|
||||||
|
account: string = claudeKeychainAccount(),
|
||||||
|
): Promise<unknown | null> {
|
||||||
|
const lookups = [
|
||||||
|
["find-generic-password", "-a", account, "-w", "-s", CLAUDE_KEYCHAIN_SERVICE],
|
||||||
|
["find-generic-password", "-w", "-s", CLAUDE_KEYCHAIN_SERVICE],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const args of lookups) {
|
||||||
|
const raw = await run(args);
|
||||||
|
if (!raw) continue;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const creds = ClaudeCredentialsSchema.safeParse(parsed);
|
||||||
|
if (creds.success && creds.data.claudeAiOauth?.accessToken) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export class ClaudeQuotaProvider implements ProviderUsageFetcher {
|
export class ClaudeQuotaProvider implements ProviderUsageFetcher {
|
||||||
readonly providerId = "claude";
|
readonly providerId = "claude";
|
||||||
readonly displayName = "Claude";
|
readonly displayName = "Claude";
|
||||||
|
|||||||
Reference in New Issue
Block a user