diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a19d24c3..569a3f1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,7 +90,7 @@ - Status: show Verbose/Elevated only when enabled. - Status: filter usage summary to the active model provider. - Status: map model providers to usage sources so unrelated usage doesn’t appear. -- Status: allow Claude usage snapshot fallback via claude.ai session cookie (`CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`) when OAuth token lacks `user:profile`. +- Status: fix Claude usage snapshots when `anthropic:default` is a setup-token lacking `user:profile` by preferring `anthropic:claude-cli`; optional claude.ai fallback via `CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`. - Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated. - Commands: keep multi-directive messages from clearing directive handling. - Commands: warn when /elevated runs in direct (unsandboxed) runtime. diff --git a/scripts/debug-claude-usage.ts b/scripts/debug-claude-usage.ts index 462465c78..69a951853 100644 --- a/scripts/debug-claude-usage.ts +++ b/scripts/debug-claude-usage.ts @@ -53,16 +53,17 @@ const loadAuthProfiles = (agentId: string) => { return { authPath, store }; }; -const pickAnthropicToken = (store: { +const pickAnthropicTokens = (store: { profiles?: Record; -}): { profileId: string; token: string } | null => { +}): Array<{ profileId: string; token: string }> => { const profiles = store.profiles ?? {}; + const found: Array<{ profileId: string; token: string }> = []; for (const [id, cred] of Object.entries(profiles)) { if (cred?.provider !== "anthropic") continue; const token = cred.type === "token" ? cred.token?.trim() : undefined; - if (token) return { profileId: id, token }; + if (token) found.push({ profileId: id, token }); } - return null; + return found; }; const fetchAnthropicOAuthUsage = async (token: string) => { @@ -79,6 +80,34 @@ const fetchAnthropicOAuthUsage = async (token: string) => { return { status: res.status, contentType: res.headers.get("content-type"), text }; }; +const readClaudeCliKeychain = (): { + accessToken: string; + expiresAt?: number; + scopes?: string[]; +} | null => { + if (process.platform !== "darwin") return null; + try { + const raw = execFileSync( + "security", + ["find-generic-password", "-s", "Claude Code-credentials", "-w"], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, + ); + const parsed = JSON.parse(raw.trim()) as Record; + const oauth = parsed?.claudeAiOauth as Record | undefined; + if (!oauth || typeof oauth !== "object") return null; + const accessToken = oauth.accessToken; + if (typeof accessToken !== "string" || !accessToken.trim()) return null; + const expiresAt = + typeof oauth.expiresAt === "number" ? oauth.expiresAt : undefined; + const scopes = Array.isArray(oauth.scopes) + ? oauth.scopes.filter((v): v is string => typeof v === "string") + : undefined; + return { accessToken, expiresAt, scopes }; + } catch { + return null; + } +}; + const chromeServiceNameForPath = (cookiePath: string): string => { if (cookiePath.includes("/Arc/")) return "Arc Safe Storage"; if (cookiePath.includes("/BraveSoftware/")) return "Brave Safe Storage"; @@ -251,19 +280,34 @@ const main = async () => { const { authPath, store } = loadAuthProfiles(opts.agentId); console.log(`Auth file: ${authPath}`); - const anthropic = pickAnthropicToken(store); - if (!anthropic) { - console.log("Anthropic: no token profiles found in auth-profiles.json"); + const keychain = readClaudeCliKeychain(); + if (keychain) { + console.log( + `Claude CLI keychain: accessToken=${opts.reveal ? keychain.accessToken : mask(keychain.accessToken)} scopes=${keychain.scopes?.join(",") ?? "(unknown)"}`, + ); + const oauth = await fetchAnthropicOAuthUsage(keychain.accessToken); + console.log( + `OAuth usage (keychain): HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`, + ); + console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim()); } else { - console.log( - `Anthropic: ${anthropic.profileId} token=${opts.reveal ? anthropic.token : mask(anthropic.token)}`, - ); - const oauth = await fetchAnthropicOAuthUsage(anthropic.token); - console.log( - `OAuth usage: HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`, - ); - console.log(oauth.text.slice(0, 400).replace(/\s+/g, " ").trim()); - console.log(""); + console.log("Claude CLI keychain: missing/unreadable"); + } + + const anthropic = pickAnthropicTokens(store); + if (anthropic.length === 0) { + console.log("Auth profiles: no Anthropic token profiles found"); + } else { + for (const entry of anthropic) { + console.log( + `Auth profiles: ${entry.profileId} token=${opts.reveal ? entry.token : mask(entry.token)}`, + ); + const oauth = await fetchAnthropicOAuthUsage(entry.token); + console.log( + `OAuth usage (${entry.profileId}): HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`, + ); + console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim()); + } } const sessionKey = diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index 8c0719b84..91ef4d675 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -227,6 +227,85 @@ describe("provider usage loading", () => { ); }); + it("prefers claude-cli token for Anthropic usage snapshots", async () => { + await withTempHome( + async () => { + const stateDir = process.env.CLAWDBOT_STATE_DIR; + if (!stateDir) throw new Error("Missing CLAWDBOT_STATE_DIR"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + profiles: { + "anthropic:default": { + type: "token", + provider: "anthropic", + token: "token-default", + expires: Date.UTC(2100, 0, 1, 0, 0, 0), + }, + "anthropic:claude-cli": { + type: "token", + provider: "anthropic", + token: "token-cli", + expires: Date.UTC(2100, 0, 1, 0, 0, 0), + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const makeResponse = (status: number, body: unknown): Response => { + const payload = + typeof body === "string" ? body : JSON.stringify(body); + const headers = + typeof body === "string" + ? undefined + : { "Content-Type": "application/json" }; + return new Response(payload, { status, headers }); + }; + + const mockFetch = vi.fn< + Parameters, + ReturnType + >(async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url.includes("api.anthropic.com/api/oauth/usage")) { + const headers = (init?.headers ?? {}) as Record; + expect(headers.Authorization).toBe("Bearer token-cli"); + return makeResponse(200, { + five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, + }); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + providers: ["anthropic"], + agentDir, + fetch: mockFetch, + }); + + expect(summary.providers).toHaveLength(1); + expect(summary.providers[0]?.provider).toBe("anthropic"); + expect(summary.providers[0]?.windows[0]?.label).toBe("5h"); + expect(mockFetch).toHaveBeenCalled(); + }, + { prefix: "clawdbot-provider-usage-" }, + ); + }); + it("falls back to claude.ai web usage when OAuth scope is missing", async () => { const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY; process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1"; diff --git a/src/infra/provider-usage.ts b/src/infra/provider-usage.ts index dfef8ab36..9e081dbd9 100644 --- a/src/infra/provider-usage.ts +++ b/src/infra/provider-usage.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { + CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, resolveApiKeyForProfile, @@ -802,7 +803,16 @@ async function resolveOAuthToken(params: { provider: params.provider, }); - for (const profileId of order) { + // Claude CLI creds are the only Anthropic tokens that reliably include the + // `user:profile` scope required for the OAuth usage endpoint. + const candidates = + params.provider === "anthropic" ? [CLAUDE_CLI_PROFILE_ID, ...order] : order; + const deduped: string[] = []; + for (const entry of candidates) { + if (!deduped.includes(entry)) deduped.push(entry); + } + + for (const profileId of deduped) { const cred = store.profiles[profileId]; if (!cred || (cred.type !== "oauth" && cred.type !== "token")) continue; try {