fix(account-access): detect the human-readable 403 eligibility refusal

50
1d ago
+106-3

Problem

A 403 eligibility refusal is never detected, so the account is retried instead of disabled.

Google returns this when a request reaches the Gemini Code Assist individuals tier instead of a provisioned managed project — which happens whenever the plugin falls back to rising-fact-p41fc (ANTIGRAVITY_DEFAULT_PROJECT_ID):

Your current account is not eligible for Gemini Code Assist for individuals.
To use Gemini Code Assist for individuals you must be 18 years old or older.
If you think you are receiving this message in error, please ensure you
have verified your age and try to log in again.

That body contains neither account_ineligible nor validation_required, so extractAccountAccessErrorDetails reports both as false. In the live request path that means neither branch in request-services.ts matches:

if (response.status === 403) {
  const extracted = classifyAccessError(errorBodyText)
  if (extracted.accountIneligible) { /* mark ineligible, disable, rotate */ }
  if (extracted.validationRequired) { /* cooldown, rotate */ }
}
const shouldRetryEndpoint = response.status === 403 || ...
if (shouldRetryEndpoint && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
  ...
  continue        // same account, next endpoint
}

markAccountIneligible is never called, so the pool keeps no record of the failure and cannot route around it. The user sees an intermittent 403 while usable accounts sit idle. Note the asymmetry: validation_required is matched on a token Google does send; the eligibility refusal is matched on a token Google does not send.

Fix

Match the refusal sentence Google actually returns, alongside the existing token:

const INELIGIBLE_REFUSAL_PATTERNS: readonly RegExp[] = [
  /(^|[^a-z0-9_])account_ineligible([^a-z0-9_]|$)/i,
  /not eligible for gemini code assist/i,
  /(gemini code assist[^\n]{0,200}18 years old or older)|(18 years old or older[^\n]{0,200}gemini code assist)/i,
]

Used in both places the token was tested (the top-level body and the per-string walk), so the probe path in interpretAccountAccessProbeResponse and the live request path agree.

Why the patterns are anchored

A bare substring is a trap here. A healthy 200 loadCodeAssist response embeds a privacy notice containing:

"...how Gemini Code Assist for individuals handles your data..."

A /age/ pattern matches that, and an earlier revision of this fix did exactly that — classifying 26/26 working accounts as ineligible. Each pattern is therefore anchored to refusal phrasing, and a regression test pins the healthy-response case.

Tests

account-access.test.ts gains 6 cases:

  • machine-readable token still detected
  • Google's human-readable sentence detected (raw body and inside a JSON message field)
  • a healthy 200 response with the privacy notice is not flagged
  • validation_required still reported without claiming ineligibility
  • an unrelated rate-limit refusal is left alone

Verified the new cases actually catch the defect: reverting the fix to token-only matching fails detects Google's human-readable individuals-tier refusal.

Impact

Every user with a multi-account pool on round-robin or hybrid. The symptom is intermittent and resembles quota trouble, so it is hard to attribute. On the reporter's 26-account pool, probing each account individually showed 18 healthy free-tier accounts with a working managed project and only 8 genuinely refused — none of which the plugin could have identified on its own.

Notes

  • Type-checked with tsc --noEmit (clean); account-access, fetch-interceptor and quota suites pass (108 tests).
  • I could not run the repo's full bun run test / bun run build: they pin Bun 1.4.2 (build.prerequisite_order) and only 1.4.3 was available here, where the full suite segfaults inside Bun itself. The TUI build gate is unrelated to this change, but please run CI.
  • Fix authored with AI assistance (Claude). Happy to adjust approach or add anything I missed.