Skip to content

fix: Antigravity auto-discovery de Project ID + verificação de conta Google #2541

Description

@havockdev

fix: Antigravity auto-discovery de Project ID + verificação de conta Google

Summary

Contas Antigravity adicionadas via OAuth falham com 422 quando a conta Google não completou o onboarding do Gemini Code Assist:

[422]: Missing Google projectId for Antigravity account.
Please reconnect OAuth in Providers → Antigravity so OmniRoute can fetch your Cloud Code project.

Contexto: Quando uma conta Google é adicionada ao OmniRoute sem ter passado pelo Antigravity desktop, ela não tem projeto GCP provisionado. A conta precisa: (1) verificar a conta Google, (2) provisionar um projeto automaticamente.


Root Cause

Componente Problema
Executor transformRequest Retornava 422 imediatamente sem tentar provisionar
loadCodeAssist Metadata com strings ("LINUX") — API espera enum numérico
loadCodeAssist Sem mode: 1 — impede discovery de projetos
Headers API Faltavam X-Client-Name, X-Client-Version, x-goog-api-client
onboardUser 403 Retorna link genérico de FAQ, não a URL dinâmica de verificação
Dashboard Sem ação para o usuário resolver

Solution Flow

Backend

Conta OAuth → Executor detecta sem projectId → tryDiscoverProject()
  → Step 1: loadCodeAssist (busca projeto existente)
  → Step 2: onboardUser multi-tier (standard-tier → free-tier)
    → Se 403: captura errorReason + verifyUrl
  → Step 3: Retry loadCodeAssist (se onboard completou)
  → Step 3b: Probe generateContent (payload mínimo "hi")
    → Se 403 VALIDATION_REQUIRED: extrai validation_url dinâmica
  → Step 4: Retorna { projectId } ou { verifyUrl, errorReason }

Dashboard UX

  1. Conta sem projectId → botão "Discover Project" (azul) visível
  2. Clica → backend executa discovery
  3. Se precisa verificação → botão "Verify Account" (amarelo) aparece
  4. Clica "Verify Account" → link copiado para clipboard
  5. Abre link no browser → completa verificação Google
  6. Clica "Discover Project" novamente → projeto criado
  7. verifyUrl limpa do DB → botões desaparecem → conta funcional

Files Changed (5 files, +619 / -42 lines)


1. [MODIFY] src/lib/oauth/services/antigravity.ts (+303 / -1)

Lines 13-50 — [NEW] extractVerificationUrl() helper

function extractVerificationUrl(errorText: string): string | null {
  if (!errorText) return null;
  // Try structured JSON — details[].metadata.validation_url
  try {
    const parsed = JSON.parse(errorText);
    const details = parsed?.error?.details || [];
    for (const detail of details) {
      if (detail?.metadata?.validation_url) return detail.metadata.validation_url;
      if (Array.isArray(detail?.links)) {
        for (const link of detail.links) {
          if (link?.url?.includes("accounts.google.com/signin")) return link.url;
        }
      }
    }
  } catch { /* not JSON */ }
  // Fallback: regex
  const raw = errorText.match(/https:\/\/accounts\.google\.com\/signin\/continue\?[^\s"\\]+/);
  if (!raw) return null;
  return raw[0].replace(/[,.)}\>\]]+$/, "");
}

Line 175 — mode: 1 no loadCodeAssist

-        body: JSON.stringify({ metadata: this.getMetadata() }),
+        body: JSON.stringify({ metadata: this.getMetadata(), mode: 1 }),

Lines 178-183 — Logging diagnóstico

    console.log(
      `[AntigravityService] loadCodeAssist raw: project=${JSON.stringify(data.cloudaicompanionProject || 'none')}, ` +
      `paidTier=${JSON.stringify(data.paidTier?.id || 'none')}, ` +
      `currentTier=${JSON.stringify(data.currentTier?.id || 'none')}, ` +
      `allowedTiers=${JSON.stringify(data.allowedTiers?.map((t: any) => ({ id: t?.id, isDefault: t?.isDefault })) || [])}`
    );

