feat(status): add claude.ai usage fallback

This commit is contained in:
Peter Steinberger
2026-01-09 15:30:28 +00:00
parent 922ca2ee1c
commit 014a4d51a6
4 changed files with 469 additions and 0 deletions

View File

@@ -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 doesnt 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.

View File

@@ -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<string, { provider?: string; type?: string; token?: string; key?: string }>;
};
return { authPath, store };
};
const pickAnthropicToken = (store: {
profiles?: Record<string, { provider?: string; type?: string; token?: string; key?: string }>;
}): { 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();

View File

@@ -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<typeof fetch>,
ReturnType<typeof fetch>
>(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;
}
});
});

View File

@@ -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<ProviderUsageSnapshot | null> {
const headers: Record<string, string> = {
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,