From 067e0496cf69d7207ed2177c2da3ad3c8b3be7dd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 29 May 2026 09:16:15 -0300 Subject: [PATCH] feat(api,oauth): add `agy` (Antigravity CLI) standalone provider with CLI token import (#2899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a standalone OAuth provider `agy` (Antigravity CLI) next to gemini-cli/antigravity. It reuses the antigravity inference backend (identical Google client_id + daily-cloudcode-pa.googleapis.com endpoint, executor and token-refresh) but ships its own model catalog — including the Claude models the backend exposes (claude-opus-4-6-thinking, claude-sonnet-4-6) — its own account pool, and four ways to connect: - token-file import (paste/upload the agy oauth token JSON) - auto-detect a local CLI login (~/.gemini/antigravity-cli/antigravity-oauth-token) - browser OAuth (via the shared OAuthModal Google loopback flow) - bulk / ZIP import New routes: POST /api/providers/agy-auth/{import,import-bulk,zip-extract,apply-local}. Catalog pinned from the live :fetchAvailableModels endpoint. Docs (openapi.yaml, ENVIRONMENT.md, .env.example, CHANGELOG) updated; new unit tests for registration, the token parser, and route auth-hardening. --- .env.example | 6 + CHANGELOG.md | 4 + docs/reference/ENVIRONMENT.md | 1 + docs/reference/openapi.yaml | 34 +++ open-sse/config/agyModels.ts | 165 +++++++++++ open-sse/config/providerRegistry.ts | 29 ++ open-sse/services/tokenRefresh.ts | 4 + .../onboarding/providerOnboardingCatalog.ts | 1 + .../providers/agy-auth/apply-local/route.ts | 127 +++++++++ .../providers/agy-auth/import-bulk/route.ts | 111 ++++++++ .../api/providers/agy-auth/import/route.ts | 96 +++++++ .../providers/agy-auth/zip-extract/route.ts | 54 ++++ src/lib/oauth/constants/oauth.ts | 29 ++ src/lib/oauth/providers.ts | 5 +- src/lib/oauth/providers/agy.ts | 15 + src/lib/oauth/providers/index.ts | 2 + src/lib/oauth/utils/agyAuthImport.ts | 259 ++++++++++++++++++ src/shared/components/OAuthModal.tsx | 2 +- src/shared/components/lobeProviderIcons.ts | 1 + src/shared/constants/providers.ts | 15 + src/shared/validation/schemas.ts | 41 +++ tests/unit/agy-auth-import.test.ts | 72 +++++ tests/unit/agy-provider.test.ts | 71 +++++ tests/unit/management-auth-hardening.test.ts | 35 +++ tests/unit/oauth-providers-config.test.ts | 4 + 25 files changed, 1180 insertions(+), 3 deletions(-) create mode 100644 open-sse/config/agyModels.ts create mode 100644 src/app/api/providers/agy-auth/apply-local/route.ts create mode 100644 src/app/api/providers/agy-auth/import-bulk/route.ts create mode 100644 src/app/api/providers/agy-auth/import/route.ts create mode 100644 src/app/api/providers/agy-auth/zip-extract/route.ts create mode 100644 src/lib/oauth/providers/agy.ts create mode 100644 src/lib/oauth/utils/agyAuthImport.ts create mode 100644 tests/unit/agy-auth-import.test.ts create mode 100644 tests/unit/agy-provider.test.ts diff --git a/.env.example b/.env.example index 2c91c53830..36ec68c8f6 100644 --- a/.env.example +++ b/.env.example @@ -532,6 +532,12 @@ PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70 # Default: empty (use upstream-reported credits). #ANTIGRAVITY_CREDITS= +# Override the path to the Antigravity CLI (agy) token file read by the +# "auto-detect local login" import. Used by: +# src/app/api/providers/agy-auth/apply-local/route.ts — for non-standard installs. +# Default: ~/.gemini/antigravity-cli/antigravity-oauth-token +#AGY_TOKEN_FILE= + # ═══════════════════════════════════════════════════════════════════════════════ # 11. OAUTH PROVIDER CREDENTIALS diff --git a/CHANGELOG.md b/CHANGELOG.md index 37959fa42b..3b71289305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### ✨ New Features + +- **agy:** add the **Antigravity CLI (`agy`)** as a standalone OAuth provider next to `gemini-cli`/`antigravity`. It reuses the antigravity inference backend (identical Google client, `daily-cloudcode-pa.googleapis.com`) but ships its own model catalog — notably the Claude models the backend exposes (`claude-opus-4-6-thinking`, `claude-sonnet-4-6`) — its own account pool, and connection methods: import the `agy` CLI token file (paste/upload), auto-detect a local CLI login (`~/.gemini/antigravity-cli/antigravity-oauth-token`), browser OAuth, and bulk/ZIP import. New routes: `POST /api/providers/agy-auth/{import,import-bulk,zip-extract,apply-local}`. + ### Breaking Changes - **proxy-logs:** `GET /api/usage/proxy-logs` now returns `clientIp` instead of `publicIp` for each log entry. External consumers reading `log.publicIp` must update to `log.clientIp`. The underlying SQLite column (`public_ip`) is unchanged, so callers that query the database directly are unaffected (#2880 — thanks @rdself). diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 0dc85ae871..3c03e5ac41 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -358,6 +358,7 @@ detection above). | `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. | | `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. | | `ANTIGRAVITY_CREDITS` | _(unset)_ | `open-sse/services/antigravityCredits.ts` | Override Antigravity's advertised remaining credits (testing / forced values). | +| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | Override the Antigravity CLI (agy) token-file path for the auto-detect local login import. | ### OAuth CLI Bridge (Internal) diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index faa3b50d07..e17191c954 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -607,6 +607,40 @@ paths: "200": description: Provider info for frontend + /api/providers/agy-auth/import: + post: + tags: [Providers] + summary: Import an Antigravity CLI (agy) token file as an `agy` connection + responses: + "200": + description: Created or updated provider connection + + /api/providers/agy-auth/import-bulk: + post: + tags: [Providers] + summary: Bulk-import multiple Antigravity CLI (agy) token files (up to 50) + responses: + "200": + description: Per-entry import results (success/failed counts) + + /api/providers/agy-auth/zip-extract: + post: + tags: [Providers] + summary: Extract `.json` token files from an uploaded ZIP for agy bulk import + responses: + "200": + description: Extracted token-file entries + + /api/providers/agy-auth/apply-local: + post: + tags: [Providers] + summary: Auto-detect and import the local Antigravity CLI (agy) login from disk + responses: + "200": + description: Created or updated provider connection + "404": + description: No local agy login found + /api/provider-nodes: get: tags: [Provider Nodes] diff --git a/open-sse/config/agyModels.ts b/open-sse/config/agyModels.ts new file mode 100644 index 0000000000..8ba930782a --- /dev/null +++ b/open-sse/config/agyModels.ts @@ -0,0 +1,165 @@ +// Antigravity CLI (`agy`) model catalog. +// +// These IDs were pinned directly from the live `:fetchAvailableModels` endpoint +// (https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels) using a +// real `agy` consumer-OAuth token on 2026-05-29. They are the exact upstream model IDs +// the Antigravity backend accepts — no alias remapping is required (unlike the +// `antigravity` provider, whose catalog used display IDs that needed remapping). +// +// The `agy` provider reuses the `antigravity` executor/translator (identical backend), +// but ships its OWN catalog so it can expose models the `antigravity` provider's static +// list omits — notably the Claude models (`claude-opus-4-6-thinking`, `claude-sonnet-4-6`), +// which `:fetchAvailableModels` reports as user-callable with quota even though the +// `antigravity` catalog comment assumes they 404. Tab-completion models +// (`tab_flash_lite_preview`, `tab_jump_flash_lite_preview`) are intentionally excluded — +// they are not chat-callable. + +export const AGY_PUBLIC_MODELS = Object.freeze([ + // Claude (Antigravity backend) — the headline differentiator for this provider. + { + id: "claude-opus-4-6-thinking", + name: "Claude Opus 4.6 (Thinking)", + contextLength: 200000, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (Thinking)", + contextLength: 200000, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + // Gemini 3.x + { + id: "gemini-3.1-pro-high", + name: "Gemini 3.1 Pro (High)", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.1-pro-low", + name: "Gemini 3.1 Pro (Low)", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-pro-agent", + name: "Gemini 3.1 Pro (Agent)", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3-flash-agent", + name: "Gemini 3.5 Flash (Agent)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3-flash", + name: "Gemini 3 Flash", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.5-flash-low", + name: "Gemini 3.5 Flash (Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.5-flash-extra-low", + name: "Gemini 3.5 Flash (Extra Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash Lite", + contextLength: 1048576, + maxOutputTokens: 65535, + toolCalling: true, + }, + { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, + // Gemini 2.5 + { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + contextLength: 1048576, + maxOutputTokens: 65535, + toolCalling: true, + }, + { + id: "gemini-2.5-flash-thinking", + name: "Gemini 2.5 Flash Thinking", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + toolCalling: true, + }, + { + id: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash Lite", + contextLength: 1048576, + maxOutputTokens: 65535, + toolCalling: true, + }, + // GPT-OSS + { + id: "gpt-oss-120b-medium", + name: "GPT-OSS 120B (Medium)", + contextLength: 131072, + maxOutputTokens: 32768, + supportsReasoning: true, + toolCalling: true, + }, +]); + +const AGY_PUBLIC_MODEL_IDS = new Set(AGY_PUBLIC_MODELS.map((model) => model.id)); + +const AGY_CLIENT_VISIBLE_MODEL_NAMES = Object.freeze( + AGY_PUBLIC_MODELS.reduce>((acc, model) => { + acc[model.id] = model.name; + return acc; + }, {}) +); + +export function getClientVisibleAgyModelName(modelId: string, fallbackName?: string): string { + return AGY_CLIENT_VISIBLE_MODEL_NAMES[modelId] || fallbackName || modelId; +} + +export function isUserCallableAgyModelId(modelId: string): boolean { + return !!modelId && AGY_PUBLIC_MODEL_IDS.has(modelId); +} diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 07269eb476..db3da3036e 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -8,6 +8,7 @@ import { ANTIGRAVITY_BASE_URLS } from "./antigravityUpstream.ts"; import { ANTIGRAVITY_PUBLIC_MODELS } from "./antigravityModelAliases.ts"; +import { AGY_PUBLIC_MODELS } from "./agyModels.ts"; import { ANTHROPIC_BETA_API_KEY, ANTHROPIC_BETA_CLAUDE_OAUTH, @@ -911,6 +912,34 @@ export const REGISTRY: Record = { passthroughModels: true, }, + // Antigravity CLI (`agy`): standalone provider that reuses the antigravity executor, + // format and backend (identical client_id + daily-cloudcode-pa endpoint), but ships its + // own model catalog (incl. Claude) and its own account pool / OAuth credential import. + agy: { + id: "agy", + alias: "agy", + format: "antigravity", + executor: "antigravity", + baseUrls: [...ANTIGRAVITY_BASE_URLS], + urlBuilder: (base, model, stream) => { + const path = stream + ? "/v1internal:streamGenerateContent?alt=sse" + : "/v1internal:generateContent"; + return `${base}${path}`; + }, + authType: "oauth", + authHeader: "bearer", + headers: getAntigravityProviderHeaders(), + oauth: { + clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID", + clientIdDefault: resolvePublicCred("antigravity_id"), + clientSecretEnv: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", + clientSecretDefault: resolvePublicCred("antigravity_alt"), + }, + models: [...AGY_PUBLIC_MODELS], + passthroughModels: true, + }, + github: { id: "github", alias: "gh", diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 55a7e0e2a2..ae7ea2e6a1 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -44,6 +44,7 @@ export const REFRESH_LEAD_MS: Record = { // is safe and reduces unnecessary upstream chatter. "gemini-cli": 15 * 60 * 1000, antigravity: 15 * 60 * 1000, + agy: 15 * 60 * 1000, // same Google backend as antigravity (non-rotating refresh tokens) }; /** @@ -1293,6 +1294,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: case "gemini": case "gemini-cli": case "antigravity": + case "agy": return await refreshGoogleToken( credentials.refreshToken, PROVIDERS[provider].clientId, @@ -1367,6 +1369,7 @@ export function supportsTokenRefresh(provider) { "gemini", "gemini-cli", "antigravity", + "agy", "claude", "codex", "qwen", @@ -1671,6 +1674,7 @@ export function formatProviderCredentials(provider, credentials, log) { }; case "antigravity": + case "agy": case "gemini-cli": return { accessToken: credentials.accessToken, diff --git a/src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts b/src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts index 624723ead1..ba022017d3 100644 --- a/src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts +++ b/src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts @@ -39,6 +39,7 @@ export const SUPPORTED_WIZARD_OAUTH_PROVIDER_IDS = new Set([ "codex", "gemini-cli", "antigravity", + "agy", "qwen", "kimi-coding", "github", diff --git a/src/app/api/providers/agy-auth/apply-local/route.ts b/src/app/api/providers/agy-auth/apply-local/route.ts new file mode 100644 index 0000000000..c5c4dc16fa --- /dev/null +++ b/src/app/api/providers/agy-auth/apply-local/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from "next/server"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { + AgyAuthFileError, + parseAndValidateAgyToken, + enrichWithAntigravityBackend, + createConnectionFromAgyToken, +} from "@/lib/oauth/utils/agyAuthImport"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { getProviderAuditTarget } from "@/lib/compliance/providerAudit"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { applyLocalAgyAuthSchema } from "@/shared/validation/schemas"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults"; + +/** + * Resolve the Antigravity CLI token-file path. The path is fixed (no request input + * reaches the filesystem APIs); an operator-controlled env override is allowed for + * non-standard installs. Default: ~/.gemini/antigravity-cli/antigravity-oauth-token. + */ +function getAgyTokenFilePath(): string { + const override = process.env.AGY_TOKEN_FILE; + if (override && override.trim()) return override.trim(); + return path.join(os.homedir(), ".gemini", "antigravity-cli", "antigravity-oauth-token"); +} + +function sanitizeConnectionForResponse(connection: Record) { + const safe = { ...connection }; + delete safe.accessToken; + delete safe.refreshToken; + delete safe.idToken; + delete safe.apiKey; + if (safe.providerSpecificData) { + safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData); + } + return safe; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + // Body is optional for this route; tolerate an empty/absent body. + let body: unknown = {}; + try { + const text = await request.text(); + if (text.trim()) body = JSON.parse(text); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsedBody = validateBody(applyLocalAgyAuthSchema, body); + if (isValidationFailure(parsedBody)) { + return NextResponse.json({ error: parsedBody.error }, { status: 400 }); + } + // Re-detecting a local login is an explicit refresh action: default to replacing + // any existing connection for the same account unless the caller opts out. + const { name, email, overwriteExisting = true } = parsedBody.data; + + const tokenPath = getAgyTokenFilePath(); + let rawJson: unknown; + try { + const content = await fs.readFile(tokenPath, "utf8"); + rawJson = JSON.parse(content); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === "ENOENT") { + return NextResponse.json( + { + error: + "No local Antigravity CLI login found. Run `agy`, sign in with Google, then try again.", + code: "no_local_login", + }, + { status: 404 } + ); + } + return NextResponse.json( + { error: "Could not read the local agy token file", code: "read_failed" }, + { status: 500 } + ); + } + + try { + const parsed = parseAndValidateAgyToken(rawJson); + const enriched = await enrichWithAntigravityBackend(parsed); + const { connection, created } = await createConnectionFromAgyToken(enriched, { + name, + email, + overwriteExisting, + }); + + logAuditEvent({ + action: "provider.credentials.imported", + actor: "admin", + target: getProviderAuditTarget(connection), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "agy", + created, + source: "apply-local", + email: enriched.email || email, + hasProjectId: !!enriched.projectId, + }, + }); + + return NextResponse.json({ + connection: sanitizeConnectionForResponse(connection as Record), + created, + }); + } catch (error) { + if (error instanceof AgyAuthFileError) { + return NextResponse.json({ error: error.message, code: error.code }, { status: error.status }); + } + return NextResponse.json( + { error: sanitizeErrorMessage(error) || "Failed to import local Antigravity CLI login" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/agy-auth/import-bulk/route.ts b/src/app/api/providers/agy-auth/import-bulk/route.ts new file mode 100644 index 0000000000..4d550da417 --- /dev/null +++ b/src/app/api/providers/agy-auth/import-bulk/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { + AgyAuthFileError, + parseAndValidateAgyToken, + enrichWithAntigravityBackend, + createConnectionFromAgyToken, +} from "@/lib/oauth/utils/agyAuthImport"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { getProviderAuditTarget } from "@/lib/compliance/providerAudit"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { importAgyAuthBulkSchema } from "@/shared/validation/schemas"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults"; + +function sanitizeConnectionForResponse(connection: Record) { + const safe = { ...connection }; + delete safe.accessToken; + delete safe.refreshToken; + delete safe.idToken; + delete safe.apiKey; + if (safe.providerSpecificData) { + safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData); + } + return safe; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsedBody = validateBody(importAgyAuthBulkSchema, body); + if (isValidationFailure(parsedBody)) { + return NextResponse.json({ error: parsedBody.error }, { status: 400 }); + } + + const { entries, overwriteExisting } = parsedBody.data; + + const created: Record[] = []; + const errors: { index: number; name: string; message: string }[] = []; + + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + const label = e.name || `entry ${i + 1}`; + try { + const parsed = parseAndValidateAgyToken(e.json); + const enriched = await enrichWithAntigravityBackend(parsed); + const { connection } = await createConnectionFromAgyToken(enriched, { + name: e.name, + email: e.email, + overwriteExisting, + }); + + created.push(sanitizeConnectionForResponse(connection as Record)); + + logAuditEvent({ + action: "provider.credentials.imported", + actor: "admin", + target: getProviderAuditTarget(connection), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "agy", + email: enriched.email || e.email, + bulkIndex: i, + }, + }); + } catch (err) { + const message = + err instanceof AgyAuthFileError + ? err.message + : sanitizeErrorMessage(err) || "Failed to import"; + errors.push({ index: i, name: label, message }); + } + } + + logAuditEvent({ + action: "provider.credentials.bulk_imported", + actor: "admin", + target: "agy", + resourceType: "provider_credentials", + status: errors.length === entries.length ? "failure" : "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "agy", + total: entries.length, + success: created.length, + failed: errors.length, + }, + }); + + return NextResponse.json({ + success: created.length, + failed: errors.length, + total: entries.length, + created, + errors, + }); +} diff --git a/src/app/api/providers/agy-auth/import/route.ts b/src/app/api/providers/agy-auth/import/route.ts new file mode 100644 index 0000000000..95d5c5c399 --- /dev/null +++ b/src/app/api/providers/agy-auth/import/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { + AgyAuthFileError, + parseAndValidateAgyToken, + enrichWithAntigravityBackend, + createConnectionFromAgyToken, +} from "@/lib/oauth/utils/agyAuthImport"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { getProviderAuditTarget } from "@/lib/compliance/providerAudit"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { importAgyAuthSchema } from "@/shared/validation/schemas"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults"; + +function sanitizeConnectionForResponse(connection: Record) { + const safe = { ...connection }; + delete safe.accessToken; + delete safe.refreshToken; + delete safe.idToken; + delete safe.apiKey; + if (safe.providerSpecificData) { + safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData); + } + return safe; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsedBody = validateBody(importAgyAuthSchema, body); + if (isValidationFailure(parsedBody)) { + return NextResponse.json({ error: parsedBody.error }, { status: 400 }); + } + + const { source, name, email, overwriteExisting } = parsedBody.data; + + let rawJson: unknown; + try { + rawJson = source.kind === "json" ? source.json : JSON.parse(source.text); + } catch { + return NextResponse.json( + { error: "Could not parse the content as JSON", code: "invalid_json" }, + { status: 400 } + ); + } + + try { + const parsed = parseAndValidateAgyToken(rawJson); + const enriched = await enrichWithAntigravityBackend(parsed); + const { connection, created } = await createConnectionFromAgyToken(enriched, { + name, + email, + overwriteExisting, + }); + + logAuditEvent({ + action: "provider.credentials.imported", + actor: "admin", + target: getProviderAuditTarget(connection), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "agy", + created, + email: enriched.email || email, + hasProjectId: !!enriched.projectId, + }, + }); + + return NextResponse.json({ + connection: sanitizeConnectionForResponse(connection as Record), + created, + }); + } catch (error) { + if (error instanceof AgyAuthFileError) { + return NextResponse.json({ error: error.message, code: error.code }, { status: error.status }); + } + return NextResponse.json( + { error: sanitizeErrorMessage(error) || "Failed to import Antigravity CLI auth" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/agy-auth/zip-extract/route.ts b/src/app/api/providers/agy-auth/zip-extract/route.ts new file mode 100644 index 0000000000..32276ef249 --- /dev/null +++ b/src/app/api/providers/agy-auth/zip-extract/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +// extractGeminiAuthZip is a generic, content-agnostic .json-from-ZIP extractor +// (path-traversal + size guards); reused as-is for agy token-file archives. +import { extractGeminiAuthZip } from "@/lib/oauth/utils/geminiAuthZipExtract"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const ZIP_BODY_LIMIT = 11 * 1024 * 1024; // 11 MB — slightly above the 10 MB extracted limit + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const contentLength = Number(request.headers.get("content-length") || "0"); + if (contentLength > ZIP_BODY_LIMIT) { + return NextResponse.json( + { error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" }, + { status: 413 } + ); + } + + let buffer: Buffer; + try { + const arrayBuffer = await request.arrayBuffer(); + if (arrayBuffer.byteLength > ZIP_BODY_LIMIT) { + return NextResponse.json( + { error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" }, + { status: 413 } + ); + } + buffer = Buffer.from(arrayBuffer); + } catch { + return NextResponse.json({ error: "Failed to read request body" }, { status: 400 }); + } + + try { + const files = extractGeminiAuthZip(buffer); + + const entries = files.map((f) => { + try { + return { name: f.name, json: JSON.parse(f.content), parseError: null }; + } catch { + return { name: f.name, json: null, parseError: "Not valid JSON" }; + } + }); + + return NextResponse.json({ entries }); + } catch (error) { + return NextResponse.json( + { error: sanitizeErrorMessage(error) || "Failed to extract ZIP", code: "extract_failed" }, + { status: 400 } + ); + } +} diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 576d1d10be..9d4f29c731 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -187,6 +187,34 @@ export const ANTIGRAVITY_CONFIG = { loadCodeAssistClientMetadata: getAntigravityLoadCodeAssistClientMetadata(), }; +// Antigravity CLI (`agy`) OAuth Configuration. +// `agy` is the standalone Antigravity CLI; it authenticates against the EXACT same Google +// consumer-OAuth client as ANTIGRAVITY_CONFIG (the client_id was verified byte-for-byte +// identical: 1071006060591-tmhssin2h21lcre235vtolojh4g403ep). It reuses the antigravity +// public credentials and Code Assist endpoints — no new embedded secret — and the same +// loopback-redirect browser flow (popup locally; paste-the-callback-URL on remote/headless), +// so the entire existing antigravity OAuth UI machinery applies unchanged. +export const AGY_CONFIG = { + clientId: resolvePublicCred("antigravity_id", "ANTIGRAVITY_OAUTH_CLIENT_ID"), + clientSecret: resolvePublicCred("antigravity_alt", "ANTIGRAVITY_OAUTH_CLIENT_SECRET"), + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo", + scopes: [...ANTIGRAVITY_CONFIG.scopes], + // Reuse the antigravity Code Assist endpoints (identical backend). + apiEndpoint: ANTIGRAVITY_CONFIG.apiEndpoint, + apiVersion: ANTIGRAVITY_CONFIG.apiVersion, + loadCodeAssistEndpoints: [...ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints], + onboardUserEndpoints: [...ANTIGRAVITY_CONFIG.onboardUserEndpoints], + fetchAvailableModelsEndpoints: [...ANTIGRAVITY_CONFIG.fetchAvailableModelsEndpoints], + loadCodeAssistEndpoint: ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, + onboardUserEndpoint: ANTIGRAVITY_CONFIG.onboardUserEndpoint, + fetchAvailableModelsEndpoint: ANTIGRAVITY_CONFIG.fetchAvailableModelsEndpoint, + loadCodeAssistUserAgent: ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, + loadCodeAssistApiClient: ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, + loadCodeAssistClientMetadata: ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, +}; + // OpenAI OAuth Configuration (Authorization Code Flow with PKCE) // Re-uses CODEX_CONFIG.clientId to avoid duplication — same provider, different originator. // IMPORTANT: same Auth0 backend as Codex → same multi-account session-takeover @@ -383,6 +411,7 @@ export const PROVIDERS = { QWEN: "qwen", QODER: "qoder", ANTIGRAVITY: "antigravity", + AGY: "agy", KIMI_CODING: "kimi-coding", OPENAI: "openai", GITHUB: "github", diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index 92749f83e4..a4253c03f8 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -10,7 +10,7 @@ import { generatePKCE, generateState } from "./utils/pkce"; import { PROVIDERS } from "./providers/index"; -const GOOGLE_BROWSER_PROVIDERS = new Set(["antigravity", "gemini-cli"]); +const GOOGLE_BROWSER_PROVIDERS = new Set(["antigravity", "agy", "gemini-cli"]); type OAuthRedirectEnv = Record; @@ -32,7 +32,8 @@ function hasCustomGoogleOAuthCredentials( providerName: string, env: OAuthRedirectEnv | null | undefined = process.env ): boolean { - if (providerName === "antigravity") { + if (providerName === "antigravity" || providerName === "agy") { + // `agy` reuses the antigravity OAuth client + env overrides. return ( hasValue(env?.ANTIGRAVITY_OAUTH_CLIENT_ID) && hasValue(env?.ANTIGRAVITY_OAUTH_CLIENT_SECRET) diff --git a/src/lib/oauth/providers/agy.ts b/src/lib/oauth/providers/agy.ts new file mode 100644 index 0000000000..3ff2caed91 --- /dev/null +++ b/src/lib/oauth/providers/agy.ts @@ -0,0 +1,15 @@ +import { antigravity } from "./antigravity"; +import { AGY_CONFIG } from "../constants/oauth"; + +/** + * Antigravity CLI (`agy`) OAuth provider module. + * + * `agy` targets the identical Google consumer-OAuth backend as the `antigravity` provider + * (same client_id, scopes, token URL and Code Assist endpoints — verified byte-for-byte), + * so it reuses the antigravity authorization-code + PKCE flow (`buildAuthUrl`, + * `exchangeToken`, `postExchange`, `mapTokens`) wholesale. Only the `config` label differs; + * `AGY_CONFIG` resolves to the same embedded antigravity credentials. Kept as its own + * module so `agy` is a first-class, standalone entry in the OAuth provider registry and can + * diverge later without touching the antigravity flow. + */ +export const agy = { ...antigravity, config: AGY_CONFIG }; diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 5b7b77f09f..208fbbacdb 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -14,6 +14,7 @@ import { claude } from "./claude"; import { codex } from "./codex"; import { gemini } from "./gemini"; import { antigravity } from "./antigravity"; +import { agy } from "./agy"; import { qoder } from "./qoder"; import { qwen } from "./qwen"; import { kimiCoding } from "./kimi-coding"; @@ -31,6 +32,7 @@ export const PROVIDERS = { codex, "gemini-cli": gemini, antigravity, + agy, qoder, qwen, "kimi-coding": kimiCoding, diff --git a/src/lib/oauth/utils/agyAuthImport.ts b/src/lib/oauth/utils/agyAuthImport.ts new file mode 100644 index 0000000000..05a256eaa6 --- /dev/null +++ b/src/lib/oauth/utils/agyAuthImport.ts @@ -0,0 +1,259 @@ +import { + getProviderConnections, + createProviderConnection, + updateProviderConnection, +} from "@/lib/localDb"; +import { AGY_CONFIG } from "@/lib/oauth/constants/oauth"; +import { + getAntigravityHeaders, + getAntigravityLoadCodeAssistMetadata, +} from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +/** + * Error carrying an HTTP status + machine code, mirroring GeminiAuthFileError so the + * agy-auth routes can translate it to a clean response (never a raw stack trace). + */ +export class AgyAuthFileError extends Error { + status: number; + code: string; + + constructor(message: string, status = 400, code = "invalid_request") { + super(message); + this.name = "AgyAuthFileError"; + this.status = status; + this.code = code; + } +} + +// ──── Public types ──────────────────────────────────────────────────────────── + +export interface ParsedAgyAuth { + accessToken: string; + refreshToken: string; + tokenType: string; + expiresAt: string | null; + authMethod: string | null; +} + +export interface EnrichedAgyAuth extends ParsedAgyAuth { + email: string | null; + projectId: string | null; + tier: string | null; +} + +export interface CreateAgyConnectionOptions { + name?: string; + email?: string; + overwriteExisting?: boolean; +} + +// ──── Parse & validate ──────────────────────────────────────────────────────── + +/** + * Parse the Antigravity CLI (`agy`) token file. Unlike gemini-cli's flat + * `oauth_creds.json`, the agy file nests the token under `.token`, uses an ISO `expiry` + * string, and has NO `id_token`. A flat top-level shape is accepted as a fallback. + */ +export function parseAndValidateAgyToken(raw: unknown): ParsedAgyAuth { + const doc = toRecord(raw); + // agy nests credentials under `.token`; fall back to the top level for flat exports. + const token = doc.token && typeof doc.token === "object" ? toRecord(doc.token) : doc; + + const accessToken = toNonEmptyString(token.access_token); + const refreshToken = toNonEmptyString(token.refresh_token); + + if (!accessToken) { + throw new AgyAuthFileError( + "access_token is missing or empty in the agy token file", + 400, + "missing_access_token" + ); + } + + if (!refreshToken) { + throw new AgyAuthFileError( + "refresh_token is missing or empty in the agy token file", + 400, + "missing_refresh_token" + ); + } + + // agy uses an ISO `expiry`; also accept a unix-ms `expiry_date`/`expires_at` for safety. + let expiresAt: string | null = null; + const isoExpiry = toNonEmptyString(token.expiry) ?? toNonEmptyString(token.expires_at); + if (isoExpiry) { + const ms = new Date(isoExpiry).getTime(); + expiresAt = Number.isNaN(ms) ? null : new Date(ms).toISOString(); + } else if (typeof token.expiry_date === "number" && Number.isFinite(token.expiry_date)) { + expiresAt = new Date(token.expiry_date).toISOString(); + } + + const tokenType = toNonEmptyString(token.token_type) ?? "Bearer"; + const authMethod = toNonEmptyString(doc.auth_method) ?? toNonEmptyString(token.auth_method); + + return { accessToken, refreshToken, tokenType, expiresAt, authMethod }; +} + +// ──── Enrich with the Antigravity Code Assist backend ───────────────────────── + +/** + * Resolve the account email (userinfo) and GCP project id (loadCodeAssist) for the token. + * Best-effort + time-boxed; the agy CLI has already onboarded the project, so we do NOT + * run the onboardUser provisioning loop here (that can take up to ~50s). + */ +export async function enrichWithAntigravityBackend( + parsed: ParsedAgyAuth +): Promise { + let email: string | null = null; + let projectId: string | null = null; + let tier: string | null = null; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + try { + const userInfoRes = await fetch(`${AGY_CONFIG.userInfoUrl}?alt=json`, { + headers: { Authorization: `Bearer ${parsed.accessToken}` }, + signal: controller.signal, + }); + if (userInfoRes.ok) { + email = toNonEmptyString(toRecord(await userInfoRes.json()).email); + } + } catch { + // best effort — email stays null + } finally { + clearTimeout(timer); + } + + const loadController = new AbortController(); + const loadTimer = setTimeout(() => loadController.abort(), 8000); + try { + const headers = getAntigravityHeaders("loadCodeAssist", parsed.accessToken); + const metadata = getAntigravityLoadCodeAssistMetadata(); + for (const endpoint of AGY_CONFIG.loadCodeAssistEndpoints) { + try { + const res = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ metadata }), + signal: loadController.signal, + }); + if (!res.ok) continue; + const data = toRecord(await res.json()); + const project = data.cloudaicompanionProject; + projectId = + (typeof project === "string" ? toNonEmptyString(project) : null) ?? + toNonEmptyString(toRecord(project).id); + tier = extractCodeAssistOnboardTierId(data) || null; + break; + } catch { + // try next endpoint + } + } + } catch { + // best effort — projectId stays null + } finally { + clearTimeout(loadTimer); + } + + return { ...parsed, email, projectId, tier }; +} + +// ──── Find existing connection ──────────────────────────────────────────────── + +export async function findExistingAgyConnection(email: string): Promise { + const connections = await getProviderConnections({ provider: "agy" }); + const lowerEmail = email.toLowerCase(); + return ( + (connections.find((c) => { + const conn = c as JsonRecord; + return toNonEmptyString(conn.email)?.toLowerCase() === lowerEmail; + }) as JsonRecord | undefined) ?? null + ); +} + +// ──── Create / update connection ────────────────────────────────────────────── + +export async function createConnectionFromAgyToken( + enriched: EnrichedAgyAuth, + options: CreateAgyConnectionOptions +): Promise<{ connection: JsonRecord; created: boolean }> { + const resolvedEmail = options.email || enriched.email; + + if (resolvedEmail) { + const existing = await findExistingAgyConnection(resolvedEmail); + if (existing) { + if (!options.overwriteExisting) { + throw new AgyAuthFileError( + "An Antigravity CLI connection for this account already exists. Pass overwriteExisting: true to replace it.", + 409, + "duplicate_account" + ); + } + + const updated = await updateProviderConnection(existing.id as string, { + accessToken: enriched.accessToken, + refreshToken: enriched.refreshToken, + expiresAt: enriched.expiresAt, + email: resolvedEmail || (existing.email as string | undefined), + name: + options.name || + (existing.name as string | undefined) || + resolvedEmail || + "Antigravity CLI (imported)", + testStatus: "active", + providerSpecificData: { + ...toRecord(existing.providerSpecificData), + tokenType: enriched.tokenType, + authMethod: enriched.authMethod, + projectId: enriched.projectId ?? toRecord(existing.providerSpecificData).projectId, + tier: enriched.tier ?? toRecord(existing.providerSpecificData).tier, + importedAt: new Date().toISOString(), + }, + }); + + return { connection: updated || existing, created: false }; + } + } else if (!options.overwriteExisting) { + throw new AgyAuthFileError( + "Could not verify the account email from the agy token (no userinfo). Pass overwriteExisting: true to import without email verification.", + 409, + "identity_unverified" + ); + } + + const name = options.name || resolvedEmail || "Antigravity CLI (imported)"; + + const connection = await createProviderConnection({ + provider: "agy", + authType: "oauth", + name, + email: resolvedEmail || undefined, + accessToken: enriched.accessToken, + refreshToken: enriched.refreshToken, + expiresAt: enriched.expiresAt, + isActive: true, + testStatus: "active", + providerSpecificData: { + tokenType: enriched.tokenType, + authMethod: enriched.authMethod, + projectId: enriched.projectId, + tier: enriched.tier, + importedAt: new Date().toISOString(), + }, + }); + + return { connection, created: true }; +} diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index bc98b8ca64..c431c5c7b8 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -7,7 +7,7 @@ import Button from "./Button"; import Input from "./Input"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; -const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "gemini-cli"]); +const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy", "gemini-cli"]); /** Providers that use a local callback server on a random port (PKCE browser flow). */ const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex"]); diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index cdc50b4e22..cb554ffd2d 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -301,6 +301,7 @@ const LOBE_PROVIDER_ALIASES = { "amazon-q": "Aws", anthropic: "Anthropic", antigravity: "Antigravity", + agy: "Antigravity", // Antigravity CLI — same brand icon as the antigravity provider assemblyai: "AssemblyAI", "aws-polly": "Aws", azure: "Azure", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 964dbd1911..ad07ec92ce 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -89,6 +89,20 @@ export const OAUTH_PROVIDERS = { authHint: "Uses Gemini CLI OAuth / Cloud Code credentials. Pro models require an eligible Google account or paid plan.", }, + agy: { + id: "agy", + alias: "agy", + name: "Antigravity CLI", + icon: "terminal", + color: "#F59E0B", + textIcon: "AGY", + website: "https://antigravity.google", + subscriptionRisk: true, + riskNoticeVariant: "oauth", + hasFree: true, + authHint: + "Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models).", + }, kiro: { id: "kiro", alias: "kr", @@ -2896,6 +2910,7 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce { + const parsed = parseAndValidateAgyToken({ + token: { + access_token: ACCESS, + token_type: "Bearer", + refresh_token: REFRESH, + expiry: "2026-05-29T06:16:24.338-03:00", + }, + auth_method: "consumer", + }); + assert.equal(parsed.accessToken, ACCESS); + assert.equal(parsed.refreshToken, REFRESH); + assert.equal(parsed.tokenType, "Bearer"); + assert.equal(parsed.authMethod, "consumer"); + assert.equal(parsed.expiresAt, new Date("2026-05-29T06:16:24.338-03:00").toISOString()); +}); + +test("accepts a flat fallback shape (access_token/refresh_token at top level)", () => { + const parsed = parseAndValidateAgyToken({ + access_token: ACCESS, + refresh_token: REFRESH, + }); + assert.equal(parsed.accessToken, ACCESS); + assert.equal(parsed.refreshToken, REFRESH); + assert.equal(parsed.tokenType, "Bearer"); // default + assert.equal(parsed.expiresAt, null); +}); + +test("supports unix-ms expiry_date as an alternative to ISO expiry", () => { + const ms = 1780038984000; + const parsed = parseAndValidateAgyToken({ + token: { access_token: ACCESS, refresh_token: REFRESH, expiry_date: ms }, + }); + assert.equal(parsed.expiresAt, new Date(ms).toISOString()); +}); + +test("rejects a token file missing access_token", () => { + assert.throws( + () => parseAndValidateAgyToken({ token: { refresh_token: REFRESH } }), + (err) => + err instanceof AgyAuthFileError && + err.code === "missing_access_token" && + err.status === 400 + ); +}); + +test("rejects a token file missing refresh_token", () => { + assert.throws( + () => parseAndValidateAgyToken({ token: { access_token: ACCESS } }), + (err) => err instanceof AgyAuthFileError && err.code === "missing_refresh_token" + ); +}); + +test("invalid/garbage expiry becomes null rather than throwing", () => { + const parsed = parseAndValidateAgyToken({ + token: { access_token: ACCESS, refresh_token: REFRESH, expiry: "not-a-date" }, + }); + assert.equal(parsed.expiresAt, null); +}); diff --git a/tests/unit/agy-provider.test.ts b/tests/unit/agy-provider.test.ts new file mode 100644 index 0000000000..b7c922e4f5 --- /dev/null +++ b/tests/unit/agy-provider.test.ts @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { AI_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts"; +import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; +import { PROVIDERS as LEGACY_PROVIDERS } from "../../open-sse/config/constants.ts"; +import { PROVIDERS as OAUTH_PROVIDER_IDS, AGY_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; +import { supportsTokenRefresh, REFRESH_LEAD_MS } from "../../open-sse/services/tokenRefresh.ts"; +import { + AGY_PUBLIC_MODELS, + isUserCallableAgyModelId, + getClientVisibleAgyModelName, +} from "../../open-sse/config/agyModels.ts"; + +test("agy is registered as an OAuth provider in the UI catalog", () => { + const agy = AI_PROVIDERS.agy; + assert.ok(agy, "AI_PROVIDERS.agy must exist"); + assert.equal(agy.id, "agy"); + assert.equal(agy.name, "Antigravity CLI"); + assert.equal(agy.riskNoticeVariant, "oauth"); + assert.equal(agy.subscriptionRisk, true); +}); + +test("agy supports the usage/quota API", () => { + assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("agy")); +}); + +test("agy registry entry reuses the antigravity backend (no duplicate executor/format)", () => { + const agy = REGISTRY.agy; + assert.ok(agy, "REGISTRY.agy must exist"); + assert.equal(agy.format, "antigravity"); + assert.equal(agy.executor, "antigravity"); + assert.equal(agy.authType, "oauth"); + assert.equal(agy.authHeader, "bearer"); + assert.equal(agy.passthroughModels, true); +}); + +test("agy reuses the identical antigravity Google OAuth credentials (no new embedded secret)", () => { + // The agy client_id was verified byte-for-byte identical to antigravity's. + assert.equal(LEGACY_PROVIDERS.agy.clientId, LEGACY_PROVIDERS.antigravity.clientId); + assert.equal(LEGACY_PROVIDERS.agy.clientSecret, LEGACY_PROVIDERS.antigravity.clientSecret); + assert.equal(AGY_CONFIG.clientId, LEGACY_PROVIDERS.antigravity.clientId); + assert.equal(OAUTH_PROVIDER_IDS.AGY, "agy"); +}); + +test("agy ships its own catalog including the Claude models antigravity omits", () => { + const ids = REGISTRY.agy.models.map((m) => m.id); + assert.ok(ids.includes("claude-opus-4-6-thinking"), "must expose Claude Opus 4.6 Thinking"); + assert.ok(ids.includes("claude-sonnet-4-6"), "must expose Claude Sonnet 4.6"); + // Tab-completion models are not chat-callable and must be excluded. + assert.ok(!ids.includes("tab_flash_lite_preview")); + assert.ok(!ids.includes("tab_jump_flash_lite_preview")); + assert.equal(ids.length, AGY_PUBLIC_MODELS.length); +}); + +test("agy model helpers resolve catalog ids and display names", () => { + assert.equal(isUserCallableAgyModelId("claude-opus-4-6-thinking"), true); + assert.equal(isUserCallableAgyModelId("tab_flash_lite_preview"), false); + assert.equal(isUserCallableAgyModelId(""), false); + assert.equal( + getClientVisibleAgyModelName("claude-opus-4-6-thinking"), + "Claude Opus 4.6 (Thinking)" + ); + assert.equal(getClientVisibleAgyModelName("unknown-model", "Fallback"), "Fallback"); +}); + +test("agy token refresh is wired on the Google (non-rotating) refresh path", () => { + assert.equal(supportsTokenRefresh("agy"), true); + // Same 15-minute proactive lead as antigravity (Google refresh tokens are permanent). + assert.equal(REFRESH_LEAD_MS.agy, REFRESH_LEAD_MS.antigravity); +}); diff --git a/tests/unit/management-auth-hardening.test.ts b/tests/unit/management-auth-hardening.test.ts index b30158b207..455e54c5b9 100644 --- a/tests/unit/management-auth-hardening.test.ts +++ b/tests/unit/management-auth-hardening.test.ts @@ -98,6 +98,41 @@ test("provider validation routes require management authentication before readin } }); +test("Antigravity CLI (agy) credential import routes require management authentication before reading the body", () => { + // Routes that parse a JSON body — auth MUST run before request.json(). + const jsonBodyRoutes = [ + "src/app/api/providers/agy-auth/import/route.ts", + "src/app/api/providers/agy-auth/import-bulk/route.ts", + ]; + for (const routePath of jsonBodyRoutes) { + const content = fs.readFileSync(routePath, "utf8"); + assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath); + assert.ok( + content.includes("const authError = await requireManagementAuth(request);"), + routePath + ); + assert.ok(content.includes("if (authError) return authError;"), routePath); + assert.ok( + content.indexOf("requireManagementAuth(request)") < content.indexOf("request.json()"), + `${routePath} should authenticate before parsing the submitted token` + ); + } + // Routes that read non-JSON bodies (local file / uploaded ZIP) — auth still comes first. + const otherBodyRoutes = [ + "src/app/api/providers/agy-auth/apply-local/route.ts", + "src/app/api/providers/agy-auth/zip-extract/route.ts", + ]; + for (const routePath of otherBodyRoutes) { + const content = fs.readFileSync(routePath, "utf8"); + assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath); + assert.ok( + content.includes("const authError = await requireManagementAuth(request);"), + routePath + ); + assert.ok(content.includes("if (authError) return authError;"), routePath); + } +}); + test("usage analytics and request log routes require management authentication", () => { const routePaths = [ "src/app/api/usage/analytics/route.ts", diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 7f6c9b4ab0..be6fb012c0 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -24,6 +24,7 @@ const PROVIDERS = providersModule.default; const { resolveBrowserOAuthRedirectUri } = oauthHelpersModule; const { ANTIGRAVITY_CONFIG, + AGY_CONFIG, CLAUDE_CONFIG, CLINE_CONFIG, CODEX_CONFIG, @@ -51,6 +52,7 @@ const EXPECTED_PROVIDER_KEYS = [ "codex", "gemini-cli", "antigravity", + "agy", "qoder", "qwen", "kimi-coding", @@ -71,6 +73,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { codex: CODEX_CONFIG, "gemini-cli": GEMINI_CONFIG, antigravity: ANTIGRAVITY_CONFIG, + agy: AGY_CONFIG, qoder: QODER_CONFIG, qwen: QWEN_CONFIG, "kimi-coding": KIMI_CODING_CONFIG, @@ -91,6 +94,7 @@ const REQUIRED_FIELDS_BY_PROVIDER = { codex: ["authorizeUrl", "tokenUrl", "scope", "clientId"], "gemini-cli": ["authorizeUrl", "tokenUrl", "userInfoUrl", "scopes", "clientId"], antigravity: ["authorizeUrl", "tokenUrl", "userInfoUrl", "scopes", "clientId"], + agy: ["authorizeUrl", "tokenUrl", "userInfoUrl", "scopes", "clientId"], qoder: ["extraParams"], qwen: ["deviceCodeUrl", "tokenUrl", "scope", "clientId"], "kimi-coding": ["deviceCodeUrl", "tokenUrl", "clientId"],