feat(api,oauth): add agy (Antigravity CLI) standalone provider with CLI token import (#2899)

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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-29 09:16:15 -03:00
committed by GitHub
parent d78088fd84
commit 067e0496cf
25 changed files with 1180 additions and 3 deletions

View File

@@ -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

View File

@@ -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).

View File

@@ -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)

View File

@@ -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]

View File

@@ -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<Record<string, string>>((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);
}

View File

@@ -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<string, RegistryEntry> = {
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",

View File

@@ -44,6 +44,7 @@ export const REFRESH_LEAD_MS: Record<string, number> = {
// 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,

View File

@@ -39,6 +39,7 @@ export const SUPPORTED_WIZARD_OAUTH_PROVIDER_IDS = new Set([
"codex",
"gemini-cli",
"antigravity",
"agy",
"qwen",
"kimi-coding",
"github",

View File

@@ -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<string, unknown>) {
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<string, unknown>),
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 }
);
}
}

View File

@@ -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<string, unknown>) {
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<string, unknown>[] = [];
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<string, unknown>));
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,
});
}

View File

@@ -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<string, unknown>) {
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<string, unknown>),
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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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",

View File

@@ -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<string, string | undefined>;
@@ -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)

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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<string, unknown>;
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<EnrichedAgyAuth> {
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<JsonRecord | null> {
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 };
}

View File

@@ -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"]);

View File

@@ -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",

View File

@@ -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<Record<string, str
// Providers that support usage/quota API
export const USAGE_SUPPORTED_PROVIDERS = [
"antigravity",
"agy",
"gemini-cli",
"kiro",
"amazon-q",

View File

@@ -429,6 +429,31 @@ export const importGeminiAuthSchema = z.object({
overwriteExisting: z.boolean().optional(),
});
// ──── Antigravity CLI (`agy`) Auth Import Schema ────
// Same source/options shape as gemini-cli; the parser handles the agy-specific token JSON.
export const importAgyAuthSchema = z.object({
source: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("json"), json: z.unknown() }),
z.object({
kind: z.literal("text"),
text: z.string().max(256 * 1024, "agy token file content exceeds 256KB"),
}),
]),
name: z.string().min(1).max(200).optional(),
email: z.string().email("Must be a valid email").optional(),
overwriteExisting: z.boolean().optional(),
});
// ──── Antigravity CLI (`agy`) auto-detect local login Schema ────
// No `source`: the route reads the token from the local agy CLI data dir on disk.
export const applyLocalAgyAuthSchema = z.object({
name: z.string().min(1).max(200).optional(),
email: z.string().email("Must be a valid email").optional(),
overwriteExisting: z.boolean().optional(),
});
// ──── Gemini CLI Auth Import Bulk Schema ────
export const importGeminiAuthBulkSchema = z.object({
@@ -445,6 +470,22 @@ export const importGeminiAuthBulkSchema = z.object({
overwriteExisting: z.boolean().optional(),
});
// ──── Antigravity CLI (`agy`) Auth Import Bulk Schema ────
export const importAgyAuthBulkSchema = z.object({
entries: z
.array(
z.object({
json: z.unknown(),
name: z.string().min(1).max(200).optional(),
email: z.string().email("Must be a valid email").optional(),
})
)
.min(1, "At least one entry is required")
.max(50, "At most 50 entries per bulk import"),
overwriteExisting: z.boolean().optional(),
});
// ──── API Key Schemas ────
export const createKeySchema = z.object({

View File

@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
parseAndValidateAgyToken,
AgyAuthFileError,
} from "../../src/lib/oauth/utils/agyAuthImport.ts";
// Fixture token values are deliberately generic (not `ya29.`/`1//` shaped) so secret
// scanners don't flag them — the parser only cares that they are non-empty strings.
const ACCESS = "agy-access-token-fixture";
const REFRESH = "agy-refresh-token-fixture";
test("parses the nested agy token-file shape (token.* with ISO expiry, no id_token)", () => {
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);
});

View File

@@ -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);
});

View File

@@ -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",

View File

@@ -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"],