fix(status): use claude-cli token for usage
This commit is contained in:
@@ -90,7 +90,7 @@
|
|||||||
- Status: show Verbose/Elevated only when enabled.
|
- Status: show Verbose/Elevated only when enabled.
|
||||||
- Status: filter usage summary to the active model provider.
|
- Status: filter usage summary to the active model provider.
|
||||||
- Status: map model providers to usage sources so unrelated usage doesn’t appear.
|
- 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: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
|
||||||
- Commands: keep multi-directive messages from clearing directive handling.
|
- Commands: keep multi-directive messages from clearing directive handling.
|
||||||
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
|
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
|
||||||
|
|||||||
@@ -53,16 +53,17 @@ const loadAuthProfiles = (agentId: string) => {
|
|||||||
return { authPath, store };
|
return { authPath, store };
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickAnthropicToken = (store: {
|
const pickAnthropicTokens = (store: {
|
||||||
profiles?: Record<string, { provider?: string; type?: string; token?: string; key?: string }>;
|
profiles?: Record<string, { provider?: string; type?: string; token?: string; key?: string }>;
|
||||||
}): { profileId: string; token: string } | null => {
|
}): Array<{ profileId: string; token: string }> => {
|
||||||
const profiles = store.profiles ?? {};
|
const profiles = store.profiles ?? {};
|
||||||
|
const found: Array<{ profileId: string; token: string }> = [];
|
||||||
for (const [id, cred] of Object.entries(profiles)) {
|
for (const [id, cred] of Object.entries(profiles)) {
|
||||||
if (cred?.provider !== "anthropic") continue;
|
if (cred?.provider !== "anthropic") continue;
|
||||||
const token = cred.type === "token" ? cred.token?.trim() : undefined;
|
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) => {
|
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 };
|
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<string, unknown>;
|
||||||
|
const oauth = parsed?.claudeAiOauth as Record<string, unknown> | 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 => {
|
const chromeServiceNameForPath = (cookiePath: string): string => {
|
||||||
if (cookiePath.includes("/Arc/")) return "Arc Safe Storage";
|
if (cookiePath.includes("/Arc/")) return "Arc Safe Storage";
|
||||||
if (cookiePath.includes("/BraveSoftware/")) return "Brave Safe Storage";
|
if (cookiePath.includes("/BraveSoftware/")) return "Brave Safe Storage";
|
||||||
@@ -251,19 +280,34 @@ const main = async () => {
|
|||||||
const { authPath, store } = loadAuthProfiles(opts.agentId);
|
const { authPath, store } = loadAuthProfiles(opts.agentId);
|
||||||
console.log(`Auth file: ${authPath}`);
|
console.log(`Auth file: ${authPath}`);
|
||||||
|
|
||||||
const anthropic = pickAnthropicToken(store);
|
const keychain = readClaudeCliKeychain();
|
||||||
if (!anthropic) {
|
if (keychain) {
|
||||||
console.log("Anthropic: no token profiles found in auth-profiles.json");
|
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 {
|
} else {
|
||||||
console.log(
|
console.log("Claude CLI keychain: missing/unreadable");
|
||||||
`Anthropic: ${anthropic.profileId} token=${opts.reveal ? anthropic.token : mask(anthropic.token)}`,
|
}
|
||||||
);
|
|
||||||
const oauth = await fetchAnthropicOAuthUsage(anthropic.token);
|
const anthropic = pickAnthropicTokens(store);
|
||||||
console.log(
|
if (anthropic.length === 0) {
|
||||||
`OAuth usage: HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`,
|
console.log("Auth profiles: no Anthropic token profiles found");
|
||||||
);
|
} else {
|
||||||
console.log(oauth.text.slice(0, 400).replace(/\s+/g, " ").trim());
|
for (const entry of anthropic) {
|
||||||
console.log("");
|
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 =
|
const sessionKey =
|
||||||
|
|||||||
@@ -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<typeof fetch>,
|
||||||
|
ReturnType<typeof fetch>
|
||||||
|
>(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<string, string>;
|
||||||
|
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 () => {
|
it("falls back to claude.ai web usage when OAuth scope is missing", async () => {
|
||||||
const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY;
|
const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY;
|
||||||
process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1";
|
process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CLAUDE_CLI_PROFILE_ID,
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
listProfilesForProvider,
|
listProfilesForProvider,
|
||||||
resolveApiKeyForProfile,
|
resolveApiKeyForProfile,
|
||||||
@@ -802,7 +803,16 @@ async function resolveOAuthToken(params: {
|
|||||||
provider: params.provider,
|
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];
|
const cred = store.profiles[profileId];
|
||||||
if (!cred || (cred.type !== "oauth" && cred.type !== "token")) continue;
|
if (!cred || (cred.type !== "oauth" && cred.type !== "token")) continue;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user