Lines 267-525 — [NEW] discoverAndProvisionProject()

  async discoverAndProvisionProject(
    accessToken: string
  ): Promise<{
    projectId?: string; tierId: string; verifyUrl?: string; errorReason?: string;
  } | null> {
    let projectId = "";
    let tierId = "free-tier";
    let onboardCompleted = false;
    let verifyUrl: string | undefined;
    let errorReason: string | undefined;

    // Step 1: loadCodeAssist
    try {
      const result = await this.loadCodeAssist(accessToken);
      projectId = result.projectId || "";
      tierId = result.tierId || "free-tier";
      if (projectId) {
        console.log(`[AntigravityService] loadCodeAssist found project: ${projectId}`);
      } else {
        console.log("[AntigravityService] loadCodeAssist succeeded but no project found");
      }
    } catch (e) {
      console.log(`[AntigravityService] loadCodeAssist failed: ${e instanceof Error ? e.message : String(e)}`);
    }

    // Step 2: onboardUser multi-tier
    if (!projectId) {
      const onboardTiers: string[] = [];
      if (tierId && tierId !== "free-tier") onboardTiers.push(tierId);
      onboardTiers.push("free-tier");

      for (const onboardTierId of onboardTiers) {
        if (projectId) break;
        console.log(`[AntigravityService] Attempting onboardUser with tierId: ${onboardTierId}`);
        try {
          const headers = this.getApiHeaders(accessToken);
          const metadata = this.getMetadata();
          const endpoints = this.config.onboardUserEndpoints || [this.config.onboardUserEndpoint];

          for (const endpoint of endpoints) {
            let onboardDone = false;
            for (let i = 0; i < 10; i++) {
              const onboardRes = await fetch(endpoint, {
                method: "POST", headers,
                body: JSON.stringify({ tierId: onboardTierId, metadata }),
              });

              if (!onboardRes.ok) {
                const errorText = await onboardRes.text();
                console.log(`[AntigravityService] onboardUser failed at ${endpoint}: ${onboardRes.status} - ${errorText}`);
                if (onboardRes.status === 403) {
                  const extracted = extractVerificationUrl(errorText);
                  if (extracted) verifyUrl = extracted;
                  const lower = errorText.toLowerCase();
                  if (lower.includes("free_tier_user_not_eligible")) {
                    errorReason = "FREE_TIER_USER_NOT_ELIGIBLE";
                    if (!verifyUrl) verifyUrl = "https://developers.google.com/gemini-code-assist/resources/faqs";
                  } else if (lower.includes("validation_required")) {
                    errorReason = "VALIDATION_REQUIRED";
                  } else if (lower.includes("account_disabled") || lower.includes("user_disabled")) {
                    errorReason = "ACCOUNT_DISABLED";
                  }
                }
                break;
              }

              const data = await onboardRes.json();
              console.log(`[AntigravityService] onboardUser response (attempt ${i + 1}, tier=${onboardTierId}): done=${data.done}, project=${JSON.stringify(data.response?.cloudaicompanionProject || "none")}`);

              if (data.done === true) {
                const respProject = data.response?.cloudaicompanionProject;
                if (typeof respProject === "string" && respProject.trim()) projectId = respProject.trim();
                else if (respProject?.id) projectId = respProject.id.trim();
                onboardCompleted = true;
                onboardDone = true;
                break;
              }
              console.log(`[AntigravityService] onboardUser attempt ${i + 1}/10 not done, waiting 5s...`);
              await new Promise((resolve) => setTimeout(resolve, 5000));
            }
            if (onboardDone) break;
          }
        } catch (e) {
          console.log(`[AntigravityService] onboardUser failed: ${e instanceof Error ? e.message : String(e)}`);
        }
      }
    }

    // Step 3: Retry loadCodeAssist
    if (!projectId && onboardCompleted) {
      console.log("[AntigravityService] Onboard completed but no project — retrying loadCodeAssist...");
      try {
        const result = await this.loadCodeAssist(accessToken);
        if (result.projectId) { projectId = result.projectId; tierId = result.tierId || tierId; }
      } catch (e) {
        console.log(`[AntigravityService] loadCodeAssist retry failed: ${e instanceof Error ? e.message : String(e)}`);
      }
    }

    // Step 3b: Probe generateContent for dynamic verification URL
    const hasRealVerifyUrl = verifyUrl?.includes("accounts.google.com/signin");
    if (!projectId && !hasRealVerifyUrl) {
      console.log("[AntigravityService] Probing generateContent for verification URL...");
      const probeEndpoints = [
        "https://daily-cloudcode-pa.googleapis.com",
        "https://cloudcode-pa.googleapis.com",
      ];
      for (const baseUrl of probeEndpoints) {
        try {
          const probeRes = await fetch(`${baseUrl}/v1internal:generateContent`, {
            method: "POST",
            headers: this.getApiHeaders(accessToken),
            body: JSON.stringify({
              project: "probe-validation-check",
              model: "gemini-2.0-flash",
              request: { contents: [{ role: "user", parts: [{ text: "hi" }] }], generationConfig: { maxOutputTokens: 1 } },
              userAgent: "antigravity",
            }),
          });
          if (probeRes.status === 403) {
            const probeText = await probeRes.text();
            const extracted = extractVerificationUrl(probeText);
            if (extracted) {
              verifyUrl = extracted;
              const lower = probeText.toLowerCase();
              if (lower.includes("validation_required")) errorReason = "VALIDATION_REQUIRED";
              else if (lower.includes("free_tier_user_not_eligible")) errorReason = "FREE_TIER_USER_NOT_ELIGIBLE";
              break;
            }
          }
        } catch (e) {
          console.log(`[AntigravityService] Probe failed at ${baseUrl}: ${e instanceof Error ? e.message : String(e)}`);
        }
      }
    }

    // Step 4: Return
    if (projectId) return { projectId, tierId };
    if (verifyUrl) return { tierId, verifyUrl, errorReason };
    return null;
  }

