Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions src/main/services/LimitsService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createServer } from "node:net";
import { homedir } from "node:os";
import { homedir, userInfo } from "node:os";
import { join } from "node:path";
import type {
LimitProviderId,
Expand All @@ -23,6 +24,7 @@ const MAX_LINE_BYTES = 1_048_576;
const MAX_BUFFER_BYTES = MAX_LINE_BYTES * 2;
const MAX_WINDOWS = 12;
const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
const OPENCODE_GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits";

Expand Down Expand Up @@ -287,6 +289,11 @@ export class LimitsService {

export interface ClaudeUsageReadOptions {
configRoot?: string;
environment?: Readonly<Record<string, string | undefined>>;
platform?: NodeJS.Platform;
homeDirectory?: string;
account?: string;
keychainRequest?: (service: string, account: string) => Promise<string>;
request?: (
url: string,
accessToken: string,
Expand All @@ -298,8 +305,10 @@ export async function readClaudeUsage(
clientVersion: string,
options: ClaudeUsageReadOptions = {}
): Promise<unknown> {
const configRoot = options.configRoot ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
const credentials = await readCredentialFile(join(configRoot, ".credentials.json"), "not-authenticated");
const environment = options.environment ?? process.env;
const defaultConfigRoot = join(options.homeDirectory ?? homedir(), ".claude");
const configRoot = (options.configRoot ?? environment.CLAUDE_CONFIG_DIR) || defaultConfigRoot;
const credentials = await readClaudeCredentials(configRoot, defaultConfigRoot, environment, options);
const oauth = isRecord(credentials.claudeAiOauth) ? credentials.claudeAiOauth : null;
const accessToken = cleanSecret(oauth?.accessToken);
// Missing credentials describe only this runtime user; they do not prove anything about the user's plan.
Expand All @@ -311,6 +320,59 @@ export async function readClaudeUsage(
});
}

async function readClaudeCredentials(
configRoot: string,
defaultConfigRoot: string,
environment: Readonly<Record<string, string | undefined>>,
options: ClaudeUsageReadOptions
): Promise<Record<string, unknown>> {
if ((options.platform ?? process.platform) !== "darwin") {
return readCredentialFile(join(configRoot, ".credentials.json"), "not-authenticated");
}

const secureStorageRoot = Object.hasOwn(environment, "CLAUDE_SECURESTORAGE_CONFIG_DIR")
? environment.CLAUDE_SECURESTORAGE_CONFIG_DIR || defaultConfigRoot
: configRoot;
const service = secureStorageRoot === defaultConfigRoot
? CLAUDE_KEYCHAIN_SERVICE
: `${CLAUDE_KEYCHAIN_SERVICE}-${createHash("sha256")
.update(secureStorageRoot.normalize("NFC"))
.digest("hex")
.slice(0, 8)}`;
const account = options.account ?? userInfo().username;
const keychainRequest = options.keychainRequest ?? readMacOSClaudeCredentials;

try {
const parsed: unknown = JSON.parse(await keychainRequest(service, account));
if (!isRecord(parsed)) throw new LimitsAdapterError("protocol-error");
return parsed;
} catch (error) {
if (error instanceof LimitsAdapterError) throw error;
throw new LimitsAdapterError(keychainErrorReason(error));
}
}

function keychainErrorReason(error: unknown): LimitUnavailableReason {
if (!isRecord(error)) return "protocol-error";
if (error.killed === true || error.code === "ETIMEDOUT") return "timeout";
if (error.code === 44 || error.code === "44") return "not-authenticated";
return "protocol-error";
}

function readMacOSClaudeCredentials(service: string, account: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
"/usr/bin/security",
["find-generic-password", "-s", service, "-a", account, "-w"],
{ encoding: "utf8", maxBuffer: MAX_LINE_BYTES, timeout: REQUEST_TIMEOUT_MS },
(error, stdout) => {
if (error) reject(error);
else resolve(stdout);
}
);
});
}

async function readOpenCodeGoUsage(clientVersion: string): Promise<unknown> {
const credentials = await readFirstCredentialFile(openCodeAuthPaths(), "subscription-required");
const go = isRecord(credentials["opencode-go"]) ? credentials["opencode-go"] : null;
Expand Down Expand Up @@ -924,6 +986,7 @@ export function normalizeClaudeLimits(raw: unknown): LimitWindow[] {
resetsAt
}];
});

}

