diff --git a/src/main/rate-limits/antigravity-usage-error.test.ts b/src/main/rate-limits/antigravity-usage-error.test.ts index f4661b196f3..86a50004115 100644 --- a/src/main/rate-limits/antigravity-usage-error.test.ts +++ b/src/main/rate-limits/antigravity-usage-error.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { classifyAntigravityUsageFailure } from './antigravity-usage-error' +import { + classifyAntigravityUsageFailure, + formatAgyErrorForDisplay +} from './antigravity-usage-error' /** Captured verbatim from agy 1.2.11 when the account had exhausted its weekly pool. */ const REAL_RESOURCE_EXHAUSTED = @@ -74,3 +77,11 @@ describe('classifyAntigravityUsageFailure', () => { expect(classifyAntigravityUsageFailure(output)).toBeNull() }) }) + +it('bounds a large unbroken error without blocking the main process', () => { + const text = `${'!'.repeat(256 * 1024)}@invalid` + const started = performance.now() + + expect(formatAgyErrorForDisplay(text)).toBe(`${'!'.repeat(159)}…`) + expect(performance.now() - started).toBeLessThan(2000) +}) diff --git a/src/main/rate-limits/antigravity-usage-error.ts b/src/main/rate-limits/antigravity-usage-error.ts index 0c0f1d6ed67..f6a1ab0f304 100644 --- a/src/main/rate-limits/antigravity-usage-error.ts +++ b/src/main/rate-limits/antigravity-usage-error.ts @@ -1,4 +1,6 @@ import type { UsageRateLimitFailureKind } from '../../shared/rate-limit-types' +import { stripTerminalControl } from '../../shared/terminal-control-stripping' +import { redactString } from '../observability/redactor' const AGY_ERROR_PREFIX = 'AGY_ERROR:' @@ -17,6 +19,11 @@ const SIGNED_OUT_PHRASES = [ 'authentication required' ] as const +const AGY_MESSAGE_MAX_LENGTH = 160 +// Start at a boundary to avoid quadratic retries inside long, unbroken diagnostics. +const EMAIL_PATTERN = /(^|[\s@])[^\s@]+@[^\s@]+\.[^\s@]+/g +const TOKEN_LIKE_PATTERN = /[\w-]{32,}/g + export type AntigravityUsageFailure = { failureKind: UsageRateLimitFailureKind /** Missing authentication requires sign-in guidance. */ @@ -85,3 +92,21 @@ export function classifyAntigravityUsageFailure(output: string): AntigravityUsag export function stderrShowsInteractiveSignIn(stderr: string): boolean { return stderr.includes('Authentication required. Please visit the URL') } + +/** One bounded line of agy's error text, safe to show in the status bar. */ +export function formatAgyErrorForDisplay(text: string): string | null { + const line = redactString(stripTerminalControl(text)) + .replace(/\s+/g, ' ') + .trim() + .replace(EMAIL_PATTERN, '$1[email]') + .replace(TOKEN_LIKE_PATTERN, '[redacted]') + if (!line) { + return null + } + const chars = Array.from(line) + if (chars.length <= AGY_MESSAGE_MAX_LENGTH) { + return line + } + const kept = chars.slice(0, AGY_MESSAGE_MAX_LENGTH - 1).join('') + return `${kept.trimEnd()}…` +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index 9125e5505ec..ccba0f0523b 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -472,3 +472,53 @@ describe('quota read safety and diagnostic precedence', () => { expect(runCommand).toHaveBeenCalledTimes(3) }) }) + +describe("agy's own error envelope", () => { + beforeEach(() => resetAntigravityUsageSupportForTests()) + + const errorEnvelope = (error: string): string => + `${JSON.stringify({ conversation_id: '', status: 'ERROR', response: '', error, num_turns: 0 })}\n` + const LOCATION_ERROR = + 'Eligibility check failed: Your current account is not eligible for Antigravity, because it is not currently available in your location.' + + it('shows agy’s text instead of the generic parse failure', async () => { + const result = await harness({ + result: processResult({ + code: 1, + stdout: errorEnvelope(LOCATION_ERROR), + stderr: `error: ${LOCATION_ERROR}\n` + }) + }).fetch() + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + expect(result.error).toBe(`Antigravity usage is not available. agy reported: ${LOCATION_ERROR}`) + }) + + it('redacts identities and bounds agy’s text to one line', async () => { + const token = 'ya29.a0AfB_byC1234567890abcdefghijklmnopqrstuvwxyz' + const result = await harness({ + result: processResult({ + code: 1, + stdout: errorEnvelope( + `\u001b[31mDenied\u001b[0m for person@example.com\nAGY_SESSION=shortsecret\nwith ${token} ${'x '.repeat(200)}` + ) + }) + }).fetch() + + const shown = result.error?.replace('Antigravity usage is not available. agy reported: ', '') + expect(shown).toMatch(/^Denied for \[email\] AGY_SESSION=\S+ with ya29\.\[redacted\] x x/) + expect(shown).not.toContain('shortsecret') + expect(Array.from(shown ?? '').length).toBeLessThanOrEqual(160) + expect(shown?.endsWith('…')).toBe(true) + }) + + it('keeps sign-in guidance ahead of agy’s text', async () => { + const result = await harness({ + result: processResult({ code: 1, stdout: errorEnvelope('Not authenticated. Run agy login.') }) + }).fetch() + + expect(result.usageMetadata?.failureKind).toBe('missing-credentials') + expect(result.error).toContain('Sign in with `agy`') + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index e5fd07a4dad..71873a852c8 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -14,10 +14,14 @@ import { ANTIGRAVITY_VERSION_TIMEOUT_MS, antigravityCommandName } from './antigravity-usage-command' -import { parseAntigravityUsageStdout, stdoutShowsModelTurn } from './antigravity-usage-response' - +import { + parseAntigravityUsageStdout, + readAntigravityErrorEnvelope, + stdoutShowsModelTurn +} from './antigravity-usage-response' import { classifyAntigravityUsageFailure, + formatAgyErrorForDisplay, stderrShowsInteractiveSignIn } from './antigravity-usage-error' @@ -211,8 +215,10 @@ export async function fetchAntigravityRateLimits( now() ) } - } - if (!reading) { + const agyError = formatAgyErrorForDisplay(readAntigravityErrorEnvelope(result.stdout) ?? '') + if (agyError) { + return failed(`Antigravity usage is not available. agy reported: ${agyError}`, 'parse', now()) + } // runProcess returns nonzero exit as data; classify signed-out first and add exit detail only when payload is missing. const exitDetail = result.code === 0 || result.code === null ? '' : ` (exit ${result.code})` return failed( diff --git a/src/main/rate-limits/antigravity-usage-response.ts b/src/main/rate-limits/antigravity-usage-response.ts index 69a5b1a132c..ceecd48bd36 100644 --- a/src/main/rate-limits/antigravity-usage-response.ts +++ b/src/main/rate-limits/antigravity-usage-response.ts @@ -156,16 +156,16 @@ export function parseAntigravityUsageEnvelope(value: unknown): AntigravityUsageR } /** Allow CLI log noise around the JSON envelope. */ -export function parseAntigravityUsageStdout(stdout: string): AntigravityUsageReading | null { +function findInStdoutEnvelopes(stdout: string, read: (value: unknown) => T | null): T | null { for (const line of stdout.split('\n')) { const trimmed = line.trim() if (!trimmed.startsWith('{')) { continue } try { - const reading = parseAntigravityUsageEnvelope(JSON.parse(trimmed)) - if (reading) { - return reading + const found = read(JSON.parse(trimmed)) + if (found !== null) { + return found } } catch { continue @@ -174,6 +174,17 @@ export function parseAntigravityUsageStdout(stdout: string): AntigravityUsageRea return null } +export function parseAntigravityUsageStdout(stdout: string): AntigravityUsageReading | null { + return findInStdoutEnvelopes(stdout, parseAntigravityUsageEnvelope) +} + +/** agy's own error text from a `{"status":"ERROR","error":…}` print envelope. */ +export function readAntigravityErrorEnvelope(stdout: string): string | null { + return findInStdoutEnvelopes(stdout, (value) => + isRecord(value) && readString(value.status) === 'ERROR' ? readString(value.error) : null + ) +} + /** Unknown slash commands can become billed model prompts; conversation/turn evidence must stop future polling. */ export function didRunModelTurn(value: unknown): boolean { if (!isRecord(value)) { @@ -187,18 +198,5 @@ export function didRunModelTurn(value: unknown): boolean { } export function stdoutShowsModelTurn(stdout: string): boolean { - for (const line of stdout.split('\n')) { - const trimmed = line.trim() - if (!trimmed.startsWith('{')) { - continue - } - try { - if (didRunModelTurn(JSON.parse(trimmed))) { - return true - } - } catch { - continue - } - } - return false + return findInStdoutEnvelopes(stdout, (value) => (didRunModelTurn(value) ? true : null)) !== null }