From 014a4d51a65efa30957b72daab37bac4b3b34592 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 15:30:28 +0000 Subject: [PATCH] feat(status): add claude.ai usage fallback --- CHANGELOG.md | 1 + scripts/debug-claude-usage.ts | 299 +++++++++++++++++++++++++++++++ src/infra/provider-usage.test.ts | 64 +++++++ src/infra/provider-usage.ts | 105 +++++++++++ 4 files changed, 469 insertions(+) create mode 100644 scripts/debug-claude-usage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3cc4a8e8..1a626c42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,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`. - 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 new file mode 100644 index 000000000..462465c78 --- /dev/null +++ b/scripts/debug-claude-usage.ts @@ -0,0 +1,299 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +type Args = { + agentId: string; + reveal: boolean; + sessionKey?: string; +}; + +const mask = (value: string) => { + const compact = value.trim(); + if (!compact) return "missing"; + const edge = compact.length >= 12 ? 6 : 4; + return `${compact.slice(0, edge)}…${compact.slice(-edge)}`; +}; + +const parseArgs = (): Args => { + const args = process.argv.slice(2); + let agentId = "main"; + let reveal = false; + let sessionKey: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--agent" && args[i + 1]) { + agentId = String(args[++i]).trim() || "main"; + continue; + } + if (arg === "--reveal") { + reveal = true; + continue; + } + if (arg === "--session-key" && args[i + 1]) { + sessionKey = String(args[++i]).trim() || undefined; + continue; + } + } + + return { agentId, reveal, sessionKey }; +}; + +const loadAuthProfiles = (agentId: string) => { + const stateRoot = + process.env.CLAWDBOT_STATE_DIR?.trim() || path.join(os.homedir(), ".clawdbot"); + const authPath = path.join(stateRoot, "agents", agentId, "agent", "auth-profiles.json"); + if (!fs.existsSync(authPath)) throw new Error(`Missing: ${authPath}`); + const store = JSON.parse(fs.readFileSync(authPath, "utf8")) as { + profiles?: Record; + }; + return { authPath, store }; +}; + +const pickAnthropicToken = (store: { + profiles?: Record; +}): { profileId: string; token: string } | null => { + const profiles = store.profiles ?? {}; + 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 }; + } + return null; +}; + +const fetchAnthropicOAuthUsage = async (token: string) => { + const res = await fetch("https://api.anthropic.com/api/oauth/usage", { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + "User-Agent": "clawdbot-debug", + }, + }); + const text = await res.text(); + return { status: res.status, contentType: res.headers.get("content-type"), text }; +}; + +const chromeServiceNameForPath = (cookiePath: string): string => { + if (cookiePath.includes("/Arc/")) return "Arc Safe Storage"; + if (cookiePath.includes("/BraveSoftware/")) return "Brave Safe Storage"; + if (cookiePath.includes("/Microsoft Edge/")) return "Microsoft Edge Safe Storage"; + if (cookiePath.includes("/Chromium/")) return "Chromium Safe Storage"; + return "Chrome Safe Storage"; +}; + +const readKeychainPassword = (service: string): string | null => { + try { + const out = execFileSync( + "security", + ["find-generic-password", "-w", "-s", service], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, + ); + const pw = out.trim(); + return pw ? pw : null; + } catch { + return null; + } +}; + +const decryptChromeCookieValue = (encrypted: Buffer, service: string): string | null => { + if (encrypted.length < 4) return null; + const prefix = encrypted.subarray(0, 3).toString("utf8"); + if (prefix !== "v10" && prefix !== "v11") return null; + + const password = readKeychainPassword(service); + if (!password) return null; + + const key = crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1"); + const iv = Buffer.alloc(16, 0x20); + const data = encrypted.subarray(3); + + try { + const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv); + decipher.setAutoPadding(true); + const decrypted = Buffer.concat([decipher.update(data), decipher.final()]); + const text = decrypted.toString("utf8").trim(); + return text ? text : null; + } catch { + return null; + } +}; + +const queryChromeCookieDb = (cookieDb: string): string | null => { + try { + const out = execFileSync( + "sqlite3", + [ + "-readonly", + cookieDb, + ` + SELECT + COALESCE(NULLIF(value,''), hex(encrypted_value)) + FROM cookies + WHERE (host_key LIKE '%claude.ai%' OR host_key = '.claude.ai') + AND name = 'sessionKey' + LIMIT 1; + `, + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, + ).trim(); + if (!out) return null; + if (out.startsWith("sk-ant-")) return out; + const hex = out.replace(/[^0-9A-Fa-f]/g, ""); + if (!hex) return null; + const buf = Buffer.from(hex, "hex"); + const service = chromeServiceNameForPath(cookieDb); + const decrypted = decryptChromeCookieValue(buf, service); + return decrypted && decrypted.startsWith("sk-ant-") ? decrypted : null; + } catch { + return null; + } +}; + +const queryFirefoxCookieDb = (cookieDb: string): string | null => { + try { + const out = execFileSync( + "sqlite3", + [ + "-readonly", + cookieDb, + ` + SELECT value + FROM moz_cookies + WHERE (host LIKE '%claude.ai%' OR host = '.claude.ai') + AND name = 'sessionKey' + LIMIT 1; + `, + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, + ).trim(); + return out && out.startsWith("sk-ant-") ? out : null; + } catch { + return null; + } +}; + +const findClaudeSessionKey = (): { sessionKey: string; source: string } | null => { + if (process.platform !== "darwin") return null; + + const firefoxRoot = path.join( + os.homedir(), + "Library", + "Application Support", + "Firefox", + "Profiles", + ); + if (fs.existsSync(firefoxRoot)) { + for (const entry of fs.readdirSync(firefoxRoot)) { + const db = path.join(firefoxRoot, entry, "cookies.sqlite"); + if (!fs.existsSync(db)) continue; + const value = queryFirefoxCookieDb(db); + if (value) return { sessionKey: value, source: `firefox:${db}` }; + } + } + + const chromeCandidates = [ + path.join(os.homedir(), "Library", "Application Support", "Google", "Chrome"), + path.join(os.homedir(), "Library", "Application Support", "Chromium"), + path.join(os.homedir(), "Library", "Application Support", "Arc"), + path.join(os.homedir(), "Library", "Application Support", "BraveSoftware", "Brave-Browser"), + path.join(os.homedir(), "Library", "Application Support", "Microsoft Edge"), + ]; + + for (const root of chromeCandidates) { + if (!fs.existsSync(root)) continue; + const profiles = fs + .readdirSync(root) + .filter((name) => name === "Default" || name.startsWith("Profile ")); + for (const profile of profiles) { + const db = path.join(root, profile, "Cookies"); + if (!fs.existsSync(db)) continue; + const value = queryChromeCookieDb(db); + if (value) return { sessionKey: value, source: `chromium:${db}` }; + } + } + + return null; +}; + +const fetchClaudeWebUsage = async (sessionKey: string) => { + const headers = { + Cookie: `sessionKey=${sessionKey}`, + Accept: "application/json", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + }; + const orgRes = await fetch("https://claude.ai/api/organizations", { headers }); + const orgText = await orgRes.text(); + if (!orgRes.ok) { + return { ok: false as const, step: "organizations", status: orgRes.status, body: orgText }; + } + const orgs = JSON.parse(orgText) as Array<{ uuid?: string }>; + const orgId = orgs?.[0]?.uuid; + if (!orgId) { + return { ok: false as const, step: "organizations", status: 200, body: orgText }; + } + + const usageRes = await fetch(`https://claude.ai/api/organizations/${orgId}/usage`, { headers }); + const usageText = await usageRes.text(); + return usageRes.ok + ? { ok: true as const, orgId, body: usageText } + : { ok: false as const, step: "usage", status: usageRes.status, body: usageText }; +}; + +const main = async () => { + const opts = parseArgs(); + 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"); + } 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(""); + } + + const sessionKey = + opts.sessionKey?.trim() || + process.env.CLAUDE_AI_SESSION_KEY?.trim() || + process.env.CLAUDE_WEB_SESSION_KEY?.trim() || + findClaudeSessionKey()?.sessionKey; + const source = + opts.sessionKey + ? "--session-key" + : process.env.CLAUDE_AI_SESSION_KEY || process.env.CLAUDE_WEB_SESSION_KEY + ? "env" + : findClaudeSessionKey()?.source ?? "auto"; + + if (!sessionKey) { + console.log("Claude web: no sessionKey found (try --session-key or export CLAUDE_AI_SESSION_KEY)"); + return; + } + + console.log( + `Claude web: sessionKey=${opts.reveal ? sessionKey : mask(sessionKey)} (source: ${source})`, + ); + const web = await fetchClaudeWebUsage(sessionKey); + if (!web.ok) { + console.log(`Claude web: ${web.step} HTTP ${web.status}`); + console.log(String(web.body).slice(0, 400).replace(/\s+/g, " ").trim()); + return; + } + console.log(`Claude web: org=${web.orgId} OK`); + console.log(web.body.slice(0, 400).replace(/\s+/g, " ").trim()); +}; + +await main(); diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index 6e9bef7a9..a1c01ac88 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -264,4 +264,68 @@ describe("provider usage loading", () => { else process.env.CLAWDBOT_STATE_DIR = stateSnapshot; } }); + + 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"; + try { + 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) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url.includes("api.anthropic.com/api/oauth/usage")) { + return makeResponse(403, { + type: "error", + error: { + type: "permission_error", + message: + "OAuth token does not meet scope requirement user:profile", + }, + }); + } + if (url.includes("claude.ai/api/organizations/org-1/usage")) { + return makeResponse(200, { + five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, + seven_day: { utilization: 40, resets_at: "2026-01-08T01:00:00Z" }, + seven_day_opus: { utilization: 5 }, + }); + } + if (url.includes("claude.ai/api/organizations")) { + return makeResponse(200, [{ uuid: "org-1", name: "Test" }]); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + auth: [{ provider: "anthropic", token: "sk-ant-oauth-1" }], + fetch: mockFetch, + }); + + expect(summary.providers).toHaveLength(1); + const claude = summary.providers[0]; + expect(claude?.provider).toBe("anthropic"); + expect(claude?.windows.some((w) => w.label === "5h")).toBe(true); + expect(claude?.windows.some((w) => w.label === "Week")).toBe(true); + } finally { + if (cookieSnapshot === undefined) + delete process.env.CLAUDE_AI_SESSION_KEY; + else process.env.CLAUDE_AI_SESSION_KEY = cookieSnapshot; + } + }); }); diff --git a/src/infra/provider-usage.ts b/src/infra/provider-usage.ts index 8d840a306..dfef8ab36 100644 --- a/src/infra/provider-usage.ts +++ b/src/infra/provider-usage.ts @@ -49,6 +49,13 @@ type ClaudeUsageResponse = { seven_day_opus?: { utilization?: number }; }; +type ClaudeWebOrganizationsResponse = Array<{ + uuid?: string; + name?: string; +}>; + +type ClaudeWebUsageResponse = ClaudeUsageResponse; + type CopilotUsageResponse = { quota_snapshots?: { premium_interactions?: { percent_remaining?: number | null }; @@ -191,6 +198,20 @@ function formatResetRemaining(targetMs?: number, now?: number): string | null { }).format(new Date(targetMs)); } +function resolveClaudeWebSessionKey(): string | undefined { + const direct = + process.env.CLAUDE_AI_SESSION_KEY?.trim() ?? + process.env.CLAUDE_WEB_SESSION_KEY?.trim(); + if (direct?.startsWith("sk-ant-")) return direct; + + const cookieHeader = process.env.CLAUDE_WEB_COOKIE?.trim(); + if (!cookieHeader) return undefined; + const stripped = cookieHeader.replace(/^cookie:\\s*/i, ""); + const match = stripped.match(/(?:^|;\\s*)sessionKey=([^;\\s]+)/i); + const value = match?.[1]?.trim(); + return value?.startsWith("sk-ant-") ? value : undefined; +} + function pickPrimaryWindow(windows: UsageWindow[]): UsageWindow | undefined { if (windows.length === 0) return undefined; return windows.reduce((best, next) => @@ -317,6 +338,21 @@ async function fetchClaudeUsage( } catch { // ignore parse errors } + + // Claude CLI setup-token yields tokens that can be used for inference + // but may not include user:profile scope required by the OAuth usage endpoint. + // When a claude.ai browser sessionKey is available, fall back to the web API. + if ( + res.status === 403 && + message?.includes("scope requirement user:profile") + ) { + const sessionKey = resolveClaudeWebSessionKey(); + if (sessionKey) { + const web = await fetchClaudeWebUsage(sessionKey, timeoutMs, fetchFn); + if (web) return web; + } + } + const suffix = message ? `: ${message}` : ""; return { provider: "anthropic", @@ -364,6 +400,75 @@ async function fetchClaudeUsage( }; } +async function fetchClaudeWebUsage( + sessionKey: string, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + const headers: Record = { + Cookie: `sessionKey=${sessionKey}`, + Accept: "application/json", + }; + + const orgRes = await fetchJson( + "https://claude.ai/api/organizations", + { headers }, + timeoutMs, + fetchFn, + ); + if (!orgRes.ok) return null; + + const orgs = (await orgRes.json()) as ClaudeWebOrganizationsResponse; + const orgId = orgs?.[0]?.uuid?.trim(); + if (!orgId) return null; + + const usageRes = await fetchJson( + `https://claude.ai/api/organizations/${orgId}/usage`, + { headers }, + timeoutMs, + fetchFn, + ); + if (!usageRes.ok) return null; + + const data = (await usageRes.json()) as ClaudeWebUsageResponse; + const windows: UsageWindow[] = []; + + if (data.five_hour?.utilization !== undefined) { + windows.push({ + label: "5h", + usedPercent: clampPercent(data.five_hour.utilization), + resetAt: data.five_hour.resets_at + ? new Date(data.five_hour.resets_at).getTime() + : undefined, + }); + } + + if (data.seven_day?.utilization !== undefined) { + windows.push({ + label: "Week", + usedPercent: clampPercent(data.seven_day.utilization), + resetAt: data.seven_day.resets_at + ? new Date(data.seven_day.resets_at).getTime() + : undefined, + }); + } + + const modelWindow = data.seven_day_sonnet || data.seven_day_opus; + if (modelWindow?.utilization !== undefined) { + windows.push({ + label: data.seven_day_sonnet ? "Sonnet" : "Opus", + usedPercent: clampPercent(modelWindow.utilization), + }); + } + + if (windows.length === 0) return null; + return { + provider: "anthropic", + displayName: PROVIDER_LABELS.anthropic, + windows, + }; +} + async function fetchCopilotUsage( token: string, timeoutMs: number,