export function normalizeKimiLimits(raw: unknown): LimitWindow[] {
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/src/features/home/HomeZone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,10 @@ function LimitRow({ row, locale, now }: { row: HomeLimitRow; locale: LocaleId; n
<ProviderIcon provider={row.provider} size="medium" />
{row.window ? (
<span className="limit-row__metric">
<strong>{formatResetCountdown(row.window.resetsAt, now, locale)}</strong>
<span className="limit-row__summary">
<strong>{formatPercent(row.window.usedPercent, locale)}</strong>
<span>{formatResetCountdown(row.window.resetsAt, now, locale)}</span>
</span>
<span className="limit-row__track" aria-hidden="true">
<i className="limit-row__fill" style={{ width: `${row.window.usedPercent}%` }} />
</span>
Expand Down
8 changes: 6 additions & 2 deletions src/renderer/src/styles/app.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion tests/appearance-settings.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ test("four or more HOME limit rows switch to bounded compact geometry", () => {
assert.match(styles, /\.limit-row\s*\{[^}]*min-height:\s*0;[^}]*overflow:\s*hidden;[^}]*container:\s*limit-row\s*\/\s*size;/u);
assert.match(styles, /@container\s+limit-row\s*\(max-height:\s*48px\)/u);
assert.match(styles, /\.limit-row\s*>\s*\.provider-icon--medium\s*\{[^}]*--provider-icon-art-size:\s*24px;[^}]*height:\s*32px;/u);
assert.match(styles, /\.limit-row__metric strong\s*\{[^}]*font-size:\s*22px;/u);
assert.match(source, /formatPercent\(row\.window\.usedPercent,\s*locale\)[\s\S]*formatResetCountdown\(row\.window\.resetsAt,\s*now,\s*locale\)/u);
assert.match(styles, /\.limit-row__summary strong\s*\{[^}]*font-size:\s*22px;/u);
assert.match(styles, /\.limit-row__summary\s*>\s*span\s*\{[^}]*font-size:\s*11px;/u);
assert.match(styles, /\.limit-row__track\s*\{[^}]*height:\s*4px;/u);
});
163 changes: 161 additions & 2 deletions tests/claude-limits-adapter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test("Claude limits use the current user's OAuth token and perform the usage req

const result = await readClaudeUsage("1.3.0", {
configRoot,
platform: "linux",
request: async (url, accessToken, headers) => {
request = { url, accessToken, headers };
return payload;
Expand Down Expand Up @@ -49,11 +50,11 @@ test("missing Claude credentials mean sign-in is unavailable, not that a subscri
}), "utf8");

await assert.rejects(
readClaudeUsage("1.3.0", { configRoot: missingRoot }),
readClaudeUsage("1.3.0", { configRoot: missingRoot, platform: "linux" }),
(error) => error instanceof Error && error.message === "not-authenticated"
);
await assert.rejects(
readClaudeUsage("1.3.0", { configRoot: emptyRoot }),
readClaudeUsage("1.3.0", { configRoot: emptyRoot, platform: "linux" }),
(error) => error instanceof Error && error.message === "not-authenticated"
);
} finally {
Expand All @@ -63,3 +64,161 @@ test("missing Claude credentials mean sign-in is unavailable, not that a subscri
]);
}
});

test("default macOS Claude profile reads the default Keychain service for the current account", async () => {
const profileToken = ["default", "profile", "oauth"].join("-");
let keychainQuery = null;
let requestedToken = null;

await readClaudeUsage("1.5.0", {
platform: "darwin",
homeDirectory: "/test-home",
environment: {},
account: "tester",
keychainRequest: async (service, account) => {
keychainQuery = { service, account };
return JSON.stringify({ claudeAiOauth: { accessToken: profileToken } });
},
request: async (_url, accessToken) => {
requestedToken = accessToken;
return { five_hour: { utilization: 7 } };
}
});

assert.deepEqual(keychainQuery, { service: "Claude Code-credentials", account: "tester" });
assert.equal(requestedToken, profileToken);
});

