Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
"anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic",
"anthropic-tool-schema.test.ts": "adapters/anthropic",
"antigravity-baseurl-override.test.ts": "adapters/google",
"antigravity-refusal.test.ts": "adapters/google",
"antigravity-static-catalog.test.ts": "adapters/google",
"api-access-endpoints.test.ts": "server",
"api-catalog-route.test.ts": "server",
Expand Down Expand Up @@ -370,6 +371,7 @@
"cli-account-pin-drain.test.ts": "cli",
"cli-account-pool-verbs.test.ts": "cli",
"cli-account-threshold.test.ts": "cli",
"cli-account-verify-status.test.ts": "cli",
"cli-account.test.ts": "cli",
"cli-capabilities.test.ts": "cli",
"cli-api-protocols.test.ts": "cli",
Expand Down Expand Up @@ -1544,6 +1546,7 @@
"self-launch-argv.test.ts": "lib",
"server-403-permission-e2e.test.ts": "server",
"server-agent-task-recovery-replay.test.ts": "server",
"server-antigravity-verify-replay-scope.test.ts": "server",
"server-auth-localhost-bind.test.ts": "server",
"server-auth-scoped-quota.test.ts": "server",
"server-auth.test.ts": "server",
Expand Down
28 changes: 28 additions & 0 deletions src/adapters/antigravity-refusal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** Account-scoped Antigravity refusals. Unknown data never convicts an account. */
import { BOUNDED_BODY_MAX_BYTES } from "../lib/bounded-body";

export type AntigravityRefusalKind = "verify_account" | "other";
export interface AntigravityRefusal { kind: AntigravityRefusalKind }

const VERIFY_ACCOUNT_WORDS = [
"verify your account",
] as const;

