Merge pull request #144 from cita-777/fix/oauth-onboarding-cliproxyapi
fix: align oauth onboarding with cliproxyapi
This commit is contained in:
@@ -129,6 +129,121 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('discovers the Antigravity project via onboardUser polling when loadCodeAssist does not return one', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'antigravity-access-token',
|
||||||
|
refresh_token: 'antigravity-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
scope: 'cloud-platform',
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ email: 'antigravity-user@example.com' }),
|
||||||
|
text: async () => JSON.stringify({ email: 'antigravity-user@example.com' }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
allowedTiers: [
|
||||||
|
{ id: 'legacy-tier', isDefault: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
done: false,
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ done: false }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
done: true,
|
||||||
|
response: {
|
||||||
|
cloudaicompanionProject: {
|
||||||
|
id: 'antigravity-auto-project',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ done: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
models: {
|
||||||
|
'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const startResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/oauth/providers/antigravity/start',
|
||||||
|
headers: {
|
||||||
|
host: 'metapi.example',
|
||||||
|
'x-forwarded-proto': 'https',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(startResponse.statusCode).toBe(200);
|
||||||
|
const startBody = startResponse.json() as { state: string };
|
||||||
|
|
||||||
|
const callbackResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
|
||||||
|
payload: {
|
||||||
|
callbackUrl: `http://localhost:51121/oauth-callback?state=${encodeURIComponent(startBody.state)}&code=antigravity-oauth-code-123`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(callbackResponse.statusCode).toBe(200);
|
||||||
|
expect(callbackResponse.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
const sessionResponse = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/oauth/sessions/${startBody.state}`,
|
||||||
|
});
|
||||||
|
expect(sessionResponse.statusCode).toBe(200);
|
||||||
|
expect(sessionResponse.json()).toMatchObject({
|
||||||
|
provider: 'antigravity',
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
const accounts = await db.select().from(schema.accounts).all();
|
||||||
|
expect(accounts).toHaveLength(1);
|
||||||
|
expect(accounts[0]).toMatchObject({
|
||||||
|
oauthProvider: 'antigravity',
|
||||||
|
oauthProjectId: 'antigravity-auto-project',
|
||||||
|
username: 'antigravity-user@example.com',
|
||||||
|
accessToken: 'antigravity-access-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(accounts[0]?.extraConfig || '{}');
|
||||||
|
expect(parsed.oauth).toMatchObject({
|
||||||
|
provider: 'antigravity',
|
||||||
|
email: 'antigravity-user@example.com',
|
||||||
|
projectId: 'antigravity-auto-project',
|
||||||
|
refreshToken: 'antigravity-refresh-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist');
|
||||||
|
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser');
|
||||||
|
expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/v1internal:onboardUser');
|
||||||
|
});
|
||||||
|
|
||||||
it('starts a codex oauth session and exposes pending status', async () => {
|
it('starts a codex oauth session and exposes pending status', async () => {
|
||||||
const startResponse = await app.inject({
|
const startResponse = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -461,6 +576,32 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
}),
|
}),
|
||||||
text: async () => JSON.stringify({ ok: true }),
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
})
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
cloudaicompanionProject: {
|
||||||
|
id: 'first-project-id',
|
||||||
|
},
|
||||||
|
allowedTiers: [
|
||||||
|
{ id: 'legacy-tier', isDefault: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
done: true,
|
||||||
|
response: {
|
||||||
|
cloudaicompanionProject: {
|
||||||
|
id: 'first-project-id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ done: true }),
|
||||||
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -529,8 +670,123 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('cloudresourcemanager.googleapis.com/v1/projects');
|
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('cloudresourcemanager.googleapis.com/v1/projects');
|
||||||
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com');
|
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist');
|
||||||
|
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser');
|
||||||
expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com');
|
expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com');
|
||||||
|
expect(String(fetchMock.mock.calls[6]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onboards Gemini CLI into the backend project and auto-enables Cloud AI API when Google returns a free-tier project remap', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'gemini-access-token',
|
||||||
|
refresh_token: 'gemini-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
scope: 'cloud-platform',
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
projects: [
|
||||||
|
{ projectId: 'gen-lang-client-source-project' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
allowedTiers: [
|
||||||
|
{ id: 'legacy-tier', isDefault: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
done: true,
|
||||||
|
response: {
|
||||||
|
cloudaicompanionProject: {
|
||||||
|
id: 'gen-lang-client-0123456789',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ done: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ state: 'DISABLED' }),
|
||||||
|
text: async () => JSON.stringify({ state: 'DISABLED' }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ ok: true }),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ email: 'gemini-free-user@example.com' }),
|
||||||
|
text: async () => JSON.stringify({ email: 'gemini-free-user@example.com' }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ state: 'ENABLED' }),
|
||||||
|
text: async () => JSON.stringify({ state: 'ENABLED' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const startResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/oauth/providers/gemini-cli/start',
|
||||||
|
headers: {
|
||||||
|
host: 'metapi.example',
|
||||||
|
'x-forwarded-proto': 'https',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(startResponse.statusCode).toBe(200);
|
||||||
|
const startBody = startResponse.json() as { state: string };
|
||||||
|
|
||||||
|
const callbackResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
|
||||||
|
payload: {
|
||||||
|
callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-free-tier`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(callbackResponse.statusCode).toBe(200);
|
||||||
|
expect(callbackResponse.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
const accounts = await db.select().from(schema.accounts).all();
|
||||||
|
expect(accounts).toHaveLength(1);
|
||||||
|
expect(accounts[0]).toMatchObject({
|
||||||
|
oauthProvider: 'gemini-cli',
|
||||||
|
oauthProjectId: 'gen-lang-client-0123456789',
|
||||||
|
username: 'gemini-free-user@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(accounts[0]?.extraConfig || '{}');
|
||||||
|
expect(parsed.oauth).toMatchObject({
|
||||||
|
projectId: 'gen-lang-client-0123456789',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist');
|
||||||
|
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser');
|
||||||
|
expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com');
|
||||||
|
expect(String(fetchMock.mock.calls[5]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com:enable');
|
||||||
|
expect(String(fetchMock.mock.calls[7]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists oauth connection health metadata and supports deleting the connection', async () => {
|
it('lists oauth connection health metadata and supports deleting the connection', async () => {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudc
|
|||||||
export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1';
|
export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1';
|
||||||
export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1';
|
export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1';
|
||||||
export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}';
|
export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}';
|
||||||
|
export const ANTIGRAVITY_INTERNAL_API_VERSION = 'v1internal';
|
||||||
|
export const ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS = 2_000;
|
||||||
|
export const ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
const ANTIGRAVITY_SCOPES = [
|
const ANTIGRAVITY_SCOPES = [
|
||||||
'https://www.googleapis.com/auth/cloud-platform',
|
'https://www.googleapis.com/auth/cloud-platform',
|
||||||
@@ -34,6 +37,16 @@ type AntigravityOAuthTokenPayload = {
|
|||||||
expiry?: unknown;
|
expiry?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AntigravityLoadCodeAssistPayload = {
|
||||||
|
cloudaicompanionProject?: unknown;
|
||||||
|
allowedTiers?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AntigravityOnboardUserPayload = {
|
||||||
|
done?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string | undefined {
|
function asTrimmedString(value: unknown): string | undefined {
|
||||||
if (typeof value !== 'string') return undefined;
|
if (typeof value !== 'string') return undefined;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -57,6 +70,65 @@ function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefin
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAntigravityMetadata() {
|
||||||
|
return {
|
||||||
|
ideType: 'ANTIGRAVITY',
|
||||||
|
platform: 'PLATFORM_UNSPECIFIED',
|
||||||
|
pluginType: 'GEMINI',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAntigravityProjectId(value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return asTrimmedString(value);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return asTrimmedString((value as { id?: unknown }).id);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAntigravityDefaultTierId(payload: AntigravityLoadCodeAssistPayload): string {
|
||||||
|
const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : [];
|
||||||
|
for (const rawTier of allowedTiers) {
|
||||||
|
if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue;
|
||||||
|
const tier = rawTier as { id?: unknown; isDefault?: unknown };
|
||||||
|
if (tier.isDefault === true) {
|
||||||
|
const tierId = asTrimmedString(tier.id);
|
||||||
|
if (tierId) return tierId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'legacy-tier';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms: number): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callAntigravityInternalApi<T>(
|
||||||
|
accessToken: string,
|
||||||
|
method: 'loadCodeAssist' | 'onboardUser',
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': ANTIGRAVITY_USER_AGENT,
|
||||||
|
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
|
||||||
|
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) return undefined;
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
async function postAntigravityToken(body: URLSearchParams) {
|
async function postAntigravityToken(body: URLSearchParams) {
|
||||||
const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
|
const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -99,34 +171,45 @@ async function fetchAntigravityUserEmail(accessToken: string): Promise<string |
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> {
|
async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> {
|
||||||
const response = await fetch(`${ANTIGRAVITY_UPSTREAM_BASE_URL}/v1internal:loadCodeAssist`, {
|
const metadata = buildAntigravityMetadata();
|
||||||
method: 'POST',
|
const payload = await callAntigravityInternalApi<AntigravityLoadCodeAssistPayload>(
|
||||||
headers: {
|
accessToken,
|
||||||
Authorization: `Bearer ${accessToken}`,
|
'loadCodeAssist',
|
||||||
Accept: 'application/json',
|
{ metadata },
|
||||||
'Content-Type': 'application/json',
|
);
|
||||||
'User-Agent': ANTIGRAVITY_USER_AGENT,
|
if (!payload) return undefined;
|
||||||
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
|
|
||||||
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
|
const discoveredFromLoad = extractAntigravityProjectId(payload.cloudaicompanionProject);
|
||||||
},
|
if (discoveredFromLoad) {
|
||||||
body: JSON.stringify({
|
return discoveredFromLoad;
|
||||||
metadata: {
|
|
||||||
ideType: 'ANTIGRAVITY',
|
|
||||||
platform: 'PLATFORM_UNSPECIFIED',
|
|
||||||
pluginType: 'GEMINI',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) return undefined;
|
|
||||||
const payload = await response.json() as {
|
|
||||||
cloudaicompanionProject?: unknown;
|
|
||||||
};
|
|
||||||
if (typeof payload.cloudaicompanionProject === 'string') {
|
|
||||||
return asTrimmedString(payload.cloudaicompanionProject);
|
|
||||||
}
|
}
|
||||||
if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject === 'object') {
|
|
||||||
return asTrimmedString((payload.cloudaicompanionProject as { id?: unknown }).id);
|
const tierId = extractAntigravityDefaultTierId(payload);
|
||||||
|
for (let attempt = 0; attempt < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
const onboardPayload = await callAntigravityInternalApi<AntigravityOnboardUserPayload>(
|
||||||
|
accessToken,
|
||||||
|
'onboardUser',
|
||||||
|
{
|
||||||
|
tierId,
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!onboardPayload) return undefined;
|
||||||
|
if (onboardPayload.done === true) {
|
||||||
|
const response = (
|
||||||
|
onboardPayload.response
|
||||||
|
&& typeof onboardPayload.response === 'object'
|
||||||
|
&& !Array.isArray(onboardPayload.response)
|
||||||
|
)
|
||||||
|
? onboardPayload.response as { cloudaicompanionProject?: unknown }
|
||||||
|
: undefined;
|
||||||
|
return extractAntigravityProjectId(response?.cloudaicompanionProject);
|
||||||
}
|
}
|
||||||
|
if ((attempt + 1) < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS) {
|
||||||
|
await sleep(ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export const GEMINI_CLI_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com
|
|||||||
export const GEMINI_CLI_GOOGLE_API_CLIENT = 'google-genai-sdk/1.41.0 gl-node/v22.19.0';
|
export const GEMINI_CLI_GOOGLE_API_CLIENT = 'google-genai-sdk/1.41.0 gl-node/v22.19.0';
|
||||||
export const GEMINI_CLI_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)';
|
export const GEMINI_CLI_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)';
|
||||||
export const GEMINI_CLI_REQUIRED_SERVICE = 'cloudaicompanion.googleapis.com';
|
export const GEMINI_CLI_REQUIRED_SERVICE = 'cloudaicompanion.googleapis.com';
|
||||||
|
export const GEMINI_CLI_INTERNAL_API_VERSION = 'v1internal';
|
||||||
|
export const GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS = 2_000;
|
||||||
|
export const GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS = 15;
|
||||||
|
export const GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS = 5_000;
|
||||||
|
export const GEMINI_CLI_ONBOARD_MAX_ATTEMPTS = 6;
|
||||||
|
|
||||||
function requireGeminiCliOAuthConfig() {
|
function requireGeminiCliOAuthConfig() {
|
||||||
if (!GEMINI_CLI_CLIENT_ID) {
|
if (!GEMINI_CLI_CLIENT_ID) {
|
||||||
@@ -46,6 +51,16 @@ type GeminiOAuthTokenPayload = {
|
|||||||
expiry?: unknown;
|
expiry?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GeminiLoadCodeAssistPayload = {
|
||||||
|
cloudaicompanionProject?: unknown;
|
||||||
|
allowedTiers?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GeminiOnboardUserPayload = {
|
||||||
|
done?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string | undefined {
|
function asTrimmedString(value: unknown): string | undefined {
|
||||||
if (typeof value !== 'string') return undefined;
|
if (typeof value !== 'string') return undefined;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -69,6 +84,69 @@ function parseExpiresAt(payload: GeminiOAuthTokenPayload): number | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildGeminiCliMetadata() {
|
||||||
|
return {
|
||||||
|
ideType: 'IDE_UNSPECIFIED',
|
||||||
|
platform: 'PLATFORM_UNSPECIFIED',
|
||||||
|
pluginType: 'GEMINI',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGeminiProjectId(value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return asTrimmedString(value);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return asTrimmedString((value as { id?: unknown }).id);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGeminiDefaultTierId(payload: GeminiLoadCodeAssistPayload): string {
|
||||||
|
const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : [];
|
||||||
|
for (const rawTier of allowedTiers) {
|
||||||
|
if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue;
|
||||||
|
const tier = rawTier as { id?: unknown; isDefault?: unknown };
|
||||||
|
if (tier.isDefault === true) {
|
||||||
|
const tierId = asTrimmedString(tier.id);
|
||||||
|
if (tierId) return tierId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'legacy-tier';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGeminiFreeUserProject(input: {
|
||||||
|
requestedProjectId?: string;
|
||||||
|
tierId: string;
|
||||||
|
}) {
|
||||||
|
const projectId = (input.requestedProjectId || '').trim();
|
||||||
|
const tierId = input.tierId.trim();
|
||||||
|
return projectId.startsWith('gen-lang-client-')
|
||||||
|
|| tierId.toUpperCase() === 'FREE'
|
||||||
|
|| tierId.toUpperCase() === 'LEGACY';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameGeminiProjectId(a: string, b: string): boolean {
|
||||||
|
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGeminiServiceErrorMessage(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as {
|
||||||
|
error?: { message?: unknown };
|
||||||
|
};
|
||||||
|
return asTrimmedString(parsed.error?.message) || trimmed;
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms: number): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
async function postGeminiToken(body: URLSearchParams) {
|
async function postGeminiToken(body: URLSearchParams) {
|
||||||
const response = await fetch(GEMINI_CLI_TOKEN_URL, {
|
const response = await fetch(GEMINI_CLI_TOKEN_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -110,6 +188,32 @@ async function fetchGeminiUserEmail(accessToken: string): Promise<string | undef
|
|||||||
return asTrimmedString(payload.email);
|
return asTrimmedString(payload.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function callGeminiCliInternalApi<T>(
|
||||||
|
accessToken: string,
|
||||||
|
method: 'loadCodeAssist' | 'onboardUser',
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': GEMINI_CLI_USER_AGENT,
|
||||||
|
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(text || `api request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchGcpProjects(accessToken: string): Promise<string[]> {
|
async function fetchGcpProjects(accessToken: string): Promise<string[]> {
|
||||||
const response = await fetch(GEMINI_CLI_PROJECTS_URL, {
|
const response = await fetch(GEMINI_CLI_PROJECTS_URL, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -127,45 +231,159 @@ async function fetchGcpProjects(accessToken: string): Promise<string[]> {
|
|||||||
.filter((projectId): projectId is string => !!projectId);
|
.filter((projectId): projectId is string => !!projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureGeminiProjectEnabled(accessToken: string, projectId: string): Promise<void> {
|
async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): Promise<void> {
|
||||||
const response = await fetch(
|
const checkResponse = await fetch(
|
||||||
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
|
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
'User-Agent': GEMINI_CLI_USER_AGENT,
|
||||||
|
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (checkResponse.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const payload = await checkResponse.json() as { state?: unknown };
|
||||||
throw new Error(text || `service usage lookup failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
const payload = await response.json() as { state?: unknown };
|
|
||||||
const state = asTrimmedString(payload.state);
|
const state = asTrimmedString(payload.state);
|
||||||
if ((state || '').toUpperCase() !== 'ENABLED') {
|
if ((state || '').toUpperCase() === 'ENABLED') {
|
||||||
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveGeminiProjectId(accessToken: string, requestedProjectId?: string): Promise<string> {
|
const response = await fetch(
|
||||||
|
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': GEMINI_CLI_USER_AGENT,
|
||||||
|
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
||||||
|
},
|
||||||
|
body: '{}',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.status === 400 && extractGeminiServiceErrorMessage(text).toLowerCase().includes('already enabled')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`project activation required: ${extractGeminiServiceErrorMessage(text) || `HTTP ${response.status}`}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performGeminiCliSetup(
|
||||||
|
accessToken: string,
|
||||||
|
requestedProjectId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const trimmedRequest = requestedProjectId.trim();
|
||||||
|
const explicitProject = !!trimmedRequest;
|
||||||
|
const metadata = buildGeminiCliMetadata();
|
||||||
|
const loadResponse = await callGeminiCliInternalApi<GeminiLoadCodeAssistPayload>(
|
||||||
|
accessToken,
|
||||||
|
'loadCodeAssist',
|
||||||
|
explicitProject
|
||||||
|
? {
|
||||||
|
metadata,
|
||||||
|
cloudaicompanionProject: trimmedRequest,
|
||||||
|
}
|
||||||
|
: { metadata },
|
||||||
|
);
|
||||||
|
|
||||||
|
const tierId = extractGeminiDefaultTierId(loadResponse);
|
||||||
|
let projectId = trimmedRequest || extractGeminiProjectId(loadResponse.cloudaicompanionProject) || '';
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
for (let attempt = 0; attempt < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
const onboardResponse = await callGeminiCliInternalApi<GeminiOnboardUserPayload>(
|
||||||
|
accessToken,
|
||||||
|
'onboardUser',
|
||||||
|
{
|
||||||
|
tierId,
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (onboardResponse.done === true) {
|
||||||
|
const response = (
|
||||||
|
onboardResponse.response
|
||||||
|
&& typeof onboardResponse.response === 'object'
|
||||||
|
&& !Array.isArray(onboardResponse.response)
|
||||||
|
)
|
||||||
|
? onboardResponse.response as { cloudaicompanionProject?: unknown }
|
||||||
|
: undefined;
|
||||||
|
projectId = extractGeminiProjectId(response?.cloudaicompanionProject) || '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ((attempt + 1) < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS) {
|
||||||
|
await sleep(GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
throw new Error('gemini cli: project selection required');
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalProjectId = projectId;
|
||||||
|
for (let attempt = 0; attempt < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
const onboardResponse = await callGeminiCliInternalApi<GeminiOnboardUserPayload>(
|
||||||
|
accessToken,
|
||||||
|
'onboardUser',
|
||||||
|
{
|
||||||
|
tierId,
|
||||||
|
metadata,
|
||||||
|
cloudaicompanionProject: projectId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (onboardResponse.done === true) {
|
||||||
|
const response = (
|
||||||
|
onboardResponse.response
|
||||||
|
&& typeof onboardResponse.response === 'object'
|
||||||
|
&& !Array.isArray(onboardResponse.response)
|
||||||
|
)
|
||||||
|
? onboardResponse.response as { cloudaicompanionProject?: unknown }
|
||||||
|
: undefined;
|
||||||
|
const responseProjectId = extractGeminiProjectId(response?.cloudaicompanionProject) || '';
|
||||||
|
if (responseProjectId) {
|
||||||
|
if (explicitProject && !isSameGeminiProjectId(responseProjectId, projectId)) {
|
||||||
|
finalProjectId = isGeminiFreeUserProject({ requestedProjectId: projectId, tierId })
|
||||||
|
? responseProjectId
|
||||||
|
: projectId;
|
||||||
|
} else {
|
||||||
|
finalProjectId = responseProjectId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finalProjectId || projectId;
|
||||||
|
}
|
||||||
|
if ((attempt + 1) < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS) {
|
||||||
|
await sleep(GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalProjectId) {
|
||||||
|
return finalProjectId;
|
||||||
|
}
|
||||||
|
throw new Error('gemini cli: onboarding timed out');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGeminiProjectAndOnboard(
|
||||||
|
accessToken: string,
|
||||||
|
requestedProjectId?: string,
|
||||||
|
): Promise<string> {
|
||||||
const explicitProject = asTrimmedString(requestedProjectId);
|
const explicitProject = asTrimmedString(requestedProjectId);
|
||||||
if (explicitProject) {
|
if (explicitProject) {
|
||||||
await ensureGeminiProjectEnabled(accessToken, explicitProject);
|
return performGeminiCliSetup(accessToken, explicitProject);
|
||||||
return explicitProject;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await fetchGcpProjects(accessToken);
|
const projects = await fetchGcpProjects(accessToken);
|
||||||
if (projects.length <= 0) {
|
|
||||||
throw new Error('no Google Cloud projects available for this account');
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstProject = projects[0];
|
const firstProject = projects[0];
|
||||||
if (!firstProject) {
|
if (!firstProject) {
|
||||||
throw new Error('no Google Cloud projects available for this account');
|
throw new Error('no Google Cloud projects available for this account');
|
||||||
}
|
}
|
||||||
await ensureGeminiProjectEnabled(accessToken, firstProject);
|
return performGeminiCliSetup(accessToken, firstProject);
|
||||||
return firstProject;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
||||||
@@ -213,7 +431,8 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
|||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
}));
|
}));
|
||||||
const resolvedProjectId = await resolveGeminiProjectId(token.accessToken, projectId);
|
const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId);
|
||||||
|
await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId);
|
||||||
const email = await fetchGeminiUserEmail(token.accessToken);
|
const email = await fetchGeminiUserEmail(token.accessToken);
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
@@ -231,16 +450,17 @@ export const geminiCliOauthProvider: OAuthProviderDefinition = {
|
|||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
}));
|
}));
|
||||||
if (oauth?.projectId) {
|
const nextProjectId = oauth?.projectId
|
||||||
await ensureGeminiProjectEnabled(token.accessToken, oauth.projectId);
|
? oauth.projectId
|
||||||
}
|
: await ensureGeminiProjectAndOnboard(token.accessToken);
|
||||||
|
await checkCloudAIAPIEnabled(token.accessToken, nextProjectId);
|
||||||
const email = await fetchGeminiUserEmail(token.accessToken);
|
const email = await fetchGeminiUserEmail(token.accessToken);
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
email,
|
email,
|
||||||
accountKey: email,
|
accountKey: email,
|
||||||
accountId: email,
|
accountId: email,
|
||||||
projectId: oauth?.projectId,
|
projectId: nextProjectId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
buildProxyHeaders: () => ({
|
buildProxyHeaders: () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user