2. [MODIFY] open-sse/services/antigravityHeaders.ts (+45 / -21)

Lines 28-68 — Metadata protobuf (strings → enum numérico)

Before:

type AntigravityLoadCodeAssistPlatform = "MACOS" | "WINDOWS" | "LINUX";
function getAntigravityLoadCodeAssistPlatformLabel(platform: NodeJS.Platform = process.platform): AntigravityLoadCodeAssistPlatform {
  switch (platform) {
    case "darwin": return "MACOS";
    case "win32": return "WINDOWS";
    default: return "LINUX";
  }
}

After:

const ANTIGRAVITY_IDE_TYPE = { ANTIGRAVITY: 9 } as const;
const ANTIGRAVITY_PLATFORM = {
  DARWIN_AMD64: 1, DARWIN_ARM64: 2, LINUX_AMD64: 3, LINUX_ARM64: 4, WINDOWS_AMD64: 5,
} as const;
const ANTIGRAVITY_PLUGIN_TYPE = { GEMINI: 2 } as const;

function getAntigravityPlatformEnum(): number {
  const os = process.platform;
  const architecture = process.arch;
  if (os === "darwin") return architecture === "arm64" ? ANTIGRAVITY_PLATFORM.DARWIN_ARM64 : ANTIGRAVITY_PLATFORM.DARWIN_AMD64;
  if (os === "win32") return ANTIGRAVITY_PLATFORM.WINDOWS_AMD64;
  return architecture === "arm64" ? ANTIGRAVITY_PLATFORM.LINUX_ARM64 : ANTIGRAVITY_PLATFORM.LINUX_AMD64;
}

Lines 108-115 — Metadata return type

