diff --git a/AGENTS.md b/AGENTS.md index c009c2f..137a403 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - `--json` emits the normalized model, and `--full` is required before account identity or per-source attempts are shown. - JSON provider reports include `provider`, `label`, `source`, `windows`, and `state`; `state.retryAfter` can appear for provider rate limits, and `state.reason: keychain_access_required` plus `state.remedyCommand` can appear when a stale or unavailable Claude result is blocked by a skipped macOS Keychain prompt. - macOS Claude Keychain value reads are skipped on plain calls until a successful value read records the non-secret access marker under the quota-axi cache directory; after that, plain calls may reuse the existing grant and read live Claude quota. +- Managed-profile, Claude identity, and Codex executable-override contracts are documented in [README Security Posture](README.md#security-posture). - `--allow-keychain-prompt` is the first-time opt-in that permits the Claude Keychain value read which can prompt, and agents should relay the one-time "Always Allow" grant when `keychain_access_required` advice appears. - Codex uses `$CODEX_HOME/auth.json` or `~/.codex/auth.json` OAuth before the CLI fallback. - Codex `auth.json` support is OAuth-token only; never treat `OPENAI_API_KEY` as valid quota auth or send API keys to ChatGPT quota endpoints. diff --git a/README.md b/README.md index 44b2644..57cecdb 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ $ quota-axi --provider claude --json "state": { "status": "fresh", "stale": false, - "sourcesTried": ["oauth"], + "sourcesTried": ["oauth", "oauth-profile"], "refreshedAt": "2026-03-15T16:41:55.000Z" } } @@ -111,7 +111,7 @@ auth[7]{provider,source,path,status,error}: claude,oauth-file,~/.claude/.credentials.json,available,none claude,keychain,none,skipped,keychain_prompt_required codex,auth-json,~/.codex/auth.json,available,none - codex,cli-rpc,none,available,none + codex,cli-rpc,~/.local/bin/codex,available,none cursor,state-vscdb,~/Library/Application Support/Cursor/User/globalStorage/state.vscdb,available,none copilot,apps-json,~/.config/github-copilot/apps.json,available,none grok,auth-json,~/.grok/auth.json,available,none @@ -224,8 +224,10 @@ It is generated from `src/skill.ts`; update it with `pnpm run build:skill` and v | Quota report | `providers` | | Provider report | `provider`, `label`, `source`, `windows`, `state`, optional `plan`, and optional `credits` | | Provider report with `--full` | Optional `account` identity and per-source `attempts` | +| Account identity (`--full`) | Optional `email`, `organization`, `accountId`, and `identityStatus` | Account identity and per-source `attempts` are omitted unless `--full` is passed. +Claude `identityStatus` is `verified` only when Anthropic returns an authoritative account identifier; `email` and `organization` are display-only and must not be used for duplicate detection. ### Provider `state` @@ -293,13 +295,13 @@ Auth source entries can include `credentialPresent` when a non-secret probe conf ### Provider credential sources -| Provider | Credential sources read | -| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Claude | `~/.claude/.credentials.json`; on macOS, the `Claude Code-credentials` Keychain value with `--allow-keychain-prompt` or, after a non-secret access marker exists, on plain calls | -| Codex | `$CODEX_HOME/auth.json` or `~/.codex/auth.json` before the read-only CLI fallback | -| Cursor | `$CURSOR_STATE_DB` when set or the platform Cursor state database path | -| GitHub Copilot | `$GITHUB_COPILOT_APPS_JSON` when set or the local Copilot apps auth file | -| Grok | `$GROK_AUTH_JSON`, inline `$GROK_AUTH`, `$GROK_AUTH_PATH`, or `$GROK_HOME/auth.json` / `~/.grok/auth.json` | +| Provider | Credential sources read | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude | `$CLAUDE_CONFIG_DIR/.credentials.json` or `~/.claude/.credentials.json`; on macOS, the corresponding default or path-hashed Claude Code Keychain value with `--allow-keychain-prompt` or, after a profile-scoped non-secret access marker exists, on plain calls | +| Codex | `$CODEX_HOME/auth.json` or `~/.codex/auth.json` before the read-only CLI fallback; `$QUOTA_AXI_CODEX_BINARY` can pin that fallback to an absolute executable path | +| Cursor | `$CURSOR_STATE_DB` when set or the platform Cursor state database path | +| GitHub Copilot | `$GITHUB_COPILOT_APPS_JSON` when set or the local Copilot apps auth file | +| Grok | `$GROK_AUTH_JSON`, inline `$GROK_AUTH`, `$GROK_AUTH_PATH`, or `$GROK_HOME/auth.json` / `~/.grok/auth.json` | ### Provider notes @@ -308,11 +310,13 @@ Auth source entries can include `credentialPresent` when a non-secret probe conf - quota-axi records the non-secret access marker after any successful Keychain value read. - When that marker exists, plain calls read the Keychain value again so an already-approved "Always Allow" grant keeps live Claude quota fresh. - Without the flag or marker, quota-axi may perform a non-secret Keychain item presence check so it only suggests Keychain access when a Claude credential item exists. +- After a successful usage read, quota-axi queries Anthropic's first-party OAuth profile endpoint with the same credential. Its authoritative root `account.uuid` is exposed as `account.accountId` only in `--full` output; if that field is absent, `identityStatus` is `unverified` instead of deriving an identity from email, organization data, or cached account metadata. **Codex** - Codex `auth.json` support is OAuth-token only; API key values such as `OPENAI_API_KEY` are treated as invalid for quota usage calls and are not sent to ChatGPT usage endpoints. - It may run `codex -s read-only -a untrusted app-server` for Codex JSON-RPC fallback. +- Set `QUOTA_AXI_CODEX_BINARY` to an absolute executable path when the fallback must use a specific Codex installation. Auth inspection and the app-server probe resolve the same path, and an invalid override fails closed instead of consulting `PATH`. **Cursor** @@ -340,15 +344,15 @@ Auth source entries can include `credentialPresent` when a non-secret probe conf ### Cache -| Item | Behavior | -| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| Quota cache | Lives at `~/.cache/quota-axi/quotas.json` or under `$XDG_CACHE_HOME/quota-axi/` when `XDG_CACHE_HOME` is set. | -| Quota cache permissions | Uses `0600` file permissions. | -| Quota cache contents | Stores normalized non-secret snapshots only. | -| Claude Keychain access marker | Lives alongside the quota cache as `claude-keychain-access-granted`, uses `0600` file permissions, and contains no credential material. | -| Cached reports | Only fresh provider snapshots with windows are cached. | -| Fresh provider reports with no windows | Clear any cached snapshot for that provider, so entitlement-only reports do not leave stale quota windows behind. | -| Reports and details not cached | Failed providers, stale providers, account identity, and source attempts are not cached. | +| Item | Behavior | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Quota cache | Lives at `~/.cache/quota-axi/quotas.json` or under `$XDG_CACHE_HOME/quota-axi/` when `XDG_CACHE_HOME` is set. | +| Quota cache permissions | Uses `0600` file permissions. | +| Quota cache contents | Stores normalized non-secret snapshots only. | +| Claude Keychain access marker | Lives alongside the quota cache as `claude-keychain-access-granted` for the default profile or with an eight-character path-hash suffix for a `$CLAUDE_CONFIG_DIR` profile; uses `0600` file permissions and contains no credential material. | +| Cached reports | Only fresh provider snapshots with windows are cached. | +| Fresh provider reports with no windows | Clear any cached snapshot for that provider, so entitlement-only reports do not leave stale quota windows behind. | +| Reports and details not cached | Failed providers, stale providers, account identity, and source attempts are not cached. | ## Development diff --git a/skills/quota-axi/SKILL.md b/skills/quota-axi/SKILL.md index ab99db0..a4b9db1 100644 --- a/skills/quota-axi/SKILL.md +++ b/skills/quota-axi/SKILL.md @@ -39,6 +39,9 @@ or when comparing supported local provider headroom side by side. `quota-axi --allow-keychain-prompt` once and approve Keychain access ("Always Allow"). After that successful grant, plain `quota-axi` calls reuse the existing Keychain access marker to refresh live Claude quota without requiring the flag. +7. For a managed Codex installation, set `QUOTA_AXI_CODEX_BINARY` to its absolute executable + path. quota-axi uses that exact executable for auth inspection and the read-only app-server + fallback, and fails closed if the override is invalid. ## Usage @@ -65,6 +68,9 @@ examples: every provider failed; exit code 2 means a usage error. - Percentages are not comparable across providers - quota-axi never claims one provider's percentage equals another's. +- Claude `--full` output exposes the authoritative OAuth profile `account.uuid` as + `account.accountId` when Anthropic returns one; otherwise the account identity is explicitly + marked unverified rather than inferred. - The quota cache at `~/.cache/quota-axi/quotas.json` only ever holds normalized non-secret snapshots. Fresh provider reports with no windows clear stale provider snapshots instead of caching diff --git a/src/lib/fs.ts b/src/lib/fs.ts index c50de5e..4737147 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -1,4 +1,5 @@ import { mkdirSync, readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, relative, sep } from "node:path"; @@ -47,8 +48,11 @@ export function cacheFilePath(): string { return join(cacheDirPath(), "quotas.json"); } -export function claudeKeychainAccessMarkerPath(): string { - return join(cacheDirPath(), "claude-keychain-access-granted"); +export function claudeKeychainAccessMarkerPath(configDir?: string): string { + const suffix = configDir + ? `-${createHash("sha256").update(configDir).digest("hex").slice(0, 8)}` + : ""; + return join(cacheDirPath(), `claude-keychain-access-granted${suffix}`); } function cacheDirPath(): string { diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 496f01d..ba15180 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,4 +1,5 @@ import { chmodSync, existsSync, renameSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; import { readCachedProvider } from "../cache.js"; @@ -29,14 +30,14 @@ import { } from "./common.js"; const API_URL = "https://api.anthropic.com/api/oauth/usage"; +const PROFILE_API_URL = "https://api.anthropic.com/api/oauth/profile"; const OAUTH_BETA = "oauth-2025-04-20"; const CLAUDE_CODE_USER_AGENT = "claude-code/2.1.202"; const API_TIMEOUT_MS = 15_000; const KEYCHAIN_PROMPT_TIMEOUT_MS = 60_000; const KEYCHAIN_PRESENCE_TIMEOUT_MS = 5_000; const KEYCHAIN_ITEM_NOT_FOUND_EXIT_CODE = 44; -const CREDENTIAL_FILE = join(homedir(), ".claude", ".credentials.json"); -const KEYCHAIN_SERVICE = "Claude Code-credentials"; +const DEFAULT_KEYCHAIN_SERVICE = "Claude Code-credentials"; type ClaudeCredentials = { source: "oauth-file" | "keychain"; @@ -59,6 +60,16 @@ type CredentialState = | UnavailableCredentialState | SkippedCredentialState; type KeychainItemPresence = "present" | "missing" | "unknown"; +type ClaudeAccount = NonNullable; +type ClaudeIdentityResult = { + account: ClaudeAccount; + error?: string; +}; +type ClaudeProfileLocations = { + credentialFile: string; + keychainService: string; + keychainAccessMarker: string; +}; type RawUsageWindow = { utilization?: unknown; @@ -142,6 +153,15 @@ export async function fetchQuota( try { const quota = await fetchOauthUsage(credential); attempts[attempts.length - 1] = { source: "oauth", status: "success" }; + attempts.push( + quota.identityError + ? { + source: "oauth-profile", + status: "failed", + error: quota.identityError, + } + : { source: "oauth-profile", status: "success" }, + ); return successProvider({ provider: "claude", label: "Claude", @@ -188,14 +208,15 @@ export async function fetchQuota( export async function inspectAuth( options: ProviderOptions, ): Promise { - const states = await readCredentialStates(options); + const locations = resolveClaudeProfileLocations(); + const states = await readCredentialStates(options, locations); const sources = states.map((state): AuthSourceReport => { if (state.status === "available") { return { source: state.credentials.source, path: state.credentials.source === "oauth-file" - ? CREDENTIAL_FILE + ? locations.credentialFile : undefined, status: "available", }; @@ -238,6 +259,33 @@ export function normalizeClaudeApiUsage( return { plan, windows, refreshedAt: nowIso() }; } +export function normalizeClaudeProfile( + raw: unknown, +): ClaudeAccount | undefined { + const data = objectValue(raw); + if (!data) return undefined; + const account = objectValue(data.account); + const accountId = stringValue(account?.uuid); + if (!accountId) return undefined; + + const organization = objectValue(data.organization); + return { + accountId, + email: + stringValue(account?.email) ?? + stringValue(account?.email_address) ?? + stringValue(account?.emailAddress) ?? + stringValue(data.email_address) ?? + stringValue(data.emailAddress) ?? + stringValue(data.email), + organization: + stringValue(organization?.name) ?? + stringValue(data.organization_name) ?? + stringValue(data.organizationName), + identityStatus: "verified", + }; +} + function normalizeScopedLimits(raw: unknown): QuotaWindow[] { if (!Array.isArray(raw)) return []; const windows: QuotaWindow[] = []; @@ -309,29 +357,32 @@ function slugify(value: string): string { async function readCredentialStates( options: ProviderOptions, + locations = resolveClaudeProfileLocations(), ): Promise { const states: CredentialState[] = []; const fileState = extractCredentialState( - readJsonFileResult(CREDENTIAL_FILE), + readJsonFileResult(locations.credentialFile), "oauth-file", - CREDENTIAL_FILE, + locations.credentialFile, ); states.push(fileState); if (process.platform === "darwin") { - if (options.allowKeychainPrompt || hasKeychainAccessMarker()) { - states.push(await readKeychainCredentialState()); + if (options.allowKeychainPrompt || hasKeychainAccessMarker(locations)) { + states.push(await readKeychainCredentialState(locations)); } else { - states.push(await readSkippedKeychainCredentialState()); + states.push(await readSkippedKeychainCredentialState(locations)); } } return states; } -async function readSkippedKeychainCredentialState(): Promise { - const presence = await readKeychainItemPresence(); +async function readSkippedKeychainCredentialState( + locations: ClaudeProfileLocations, +): Promise { + const presence = await readKeychainItemPresence(locations); if (presence === "present") { return { status: "skipped", @@ -359,11 +410,13 @@ async function readSkippedKeychainCredentialState(): Promise { }; } -async function readKeychainItemPresence(): Promise { +async function readKeychainItemPresence( + locations: ClaudeProfileLocations, +): Promise { try { await execFileText( "security", - ["find-generic-password", "-s", KEYCHAIN_SERVICE], + ["find-generic-password", "-s", locations.keychainService], KEYCHAIN_PRESENCE_TIMEOUT_MS, ); return "present"; @@ -372,18 +425,20 @@ async function readKeychainItemPresence(): Promise { } } -async function readKeychainCredentialState(): Promise { +async function readKeychainCredentialState( + locations: ClaudeProfileLocations, +): Promise { let blob: string; try { blob = await execFileText( "security", - ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], + ["find-generic-password", "-s", locations.keychainService, "-w"], KEYCHAIN_PROMPT_TIMEOUT_MS, ); } catch (error) { return keychainFailureState(error); } - writeKeychainAccessMarkerBestEffort(); + writeKeychainAccessMarkerBestEffort(locations); try { return extractCredentialState( { status: "success", value: JSON.parse(blob) }, @@ -401,13 +456,15 @@ async function readKeychainCredentialState(): Promise { } } -function hasKeychainAccessMarker(): boolean { - return existsSync(claudeKeychainAccessMarkerPath()); +function hasKeychainAccessMarker(locations: ClaudeProfileLocations): boolean { + return existsSync(locations.keychainAccessMarker); } -function writeKeychainAccessMarkerBestEffort(): void { +function writeKeychainAccessMarkerBestEffort( + locations: ClaudeProfileLocations, +): void { try { - const file = claudeKeychainAccessMarkerPath(); + const file = locations.keychainAccessMarker; ensurePrivateParent(file); const temp = `${file}.${process.pid}.tmp`; writeFileSync(temp, "granted\n", { mode: 0o600 }); @@ -419,6 +476,36 @@ function writeKeychainAccessMarkerBestEffort(): void { } } +export function claudeCredentialFile(): string { + return resolveClaudeProfileLocations().credentialFile; +} + +export function claudeKeychainService(): string { + return resolveClaudeProfileLocations().keychainService; +} + +function resolveClaudeProfileLocations(): ClaudeProfileLocations { + const configuredDir = process.env.CLAUDE_CONFIG_DIR; + const configDir = (configuredDir ?? join(homedir(), ".claude")).normalize( + "NFC", + ); + const keychainConfigDir = configuredDir ? configDir : undefined; + return { + credentialFile: join(configDir, ".credentials.json"), + keychainService: keychainServiceForConfigDir(keychainConfigDir), + keychainAccessMarker: claudeKeychainAccessMarkerPath(keychainConfigDir), + }; +} + +function keychainServiceForConfigDir(configDir?: string): string { + if (!configDir) return DEFAULT_KEYCHAIN_SERVICE; + const suffix = createHash("sha256") + .update(configDir) + .digest("hex") + .slice(0, 8); + return `${DEFAULT_KEYCHAIN_SERVICE}-${suffix}`; +} + function isKeychainItemNotFound(error: unknown): boolean { return ( (error as { code?: number | string | null }).code === @@ -495,6 +582,7 @@ function extractCredentialState( async function fetchOauthUsage(credentials: ClaudeCredentials): Promise<{ plan?: string; account?: ProviderQuota["account"]; + identityError?: string; windows: QuotaWindow[]; refreshedAt: string; }> { @@ -517,12 +605,60 @@ async function fetchOauthUsage(credentials: ClaudeCredentials): Promise<{ credentials.plan, ); if (!quota) throw new Error("Claude quota unavailable"); - return quota; + const identity = await fetchOauthProfile(credentials); + return { + ...quota, + account: identity.account, + identityError: identity.error, + }; } finally { clearTimeout(timer); } } +async function fetchOauthProfile( + credentials: ClaudeCredentials, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS); + try { + const response = await fetch(PROFILE_API_URL, { + headers: { + authorization: `Bearer ${credentials.accessToken}`, + "User-Agent": CLAUDE_CODE_USER_AGENT, + "Content-Type": "application/json", + "Cache-Control": "no-cache", + accept: "application/json", + }, + signal: controller.signal, + }); + if (!response.ok) { + return unverifiedClaudeIdentity( + `identity_profile_http_${response.status}`, + ); + } + const account = normalizeClaudeProfile(await response.json()); + return account + ? { account } + : unverifiedClaudeIdentity("identity_profile_unrecognized"); + } catch (error) { + return unverifiedClaudeIdentity( + error instanceof Error && error.name === "AbortError" + ? "identity_profile_timeout" + : "identity_profile_unavailable", + ); + } finally { + clearTimeout(timer); + } +} + +function unverifiedClaudeIdentity(error: string): ClaudeIdentityResult { + return { + account: { identityStatus: "unverified" }, + error, + }; +} + // Anthropic's OAuth usage endpoint follows plain HTTP semantics: 401/403 mean // the access token no longer authenticates, and 429 means the caller must // back off, honoring the standard `Retry-After` header (RFC 9110 - either a diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 983d4fc..649a14d 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -1,9 +1,9 @@ import { homedir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { spawn } from "node:child_process"; import { readCachedProvider } from "../cache.js"; import { readJsonFileResult, type JsonFileReadResult } from "../lib/fs.js"; -import { commandExists, terminateChild } from "../lib/process.js"; +import { findCommandPath, terminateChild } from "../lib/process.js"; import { clampPercent, nowIso, @@ -35,6 +35,11 @@ const ENDPOINTS = [ const API_TIMEOUT_MS = 15_000; const CLI_TIMEOUT_MS = 15_000; const RPC_TIMEOUT_MS = 8_000; +const CODEX_BINARY_ENV = "QUOTA_AXI_CODEX_BINARY"; + +type CodexBinaryState = + | { status: "available"; path: string } + | { status: "missing"; path?: string; error?: string }; type CodexCredentials = { accessToken: string; @@ -161,13 +166,16 @@ export async function inspectAuth( ): Promise { const authFile = codexAuthFile(); const credentialState = readCredentialState(authFile); + const binary = await resolveCodexBinary(); return { provider: "codex", sources: [ credentialState.source, { source: "cli-rpc", - status: (await commandExists("codex")) ? "available" : "missing", + path: binary.path, + status: binary.status, + error: binary.status === "missing" ? binary.error : undefined, }, ], }; @@ -461,10 +469,12 @@ async function probeCodexCli(): Promise<{ credits?: ProviderQuota["credits"]; refreshedAt: string; }> { - if (!(await commandExists("codex"))) - throw new Error("Codex quota unavailable"); + const binary = await resolveCodexBinary(); + if (binary.status === "missing") { + throw new Error(codexBinaryErrorMessage(binary)); + } const child = spawn( - "codex", + binary.path, ["-s", "read-only", "-a", "untrusted", "app-server"], { stdio: ["pipe", "pipe", "pipe"], @@ -570,6 +580,45 @@ async function probeCodexCli(): Promise<{ } } +async function resolveCodexBinary(): Promise { + const configured = process.env[CODEX_BINARY_ENV]; + if (configured !== undefined) { + const path = configured.trim(); + if (!path || !isAbsolute(path)) { + return { + status: "missing", + error: "codex_binary_override_not_absolute", + }; + } + const executable = await findCommandPath(path); + if (!executable) { + return { + status: "missing", + path, + error: "codex_binary_override_not_executable", + }; + } + return { status: "available", path: executable }; + } + + const executable = await findCommandPath("codex"); + return executable + ? { status: "available", path: executable } + : { status: "missing" }; +} + +function codexBinaryErrorMessage( + binary: Extract, +): string { + if (binary.error === "codex_binary_override_not_absolute") { + return "Configured Codex binary must be an absolute executable path"; + } + if (binary.error === "codex_binary_override_not_executable") { + return "Configured Codex binary is not executable"; + } + return "Codex quota unavailable"; +} + function sendRpc( child: { stdin: { writable: boolean; write: (chunk: string) => unknown } }, id: number, diff --git a/src/render.ts b/src/render.ts index 290a2f6..b3bb7d6 100644 --- a/src/render.ts +++ b/src/render.ts @@ -59,6 +59,7 @@ export function renderQuotaToon( email: provider.account?.email ?? "hidden", organization: provider.account?.organization ?? "none", accountId: provider.account?.accountId ?? "none", + identityStatus: provider.account?.identityStatus ?? "unknown", })); const attempts = response.providers.flatMap((provider) => (provider.attempts ?? []).map((attempt) => attemptRow(provider, attempt)), diff --git a/src/skill.ts b/src/skill.ts index 9f5a2ee..601e2d5 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -80,6 +80,9 @@ or when comparing supported local provider headroom side by side. \`quota-axi --allow-keychain-prompt\` once and approve Keychain access ("Always Allow"). After that successful grant, plain \`quota-axi\` calls reuse the existing Keychain access marker to refresh live Claude quota without requiring the flag. +7. For a managed Codex installation, set \`QUOTA_AXI_CODEX_BINARY\` to its absolute executable + path. quota-axi uses that exact executable for auth inspection and the read-only app-server + fallback, and fails closed if the override is invalid. ## Usage @@ -95,6 +98,9 @@ ${TOP_HELP.trimEnd()} every provider failed; exit code 2 means a usage error. - Percentages are not comparable across providers - quota-axi never claims one provider's percentage equals another's. +- Claude \`--full\` output exposes the authoritative OAuth profile \`account.uuid\` as + \`account.accountId\` when Anthropic returns one; otherwise the account identity is explicitly + marked unverified rather than inferred. - The quota cache at \`~/.cache/quota-axi/quotas.json\` only ever holds normalized non-secret snapshots. Fresh provider reports with no windows clear stale provider snapshots instead of caching diff --git a/src/types.ts b/src/types.ts index cf6979d..191426e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,6 +55,7 @@ export type ProviderQuota = { email?: string; organization?: string; accountId?: string; + identityStatus?: "verified" | "unverified"; }; windows: QuotaWindow[]; credits?: { diff --git a/test/cache.test.ts b/test/cache.test.ts index 1529aa2..96ebf73 100644 --- a/test/cache.test.ts +++ b/test/cache.test.ts @@ -61,6 +61,7 @@ describe("quota cache", () => { payload.providers.find((provider) => provider.provider === "codex") ?.windows[0].percentUsed, ).toBe(20); + expect(payload.providers.every((provider) => !provider.account)).toBe(true); }); it("clears a stale snapshot after a fresh no-window report", () => { @@ -98,7 +99,11 @@ function quota(provider: ProviderId, percentUsed: number): ProviderQuota { refreshedAt: "2026-07-06T18:10:00Z", sourcesTried: ["oauth"], }, - account: { email: "person@example.invalid" }, + account: { + email: "person@example.invalid", + accountId: "fixture-account", + identityStatus: "verified", + }, attempts: [{ source: "oauth", status: "success" }], }; } diff --git a/test/fixtures/claude/oauth-profile.json b/test/fixtures/claude/oauth-profile.json new file mode 100644 index 0000000..4d485cc --- /dev/null +++ b/test/fixtures/claude/oauth-profile.json @@ -0,0 +1,10 @@ +{ + "account": { + "uuid": "11111111-2222-4333-8444-555555555555", + "email": "person@example.invalid" + }, + "organization": { + "uuid": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "name": "Fixture Organization" + } +} diff --git a/test/lib/fs.test.ts b/test/lib/fs.test.ts index c3cae05..00bb855 100644 --- a/test/lib/fs.test.ts +++ b/test/lib/fs.test.ts @@ -59,5 +59,11 @@ describe("cache paths", () => { expect(claudeKeychainAccessMarkerPath()).toBe( "/tmp/quota-cache/quota-axi/claude-keychain-access-granted", ); + expect(claudeKeychainAccessMarkerPath("/tmp/claude-profile")).toMatch( + /^\/tmp\/quota-cache\/quota-axi\/claude-keychain-access-granted-[0-9a-f]{8}$/, + ); + expect(claudeKeychainAccessMarkerPath("")).toBe( + "/tmp/quota-cache/quota-axi/claude-keychain-access-granted", + ); }); }); diff --git a/test/providers/claude-auth.test.ts b/test/providers/claude-auth.test.ts index 7c29228..d26f02d 100644 --- a/test/providers/claude-auth.test.ts +++ b/test/providers/claude-auth.test.ts @@ -5,6 +5,7 @@ import { rmSync, writeFileSync, } from "node:fs"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -12,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; const originalXdgCacheHome = process.env.XDG_CACHE_HOME; +const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); let tempDir: string | undefined; @@ -32,6 +34,9 @@ afterEach(() => { else process.env.USERPROFILE = originalUserProfile; if (originalXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME; else process.env.XDG_CACHE_HOME = originalXdgCacheHome; + if (originalClaudeConfigDir === undefined) + delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; if (tempDir) rmSync(tempDir, { recursive: true, force: true }); tempDir = undefined; }); @@ -52,6 +57,162 @@ function usePlatform(platform: NodeJS.Platform): void { } describe("Claude credential-state reporting", () => { + it("uses CLAUDE_CONFIG_DIR for file credentials", async () => { + const home = useTempHome(); + const configDir = join(home, "managed-claude"); + process.env.CLAUDE_CONFIG_DIR = configDir; + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "fresh-token", + expiresAt: "2035-01-01T00:00:00.000Z", + }, + }), + ); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { + status: 200, + }), + ), + ); + + const { fetchQuota, inspectAuth } = + await import("../../src/providers/claude.js"); + const auth = await inspectAuth({ allowKeychainPrompt: false }); + const result = await fetchQuota({ allowKeychainPrompt: false }); + + expect(auth.sources[0]).toMatchObject({ + source: "oauth-file", + path: join(configDir, ".credentials.json"), + status: "available", + }); + expect(result.state.status).toBe("fresh"); + }); + + it("derives the custom-config Keychain service from the literal config path", async () => { + usePlatform("darwin"); + const home = useTempHome(); + const configDir = join(home, "managed-claude"); + process.env.CLAUDE_CONFIG_DIR = configDir; + const suffix = createHash("sha256") + .update(configDir) + .digest("hex") + .slice(0, 8); + const execFileText = vi.fn(async () => ""); + vi.doMock("../../src/lib/process.js", () => ({ execFileText })); + + const { inspectAuth } = await import("../../src/providers/claude.js"); + await inspectAuth({ allowKeychainPrompt: false }); + + expect(execFileText).toHaveBeenCalledWith( + "security", + ["find-generic-password", "-s", `Claude Code-credentials-${suffix}`], + expect.any(Number), + ); + }); + + it("preserves an empty-present CLAUDE_CONFIG_DIR across profile derivations", async () => { + usePlatform("darwin"); + const home = useTempHome(); + process.env.CLAUDE_CONFIG_DIR = ""; + const { claudeKeychainAccessMarkerPath } = + await import("../../src/lib/fs.js"); + const marker = claudeKeychainAccessMarkerPath(""); + mkdirSync(dirname(marker), { recursive: true, mode: 0o700 }); + writeFileSync(marker, "granted\n", { mode: 0o600 }); + const execFileText = vi.fn(async () => + JSON.stringify({ + claudeAiOauth: { + accessToken: "fresh-keychain-token", + expiresAt: "2035-01-01T00:00:00.000Z", + }, + }), + ); + vi.doMock("../../src/lib/process.js", () => ({ execFileText })); + + const { claudeCredentialFile, inspectAuth } = + await import("../../src/providers/claude.js"); + const auth = await inspectAuth({ allowKeychainPrompt: false }); + + expect(claudeCredentialFile()).toBe(".credentials.json"); + expect(marker).toBe( + join(home, "cache", "quota-axi", "claude-keychain-access-granted"), + ); + expect(execFileText).toHaveBeenCalledWith( + "security", + ["find-generic-password", "-s", "Claude Code-credentials", "-w"], + expect.any(Number), + ); + expect(auth.sources).toContainEqual({ + source: "keychain", + status: "available", + }); + }); + + it("normalizes a decomposed CLAUDE_CONFIG_DIR before profile derivations", async () => { + usePlatform("darwin"); + const home = useTempHome(); + const decomposedConfigDir = join(home, "managed-e\u0301"); + const normalizedConfigDir = decomposedConfigDir.normalize("NFC"); + process.env.CLAUDE_CONFIG_DIR = decomposedConfigDir; + mkdirSync(normalizedConfigDir, { recursive: true }); + writeFileSync( + join(normalizedConfigDir, ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "fresh-file-token", + expiresAt: "2035-01-01T00:00:00.000Z", + }, + }), + ); + const { claudeKeychainAccessMarkerPath } = + await import("../../src/lib/fs.js"); + const marker = claudeKeychainAccessMarkerPath(normalizedConfigDir); + mkdirSync(dirname(marker), { recursive: true, mode: 0o700 }); + writeFileSync(marker, "granted\n", { mode: 0o600 }); + const suffix = createHash("sha256") + .update(normalizedConfigDir) + .digest("hex") + .slice(0, 8); + const execFileText = vi.fn(async () => + JSON.stringify({ + claudeAiOauth: { + accessToken: "fresh-keychain-token", + expiresAt: "2035-01-01T00:00:00.000Z", + }, + }), + ); + vi.doMock("../../src/lib/process.js", () => ({ execFileText })); + + const { inspectAuth } = await import("../../src/providers/claude.js"); + const auth = await inspectAuth({ allowKeychainPrompt: false }); + + expect(auth.sources[0]).toMatchObject({ + source: "oauth-file", + path: join(normalizedConfigDir, ".credentials.json"), + status: "available", + }); + expect(execFileText).toHaveBeenCalledWith( + "security", + [ + "find-generic-password", + "-s", + `Claude Code-credentials-${suffix}`, + "-w", + ], + expect.any(Number), + ); + expect(auth.sources).toContainEqual({ + source: "keychain", + status: "available", + }); + }); + it("surfaces expired file credentials as a skipped attempt and auth_required", async () => { const home = useTempHome(); mkdirSync(join(home, ".claude"), { recursive: true }); @@ -145,6 +306,99 @@ describe("Claude credential-state reporting", () => { ); }); + it("fetches a profile with the same OAuth credential and exposes a verified account identity", async () => { + const home = useTempHome(); + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync( + join(home, ".claude", ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "fresh-token", + expiresAt: "2035-01-01T00:00:00.000Z", + }, + }), + ); + const fetchMock = vi.fn(async (input: string | URL | Request) => { + if (String(input).endsWith("/api/oauth/profile")) { + return new Response( + JSON.stringify({ + account: { + uuid: "11111111-2222-4333-8444-555555555555", + email: "person@example.invalid", + }, + organization: { name: "Fixture Organization" }, + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { + status: 200, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const { fetchQuota } = await import("../../src/providers/claude.js"); + const result = await fetchQuota({ allowKeychainPrompt: false }); + + expect(result.account).toEqual({ + accountId: "11111111-2222-4333-8444-555555555555", + email: "person@example.invalid", + organization: "Fixture Organization", + identityStatus: "verified", + }); + expect(result.attempts).toContainEqual({ + source: "oauth-profile", + status: "success", + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.anthropic.com/api/oauth/profile", + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer fresh-token", + "Cache-Control": "no-cache", + }), + }), + ); + }); + + it("marks identity unverified when the profile response lacks a stable account id", async () => { + const home = useTempHome(); + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync( + join(home, ".claude", ".credentials.json"), + JSON.stringify({ + claudeAiOauth: { + accessToken: "fresh-token", + expiresAt: "2035-01-01T00:00:00.000Z", + }, + }), + ); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => + String(input).endsWith("/api/oauth/profile") + ? new Response( + JSON.stringify({ email_address: "person@example.invalid" }), + { status: 200 }, + ) + : new Response(JSON.stringify({ five_hour: { utilization: 12 } }), { + status: 200, + }), + ), + ); + + const { fetchQuota } = await import("../../src/providers/claude.js"); + const result = await fetchQuota({ allowKeychainPrompt: false }); + + expect(result.state.status).toBe("fresh"); + expect(result.account).toEqual({ identityStatus: "unverified" }); + expect(result.attempts).toContainEqual({ + source: "oauth-profile", + status: "failed", + error: "identity_profile_unrecognized", + }); + }); + it("surfaces missing file credentials as a skipped attempt and auth_required", async () => { useTempHome(); diff --git a/test/providers/claude.test.ts b/test/providers/claude.test.ts index 06a7748..37af97a 100644 --- a/test/providers/claude.test.ts +++ b/test/providers/claude.test.ts @@ -1,7 +1,10 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { normalizeClaudeApiUsage } from "../../src/providers/claude.js"; +import { + normalizeClaudeApiUsage, + normalizeClaudeProfile, +} from "../../src/providers/claude.js"; const fixtureDir = join(import.meta.dirname, "..", "fixtures", "claude"); @@ -86,3 +89,38 @@ describe("Claude quota parsing", () => { ]); }); }); + +describe("Claude OAuth profile parsing", () => { + it("normalizes the stable account UUID and non-secret full-output fields", () => { + const raw = JSON.parse( + readFileSync(join(fixtureDir, "oauth-profile.json"), "utf8"), + ) as unknown; + + expect(normalizeClaudeProfile(raw)).toEqual({ + accountId: "11111111-2222-4333-8444-555555555555", + email: "person@example.invalid", + organization: "Fixture Organization", + identityStatus: "verified", + }); + }); + + it("does not invent an identity from email or organization UUID", () => { + expect( + normalizeClaudeProfile({ + email_address: "person@example.invalid", + organization: { + uuid: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + }, + }), + ).toBeUndefined(); + }); + + it("does not treat cached camelCase account metadata as an authoritative profile", () => { + expect( + normalizeClaudeProfile({ + accountUuid: "11111111-2222-4333-8444-555555555555", + emailAddress: "person@example.invalid", + }), + ).toBeUndefined(); + }); +}); diff --git a/test/providers/codex-auth.test.ts b/test/providers/codex-auth.test.ts index e674071..7ad0ecb 100644 --- a/test/providers/codex-auth.test.ts +++ b/test/providers/codex-auth.test.ts @@ -1,9 +1,13 @@ +import { EventEmitter } from "node:events"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const originalCodexHome = process.env.CODEX_HOME; +const originalCodexBinary = process.env.QUOTA_AXI_CODEX_BINARY; const originalXdgCacheHome = process.env.XDG_CACHE_HOME; let tempDir: string | undefined; @@ -14,7 +18,7 @@ beforeEach(() => { process.env.CODEX_HOME = tempDir; process.env.XDG_CACHE_HOME = join(tempDir, "cache"); vi.doMock("../../src/lib/process.js", () => ({ - commandExists: vi.fn(async () => false), + findCommandPath: vi.fn(async () => undefined), terminateChild: vi.fn(), })); }); @@ -22,9 +26,13 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); vi.doUnmock("../../src/lib/process.js"); + vi.doUnmock("node:child_process"); vi.resetModules(); if (originalCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = originalCodexHome; + if (originalCodexBinary === undefined) + delete process.env.QUOTA_AXI_CODEX_BINARY; + else process.env.QUOTA_AXI_CODEX_BINARY = originalCodexBinary; if (originalXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME; else process.env.XDG_CACHE_HOME = originalXdgCacheHome; if (tempDir) rmSync(tempDir, { recursive: true, force: true }); @@ -47,6 +55,83 @@ function jwt(payload: Record): string { } describe("Codex credential-state reporting", () => { + it("uses the configured absolute executable for auth inspection and RPC fallback", async () => { + const binary = join(tempDir!, "pinned", "codex"); + process.env.QUOTA_AXI_CODEX_BINARY = binary; + const findCommandPath = vi.fn(async (command: string) => command); + const terminateChild = vi.fn(); + vi.doMock("../../src/lib/process.js", () => ({ + findCommandPath, + terminateChild, + })); + const child = failingChild(); + const spawn = vi.fn(() => { + queueMicrotask(() => child.emit("error", new Error("fixture stop"))); + return child; + }); + vi.doMock("node:child_process", () => ({ spawn })); + + const { fetchQuota, inspectAuth } = + await import("../../src/providers/codex.js"); + const auth = await inspectAuth({ allowKeychainPrompt: false }); + await fetchQuota({ allowKeychainPrompt: false }); + + expect(auth.sources[1]).toEqual({ + source: "cli-rpc", + path: binary, + status: "available", + }); + expect(findCommandPath).toHaveBeenCalledWith(binary); + expect(findCommandPath).not.toHaveBeenCalledWith("codex"); + expect(spawn).toHaveBeenCalledWith( + binary, + ["-s", "read-only", "-a", "untrusted", "app-server"], + expect.any(Object), + ); + }); + + it("fails closed instead of consulting PATH for a non-absolute override", async () => { + process.env.QUOTA_AXI_CODEX_BINARY = "codex-from-path"; + const findCommandPath = vi.fn(async () => "/unexpected/codex"); + vi.doMock("../../src/lib/process.js", () => ({ + findCommandPath, + terminateChild: vi.fn(), + })); + + const { inspectAuth } = await import("../../src/providers/codex.js"); + const auth = await inspectAuth({ allowKeychainPrompt: false }); + + expect(auth.sources[1]).toEqual({ + source: "cli-rpc", + path: undefined, + status: "missing", + error: "codex_binary_override_not_absolute", + }); + expect(findCommandPath).not.toHaveBeenCalled(); + }); + + it("reports an absolute override that is not executable without falling back", async () => { + const binary = join(tempDir!, "missing", "codex"); + process.env.QUOTA_AXI_CODEX_BINARY = binary; + const findCommandPath = vi.fn(async () => undefined); + vi.doMock("../../src/lib/process.js", () => ({ + findCommandPath, + terminateChild: vi.fn(), + })); + + const { inspectAuth } = await import("../../src/providers/codex.js"); + const auth = await inspectAuth({ allowKeychainPrompt: false }); + + expect(auth.sources[1]).toEqual({ + source: "cli-rpc", + path: binary, + status: "missing", + error: "codex_binary_override_not_executable", + }); + expect(findCommandPath).toHaveBeenCalledOnce(); + expect(findCommandPath).toHaveBeenCalledWith(binary); + }); + it("does not send OPENAI_API_KEY to ChatGPT OAuth usage endpoints", async () => { writeAuth({ OPENAI_API_KEY: "sk-test" }); const fetchMock = vi.fn(); @@ -138,3 +223,16 @@ describe("Codex credential-state reporting", () => { }); }); }); + +function failingChild(): ChildProcessWithoutNullStreams { + const child = new EventEmitter() as ChildProcessWithoutNullStreams; + Object.assign(child, { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + }); + return child; +}