/**
* Classify an Antigravity (Cloud Code Assist) HTTP refusal.
*
* Only the observed 403 verification demand convicts: Google answers
* `PERMISSION_DENIED` with "Please verify your account to continue using
* Antigravity." when the account itself is blocked, while the stored OAuth
* grant (and its refresh) stays valid. Anything else — rate limits, location
* refusals, malformed or oversized bodies — is "other" and must never mark
* the account.
*/
export function classifyAntigravityRefusal(status: number, bodyText: string): AntigravityRefusal {
if (status !== 403) return { kind: "other" };
if (Buffer.byteLength(bodyText, "utf8") > BOUNDED_BODY_MAX_BYTES)
return { kind: "other" };
const lower = bodyText.toLowerCase();
if (VERIFY_ACCOUNT_WORDS.some(word => lower.includes(word))) return { kind: "verify_account" };
return { kind: "other" };
}
3 changes: 3 additions & 0 deletions src/cli/account-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface AccountRow {
masked?: string;
active: boolean;
needsReauth?: boolean;
needsReauthReason?: "verify_account";
autoSelectable?: boolean;
skipReason?: "paused" | "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted";
selectionExcludedReason?: "plan_excluded";
Expand Down Expand Up @@ -348,6 +349,7 @@ interface OAuthAccountDto {
/** Present only for providers that support operator pause (generic OAuth pools). */
paused?: boolean;
autoSwitchThresholdOverride?: number | null;
needsReauthReason?: "verify_account";
autoSelectable?: boolean;
skipReason?: unknown;
/** Always sent by the management route; explicitly `null` when the tier is unknown. */
Expand Down Expand Up @@ -390,6 +392,7 @@ async function fetchOAuthRows(
needsReauth: a.needsReauth,
...(a.paused === true ? { paused: true } : {}),
...(name === "anthropic" && Object.hasOwn(a, "autoSwitchThresholdOverride") ? { autoSwitchThresholdOverride: a.autoSwitchThresholdOverride } : {}),
...(a.needsReauthReason === "verify_account" ? { needsReauthReason: a.needsReauthReason } : {}),
...(name === "kiro" && typeof a.autoSelectable === "boolean"
? { autoSelectable: a.autoSelectable } : {}),
...(name === "kiro" && a.autoSelectable === false && isKiroSkipReason(a.skipReason)
Expand Down
4 changes: 3 additions & 1 deletion src/cli/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ function statusText(row: AccountRow): string {
// held out -- so printing only one of the two would hide exactly the confusing case (#2703).
if (row.paused) parts.push("paused");
if (row.active) parts.push(row.type === "codex" ? "selected" : "active");
if (row.needsReauth && !(row.provider === "kiro" && row.skipReason === "needs_reauth")) parts.push("needs-reauth");
if (row.needsReauth && !(row.provider === "kiro" && row.skipReason === "needs_reauth")) {
parts.push(row.needsReauthReason === "verify_account" ? "needs-reauth(verify)" : "needs-reauth");
}
// A paused Kiro row already says "paused"; repeating it as a skip reason adds nothing.
if (row.provider === "kiro" && row.autoSelectable === false && !(row.paused && row.skipReason === "paused"))
parts.push(row.skipReason ? `not-auto-selected(${row.skipReason})` : "not-auto-selected");
Expand Down
21 changes: 21 additions & 0 deletions src/oauth/generic-account-failover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,3 +734,24 @@ export function clearGenericFailoverHealth(providerName?: string): void {
if (key.startsWith(`${providerName}\u0000`)) health.delete(key);
}
}

/**
* Retire one account's superseded auth evidence after an explicit (re-)login. An
* auth cooldown recorded against the old grant must not hold out the fresh
* credential, so it leaves with the login rather than lingering until its
* deadline. Rate, quota, and suspension evidence is preserved: a re-login says
* nothing about those verdicts, and dropping them would make a throttled account
* eligible early. Entries bound to the still-current generation stay as well.
*/
export function clearGenericFailoverHealthForAccount(providerName: string, accountId: string): void {
const live = getAccountSet(providerName)?.accounts.find(row => row.id === accountId);
const current = live ? credentialGeneration(live.credential) : undefined;
const bare = `${providerName}\u0000${accountId}`;
for (const key of [...health.keys()]) {
if (key !== bare && !key.startsWith(`${bare}\u0000`)) continue;
const entry = health.get(key);
if (!entry || entry.cooldownSource !== "auth") continue;
if (current !== undefined && entry.identity === current) continue;
health.delete(key);
}
}
37 changes: 32 additions & 5 deletions src/oauth/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshI
import type { ProviderAccount } from "./types";

export type OAuthAccountHealth =
| { status: "healthy" }
| { status: "cooldown"; until: string; reason: "rate_limit" | "quota" }
| { status: "reauth_required"; reason: OAuthReauthReason }
| { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" | "validation_pending" };

/** Why an OAuth account needs reauthentication. `verify_account` is distinct from a dead
* credential: the grant is alive but the provider blocks the account until a human
* verifies it, so a plain re-login without that verification will not help. */
export type OAuthReauthReason = "unauthorized" | "forbidden" | "refresh_failed" | "verify_account";

/**
* Codex-pool health: the same shape minus the provider-verification cause, which only
* the generic OAuth quarantine produces. Narrowing the return keeps Codex DTOs on
* `CodexAccountReauthReason` without a lossy re-narrow at every consumer.
*/
export type CodexPoolAccountHealth =
| { status: "healthy" }
| { status: "cooldown"; until: string; reason: "rate_limit" | "quota" }
| { status: "reauth_required"; reason: "unauthorized" | "forbidden" | "refresh_failed" }
Expand All @@ -22,6 +38,7 @@ export type OAuthHealthLabel =
| "Rate limited"
| "Quota limited"
| "Reauthentication required"
| "Verification required"
| "Refresh failed"
| "Metadata mismatch"
| "Credential conflict"
Expand Down Expand Up @@ -58,7 +75,7 @@ type OAuthWarningReason = "refresh_conflict" | "metadata_mismatch" | "stale_cred

export function projectOAuthAccountHealth(input: {
needsReauth?: boolean;
reauthReason?: "unauthorized" | "forbidden" | "refresh_failed";
reauthReason?: OAuthReauthReason;
cooldownUntilMs?: number;
cooldownReason?: "rate_limit" | "quota";
warningReason?: OAuthWarningReason;
Expand Down Expand Up @@ -94,6 +111,9 @@ function actionFor(provider: string, health: OAuthAccountHealth): string | undef
}
if (health.status === "reauth_required") {
if (provider === "codex") return CODEX_REAUTH_ACTION;
if (health.reason === "verify_account") {
return `verify the account with the provider in a browser, then run \`ocx login ${provider}\``;
}
return `run \`ocx login ${provider}\``;
}
if (health.status === "cooldown") {
Expand All @@ -113,7 +133,9 @@ export function oauthHealthLabel(health: OAuthAccountHealth): OAuthHealthLabel {
case "cooldown":
return health.reason === "rate_limit" ? "Rate limited" : "Quota limited";
case "reauth_required":
return health.reason === "refresh_failed" ? "Refresh failed" : "Reauthentication required";
if (health.reason === "refresh_failed") return "Refresh failed";
if (health.reason === "verify_account") return "Verification required";
return "Reauthentication required";
case "warning":
switch (health.reason) {
case "validation_pending":
Expand Down Expand Up @@ -188,7 +210,9 @@ export function projectStoredOAuthAccountHealth(
: null;
return projectOAuthAccountHealth({
needsReauth: account.needsReauth === true,
reauthReason: account.needsReauth === true ? "refresh_failed" : undefined,
reauthReason: account.needsReauth === true
? (account.needsReauthReason ?? "refresh_failed")
: undefined,
cooldownUntilMs: anthropicSnap?.cooldownUntil,
// Same mapping as the Codex pool's `cooldownReasonFromSource`: only a Retry-After is
// request-rate throttling. A reset-derived cooldown means a usage window is spent, which
Expand All @@ -204,7 +228,7 @@ export function projectCodexAccountHealth(input: {
needsReauth: boolean;
reauthReason?: "unauthorized" | "forbidden" | "refresh_failed";
now?: number;
}): OAuthAccountHealth {
}): CodexPoolAccountHealth {
// One read serves every verdict below. Each lookup re-reads and re-hardens the whole store
// file, and the main account lives in the native Codex auth file rather than the pool store,
// so a lookup for it could only ever miss.
Expand Down Expand Up @@ -248,13 +272,16 @@ export function projectCodexAccountHealth(input: {
}
const now = input.now ?? Date.now();
const snap = getCodexAccountHealthSnapshot(input.accountId, now);
// Safe narrowing: every reason above is Codex-scoped (`verify_account` only enters
// through the generic OAuth quarantine path), so the shared projector cannot
// produce it here despite the wider return type.
return projectOAuthAccountHealth({
needsReauth,
reauthReason: needsReauth ? (input.reauthReason ?? storedFailureReason ?? "refresh_failed") : undefined,
cooldownUntilMs: snap?.cooldownUntil,
cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
now,
});
}) as CodexPoolAccountHealth;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1785,6 +1785,7 @@ export interface OAuthAccountSummary {
email?: string;
active: boolean;
needsReauth?: boolean;
needsReauthReason?: "verify_account";
expiresAt?: number;
/**
* Subscription tier, mirroring the field the OpenAI/Codex provider reports, so a consumer
Expand Down Expand Up @@ -1821,6 +1822,7 @@ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn:
email: projectEmail(a.credential.email, maskEmails) ?? undefined,
active: a.id === set.activeAccountId,
...(a.needsReauth ? { needsReauth: true } : {}),
...(a.needsReauth && a.needsReauthReason === "verify_account" ? { needsReauthReason: a.needsReauthReason } : {}),
expiresAt: a.credential.expires,
// Explicitly null rather than omitted — see OAuthAccountSummary.plan. No OAuth provider
// exposes a subscription tier today, so there is nothing truthful to put here; deriving one
Expand Down
Loading
Loading