-export function getAntigravityLoadCodeAssistMetadata(platform: NodeJS.Platform = process.platform): Record<string, string> {
-  return { ideType: "ANTIGRAVITY", platform: getAntigravityLoadCodeAssistPlatformLabel(platform), pluginType: "GEMINI" };
+export function getAntigravityLoadCodeAssistMetadata(): Record<string, number> {
+  return { ideType: ANTIGRAVITY_IDE_TYPE.ANTIGRAVITY, platform: getAntigravityPlatformEnum(), pluginType: ANTIGRAVITY_PLUGIN_TYPE.GEMINI };

Lines 134-136 — Headers obrigatórios adicionados

           "Content-Type": "application/json",
           "User-Agent": antigravityNativeOAuthUserAgent(),
+          "X-Client-Name": "antigravity",
+          "X-Client-Version": getCachedAntigravityVersion(),
+          "x-goog-api-client": "gl-node/22.21.1 fire/0.8.6 grpc/1.10.x",

3. [MODIFY] open-sse/executors/antigravity.ts (+57 / -18)

Lines 42-43 — Imports

import { AntigravityService } from "@/lib/oauth/services/antigravity";
import { updateProviderConnection } from "@/lib/db/providers";

Lines 398-405 — transformRequest tornado async

-  transformRequest(
+  async transformRequest(
     model: string,
     body: unknown,
     _stream: boolean,
     credentials: AntigravityCredentials
-  ): AntigravityRequestEnvelope | Response {
+  ): Promise<AntigravityRequestEnvelope | Response> {

Lines 423-455 — projectId com auto-discovery

Before:

    const projectId =
      allowBodyProjectOverride && bodyProjectId
        ? bodyProjectId
        : credentialsProjectId || providerSpecificProjectId || bodyProjectId;

    if (!projectId) {
      const errorMsg = "Missing Google projectId for Antigravity account...";
      const errorBody = { error: { message: errorMsg, type: "oauth_missing_project_id", code: "missing_project_id" } };
      const resp = new Response(JSON.stringify(errorBody), { status: 422, headers: { "Content-Type": "application/json" } });
      return resp as unknown as never;
    }

After:

    let projectId =
      allowBodyProjectOverride && bodyProjectId
        ? bodyProjectId
        : credentialsProjectId || providerSpecificProjectId || bodyProjectId;

    if (!projectId) {
      // Auto-discovery: try to discover/provision projectId before returning 422
      const discoveredProjectId = await this.tryDiscoverProject(credentials);
      if (discoveredProjectId) {
        projectId = discoveredProjectId;
      } else {
        const errorMsg = "Missing Google projectId for Antigravity account...";
        const errorBody = { error: { message: errorMsg, type: "oauth_missing_project_id", code: "missing_project_id" } };
        const resp = new Response(JSON.stringify(errorBody), { status: 422, headers: { "Content-Type": "application/json" } });
        return resp as unknown as never;
      }
    }

Lines 560-606 — [NEW] tryDiscoverProject()

  private async tryDiscoverProject(credentials: AntigravityCredentials): Promise<string | null> {
    if (!credentials?.accessToken) return null;
    const connId = credentials.connectionId || "unknown";

    try {
      console.log(`[AntigravityExecutor] Starting auto-discovery for connection=${connId}`);
      const service = new AntigravityService();
      const result = await service.discoverAndProvisionProject(credentials.accessToken);

      if (!result?.projectId) {
        console.log(`[AntigravityExecutor] Auto-discovery returned no projectId for connection=${connId}`);
        return null;
      }

      // Persist the discovered projectId to the provider connection
      if (credentials.connectionId) {
        try {
          await updateProviderConnection(credentials.connectionId, {
            providerSpecificData: {
              ...((credentials.providerSpecificData as Record<string, unknown>) || {}),
              projectId: result.projectId,
            },
          });
          console.log(
            `[AntigravityExecutor] Persisted discovered projectId=${result.projectId} for connection=${connId}`
          );
        } catch (e) {
          console.log(`[AntigravityExecutor] Failed to persist projectId for connection=${connId}:`, e);
        }
      }

      return result.projectId;
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e);
      console.log(`[AntigravityExecutor] Auto-discovery failed for connection=${connId}: ${msg}`);
      return null;
    }
  }

4. [NEW] src/app/api/providers/[id]/discover-project/route.ts (100 lines)

import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { AntigravityService } from "@/lib/oauth/services/antigravity";
import { getProviderConnectionById, updateProviderConnection } from "@/lib/db/providers";

/**
 * POST /api/providers/[id]/discover-project
 *
 * Triggers auto-discovery of the Google Cloud projectId for an Antigravity
 * OAuth connection. Uses loadCodeAssist + onboardUser to find/provision
 * the project, then persists it back to the connection record.
 *
 * Returns:
 * - { success: true, projectId, tierId } — project discovered
 * - { needsVerification: true, verifyUrl, errorReason } — account needs Google verification
 * - { error: "..." } — discovery failed
 */
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const authError = await requireManagementAuth(request);
  if (authError) return authError;

  const { id } = await params;
  if (!id) {
    return NextResponse.json({ error: "Missing connection ID" }, { status: 400 });
  }

  try {
    const connection = await getProviderConnectionById(id);
    if (!connection) {
      return NextResponse.json({ error: "Connection not found" }, { status: 404 });
    }

    const accessToken = (connection as Record<string, unknown>).accessToken as string | undefined;
    if (!accessToken) {
      return NextResponse.json(
        { error: "No access token found for this connection. Please reconnect OAuth." },
        { status: 400 }
      );
    }

    const service = new AntigravityService();
    const result = await service.discoverAndProvisionProject(accessToken);

    // Case 1: Verification required
    if (result?.verifyUrl && !result?.projectId) {
      const existingData = (connection as Record<string, unknown>).providerSpecificData as
        | Record<string, unknown> | undefined;
      await updateProviderConnection(id, {
        providerSpecificData: {
          ...(existingData || {}),
          verifyUrl: result.verifyUrl,
          errorReason: result.errorReason,
        },
      });
      return NextResponse.json({
        needsVerification: true,
        verifyUrl: result.verifyUrl,
        errorReason: result.errorReason,
      });
    }

    // Case 2: Project discovered
    if (result?.projectId) {
      const existingData = (connection as Record<string, unknown>).providerSpecificData as
        | Record<string, unknown> | undefined;
      await updateProviderConnection(id, {
        providerSpecificData: {
          ...(existingData || {}),
          projectId: result.projectId,
          verifyUrl: undefined,
          errorReason: undefined,
        },
      });
      return NextResponse.json({
        success: true,
        projectId: result.projectId,
        tierId: result.tierId,
      });
    }

    // Case 3: Complete failure
    return NextResponse.json(
      { error: "Could not discover a Google Cloud project. Ensure your Google account has a GCP project with Gemini Code Assist enabled." },
      { status: 404 }
    );
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

5. [MODIFY] src/app/(dashboard)/dashboard/providers/[id]/page.tsx (+99)

Lines 567-578 — ConnectionRowProps — novas props

  isAntigravity?: boolean;
  hasProjectId?: boolean;
  onDiscoverProject?: () => void;
  isDiscoveringProject?: boolean;
  verifyUrl?: string;
  onCopyVerifyUrl?: (url: string) => void;

Line 2253 — Estado para discovery

  const [discoveringProjectId, setDiscoveringProjectId] = useState<string | null>(null);

Lines 2309-2335 — handleDiscoverProject()

  const handleDiscoverProject = async (connectionId: string) => {
    if (discoveringProjectId) return;
    setDiscoveringProjectId(connectionId);
    try {
      const res = await fetch(`/api/providers/${connectionId}/discover-project`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
      });
      const data = await res.json().catch(() => ({}));
      if (res.ok && data.success) {
        notify.success(`Project discovered: ${data.projectId}`);
        await fetchConnections();
      } else if (res.ok && data.needsVerification) {
        notify.info(
          "Account needs Google verification. Click 'Verify Account' to complete setup."
        );
        await fetchConnections();
      } else {
        notify.error(data.error || "Failed to discover project");
      }
    } catch (error) {
      console.error("Error discovering project:", error);
      notify.error("Network error during project discovery");
    } finally {
      setDiscoveringProjectId(null);
    }
  };

Lines 2336-2344 — handleCopyVerifyUrl()

  const handleCopyVerifyUrl = (url: string) => {
    navigator.clipboard.writeText(url).then(() => {
      notify.success("Verification link copied to clipboard!");
    }).catch(() => {
      notify.error("Failed to copy link");
    });
  };

Lines 3940-3955, 4137-4150 — Props ao ConnectionRow (2 locais: grid + list)

                          isAntigravity={providerId === "antigravity"}
                          hasProjectId={!!(conn.providerSpecificData?.projectId || conn.projectId)}
                          verifyUrl={providerId === "antigravity" ? conn.providerSpecificData?.verifyUrl : undefined}
                          onDiscoverProject={
                            providerId === "antigravity"
                              ? () => handleDiscoverProject(conn.id)
                              : undefined
                          }
                          onCopyVerifyUrl={providerId === "antigravity" ? handleCopyVerifyUrl : undefined}
                          isDiscoveringProject={discoveringProjectId === conn.id}

Lines 6237-6248 — Destructuring no ConnectionRow

  isAntigravity,
  hasProjectId,
  onDiscoverProject,
  isDiscoveringProject,
  verifyUrl,
  onCopyVerifyUrl,

Lines 6673-6704 — Botões na ConnectionRow

        {isAntigravity && !hasProjectId && verifyUrl && onCopyVerifyUrl && (
          <Button
            size="sm"
            variant="ghost"
            icon="verified_user"
            onClick={() => onCopyVerifyUrl(verifyUrl)}
            className="!h-7 !px-2 text-xs text-amber-500 hover:text-amber-400"
            title="Copy Google verification link to clipboard"
          >
            Verify Account
          </Button>
        )}
        {isAntigravity && !hasProjectId && onDiscoverProject && (
          <Button
            size="sm"
            variant="ghost"
            icon="search"
            loading={isDiscoveringProject}
            disabled={isDiscoveringProject || connection.isActive === false}
            onClick={onDiscoverProject}
            className="!h-7 !px-2 text-xs text-blue-500 hover:text-blue-400"
            title={t.has?.("discoverProjectTitle") ? t("discoverProjectTitle") : "Auto-discover Google Cloud project ID"}
          >
            {t.has?.("discoverProjectLabel") ? t("discoverProjectLabel") : "Discover Project"}
          </Button>
        )}

Verification

Cenário Resultado
Conta nova sem projectId → "Discover Project" ✅ Retorna needsVerification com verifyUrl dinâmica
Botão "Verify Account" copia link para clipboard accounts.google.com/signin/continue?...
Ambos botões visíveis simultaneamente
Após verificar → "Discover Project" ✅ Projeto criado, verifyUrl limpa do DB
Metadata protobuf numérico ✅ API aceita (antes 400)
Auto-discovery no executor (requisição real) tryDiscoverProject() provisiona inline

Labels

bug, antigravity, auto-discovery, account-verification, oauth

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions