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
- Conta sem projectId → botão "Discover Project" (azul) visível
- Clica → backend executa discovery
- Se precisa verificação → botão "Verify Account" (amarelo) aparece
- Clica "Verify Account" → link copiado para clipboard
- Abre link no browser → completa verificação Google
- Clica "Discover Project" novamente → projeto criado
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
fix: Antigravity auto-discovery de Project ID + verificação de conta Google
Summary
Contas Antigravity adicionadas via OAuth falham com
422quando a conta Google não completou o onboarding do Gemini Code Assist: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
transformRequestloadCodeAssist"LINUX") — API espera enum numéricoloadCodeAssistmode: 1— impede discovery de projetosX-Client-Name,X-Client-Version,x-goog-api-clientonboardUser403Solution Flow
Backend
Dashboard UX
verifyUrllimpa do DB → botões desaparecem → conta funcionalFiles Changed (5 files, +619 / -42 lines)
1. [MODIFY]
src/lib/oauth/services/antigravity.ts(+303 / -1)Lines 13-50 — [NEW]
extractVerificationUrl()helperLine 175 —
mode: 1no loadCodeAssistLines 178-183 — Logging diagnóstico
Lines 267-525 — [NEW]
discoverAndProvisionProject()2. [MODIFY]
open-sse/services/antigravityHeaders.ts(+45 / -21)Lines 28-68 — Metadata protobuf (strings → enum numérico)
Before:
After:
Lines 108-115 — Metadata return type
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
Lines 398-405 —
transformRequesttornado asyncLines 423-455 — projectId com auto-discovery
Before:
After:
Lines 560-606 — [NEW]
tryDiscoverProject()4. [NEW]
src/app/api/providers/[id]/discover-project/route.ts(100 lines)5. [MODIFY]
src/app/(dashboard)/dashboard/providers/[id]/page.tsx(+99)Lines 567-578 —
ConnectionRowProps— novas propsLine 2253 — Estado para discovery
Lines 2309-2335 —
handleDiscoverProject()Lines 2336-2344 —
handleCopyVerifyUrl()Lines 3940-3955, 4137-4150 — Props ao ConnectionRow (2 locais: grid + list)
Lines 6237-6248 — Destructuring no ConnectionRow
Lines 6673-6704 — Botões na ConnectionRow
Verification
needsVerificationcomverifyUrldinâmicaaccounts.google.com/signin/continue?...verifyUrllimpa do DBtryDiscoverProject()provisiona inlineLabels
bug,antigravity,auto-discovery,account-verification,oauth