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
1 change: 1 addition & 0 deletions src/lib/subscriptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ async function readCodexAuthBundle({ home, env } = {}) {

module.exports = {
collectLocalSubscriptions,
detectClaudeCodeCredentialsPresence,
detectClaudeCodeSubscriptionDetails,
readClaudeCodeAccessToken,
readCodexAccessToken,
Expand Down
19 changes: 17 additions & 2 deletions src/lib/usage-limits.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const { performance } = require("node:perf_hooks");
const { promisify } = require("node:util");

const {
detectClaudeCodeCredentialsPresence,
detectClaudeCodeSubscriptionDetails,
readClaudeCodeAccessToken,
readCodexAccessToken,
Expand Down Expand Up @@ -188,6 +189,10 @@ function extractClaudeScopedWeekly(body) {
return out.length > 0 ? out : null;
}

// Shared by the 401 path below and by the blank-credential path in getUsageLimits:
// both mean "the CLI login no longer works", and both are fixed the same way.
const CLAUDE_AUTH_EXPIRED_MESSAGE = "Claude token expired — run `claude` once to refresh.";

async function fetchClaudeUsageLimits(accessToken, { fetchImpl = fetch, maxAttempts = 3 } = {}) {
const url = "https://api.anthropic.com/api/oauth/usage";
const headers = {
Expand All @@ -198,7 +203,7 @@ async function fetchClaudeUsageLimits(accessToken, { fetchImpl = fetch, maxAttem
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetchImpl(url, { method: "GET", headers });
if (res.status === 401) {
const err = new Error("Claude token expired — run `claude` once to refresh.");
const err = new Error(CLAUDE_AUTH_EXPIRED_MESSAGE);
err.code = "AUTH_EXPIRED";
throw err;
}
Expand Down Expand Up @@ -3243,7 +3248,17 @@ async function fetchUsageLimitsUncached({

let claude;
if (!claudeToken) {
claude = { configured: false };
// Claude Code blanks `accessToken`/`refreshToken` in place when its login expires
// (macOS Keychain item and .credentials.json alike) instead of removing the entry,
// so "no token" is ambiguous: never signed in, or signed in and expired. An entry
// that still exists means the latter — reporting `configured: false` there hides the
// whole Claude section while the usage bars (parsed from local logs, no auth needed)
// keep updating, which reads as "TokenTracker doesn't support my plan" rather than
// "your CLI login expired". Existence-only probe: no secret is read here.
const credentialsPresent = detectClaudeCodeCredentialsPresence({ platform, securityRunner, home });
claude = credentialsPresent
? { configured: true, error: CLAUDE_AUTH_EXPIRED_MESSAGE, auth_action_required: "reauth" }
: { configured: false };
} else if (freshClaudeCache) {
claude = freshClaudeCache;
} else if (claudeResult && claudeResult.status === "fulfilled") {
Expand Down
78 changes: 78 additions & 0 deletions test/usage-limits.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,84 @@ describe("getUsageLimits", () => {
}
});

it("flags reauth when the credential entry exists but its token fields are blank", async () => {
resetUsageLimitsCache();
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-limits-claude-blank-token-"));
try {
// What an expired Claude Code login actually leaves behind: the entry survives,
// the secrets are emptied in place.
const claudeDir = path.join(tmp, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "",
refreshToken: "",
expiresAt: 0,
subscriptionType: "max",
},
}),
);

// Throwing alone proves nothing here: provider failures are collected with
// allSettled and this branch never reads claudeResult, so a forbidden call
// would be swallowed. Track it and assert it never happened.
let usageApiCalled = false;
const result = await getUsageLimits({
home: tmp,
platform: "linux",
providerTimeoutMs: 1000,
securityRunner() {
return { status: 1, stdout: "" };
},
commandRunner() {
return { status: 1, stdout: "" };
},
fetchImpl(url) {
if (typeof url === "string" && url === "https://api.anthropic.com/api/oauth/usage") {
usageApiCalled = true;
throw new Error("must not call the usage API without a token");
}
return pendingUnlessCodexReset(url);
},
});

assert.equal(result.claude.configured, true);
assert.match(result.claude.error, /token expired/i);
assert.equal(result.claude.auth_action_required, "reauth");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert.equal(usageApiCalled, false);
} finally {
resetUsageLimitsCache();
fs.rmSync(tmp, { recursive: true, force: true });
}
});

it("stays unconfigured when no Claude credential entry exists at all", async () => {
resetUsageLimitsCache();
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-limits-claude-no-creds-"));
try {
const result = await getUsageLimits({
home: tmp,
platform: "linux",
providerTimeoutMs: 1000,
securityRunner() {
return { status: 1, stdout: "" };
},
commandRunner() {
return { status: 1, stdout: "" };
},
fetchImpl: pendingUnlessCodexReset,
});

assert.equal(result.claude.configured, false);
assert.equal(result.claude.auth_action_required, undefined);
} finally {
resetUsageLimitsCache();
fs.rmSync(tmp, { recursive: true, force: true });
}
});

for (const status of [401, 403, 404]) {
it(`Codex reset headers do not fetch reset list when wham ${status} returns no-data`, async () => {
resetUsageLimitsCache();
Expand Down
Loading