test("custom CLAUDE_CONFIG_DIR selects its normalized hashed macOS Keychain service", async () => {
const configRoot = "/test-home/Claude-Profiles/Cafe\u0301";
const profileToken = ["custom", "profile", "oauth"].join("-");
let keychainQuery = null;

await readClaudeUsage("1.5.0", {
platform: "darwin",
homeDirectory: "/test-home",
environment: { CLAUDE_CONFIG_DIR: configRoot },
account: "profile-user",
keychainRequest: async (service, account) => {
keychainQuery = { service, account };
return JSON.stringify({ claudeAiOauth: { accessToken: profileToken } });
},
request: async () => ({ five_hour: { utilization: 7 } })
});

assert.deepEqual(keychainQuery, {
service: "Claude Code-credentials-49260767",
account: "profile-user"
});
});

test("CLAUDE_SECURESTORAGE_CONFIG_DIR overrides the config-root Keychain service", async () => {
const secureStorageRoot = "/Volumes/Claude Credentials/profile-a";
const profileToken = ["secure", "storage", "oauth"].join("-");
let requestedService = null;

await readClaudeUsage("1.5.0", {
platform: "darwin",
homeDirectory: "/test-home",
environment: {
CLAUDE_CONFIG_DIR: "/test-home/.claude-work",
CLAUDE_SECURESTORAGE_CONFIG_DIR: secureStorageRoot
},
account: "tester",
keychainRequest: async (service) => {
requestedService = service;
return JSON.stringify({ claudeAiOauth: { accessToken: profileToken } });
},
request: async () => ({ five_hour: { utilization: 7 } })
});

assert.equal(requestedService, "Claude Code-credentials-6aefe1a7");
});

test("empty CLAUDE_SECURESTORAGE_CONFIG_DIR explicitly selects the default Keychain service", async () => {
const profileToken = ["default", "store", "oauth"].join("-");
let requestedService = null;

await readClaudeUsage("1.5.0", {
platform: "darwin",
homeDirectory: "/test-home",
environment: {
CLAUDE_CONFIG_DIR: "/test-home/.claude-work",
CLAUDE_SECURESTORAGE_CONFIG_DIR: ""
},
account: "tester",
keychainRequest: async (service) => {
requestedService = service;
return JSON.stringify({ claudeAiOauth: { accessToken: profileToken } });
},
request: async () => ({ five_hour: { utilization: 7 } })
});

assert.equal(requestedService, "Claude Code-credentials");
});

for (const platform of ["linux", "win32"]) {
test(`${platform} Claude limits keep using config-root credential files`, async () => {
const configRoot = await mkdtemp(join(tmpdir(), `canvastty-claude-${platform}-`));
const fileToken = [platform, "file", "oauth"].join("-");
try {
await writeFile(join(configRoot, ".credentials.json"), JSON.stringify({
claudeAiOauth: { accessToken: fileToken }
}), "utf8");
let requestedToken = null;

await readClaudeUsage("1.5.0", {
platform,
environment: {
CLAUDE_CONFIG_DIR: configRoot,
CLAUDE_SECURESTORAGE_CONFIG_DIR: "/ignored/on/non-macos"
},
keychainRequest: async () => {
throw new Error("Keychain must not be queried");
},
request: async (_url, accessToken) => {
requestedToken = accessToken;
return { five_hour: { utilization: 7 } };
}
});

assert.equal(requestedToken, fileToken);
} finally {
await rm(configRoot, { recursive: true, force: true });
}
});
}

test("macOS Keychain failures preserve missing-item, timeout, and protocol semantics", async () => {
const cases = [
{ failure: Object.assign(new Error("missing"), { code: 44 }), reason: "not-authenticated" },
{ failure: Object.assign(new Error("timed out"), { killed: true, signal: "SIGTERM" }), reason: "timeout" },
{ failure: Object.assign(new Error("security failed"), { code: 1 }), reason: "protocol-error" }
];

for (const { failure, reason } of cases) {
await assert.rejects(
readClaudeUsage("1.5.0", {
platform: "darwin",
homeDirectory: "/test-home",
environment: {},
account: "tester",
keychainRequest: async () => { throw failure; }
}),
(error) => error instanceof Error && error.message === reason
);
}
});

test("malformed macOS Keychain credential JSON is a protocol error", async () => {
await assert.rejects(
readClaudeUsage("1.5.0", {
platform: "darwin",
homeDirectory: "/test-home",
environment: {},
account: "tester",
keychainRequest: async () => "not-json"
}),
(error) => error instanceof Error && error.message === "protocol-error"
);
});