Files
opencode-antigravity-auth/src/plugin/project.ts
tctinh ab86d6d8d1 fix: prevent tool_use without tool_result errors
Add defense-in-depth protection against Claude API tool pairing errors:

- findOrphanedToolUseIds(): Detect orphaned tool_use blocks
- fixClaudeToolPairing(): Inject placeholder tool_result responses
- validateAndFixClaudeToolPairing(): Nuclear fallback removes broken blocks
- 12 new unit tests covering all edge cases

Fixes permanent session corruption when ESC pressed or context compacted.
2025-12-26 11:44:06 +07:00

322 lines
8.8 KiB
TypeScript

import {
ANTIGRAVITY_HEADERS,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_LOAD_ENDPOINTS,
ANTIGRAVITY_DEFAULT_PROJECT_ID,
} from "../constants";
import { formatRefreshParts, parseRefreshParts } from "./auth";
import { createLogger } from "./logger";
import type { OAuthAuthDetails, ProjectContextResult } from "./types";
const log = createLogger("project");
const projectContextResultCache = new Map<string, ProjectContextResult>();
const projectContextPendingCache = new Map<string, Promise<ProjectContextResult>>();
const CODE_ASSIST_METADATA = {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
} as const;
interface AntigravityUserTier {
id?: string;
isDefault?: boolean;
userDefinedCloudaicompanionProject?: boolean;
}
interface LoadCodeAssistPayload {
cloudaicompanionProject?: string | { id?: string };
currentTier?: {
id?: string;
};
allowedTiers?: AntigravityUserTier[];
}
interface OnboardUserPayload {
done?: boolean;
response?: {
cloudaicompanionProject?: {
id?: string;
};
};
}
class ProjectIdRequiredError extends Error {
/**
* Error raised when a required Google Cloud project is missing during Antigravity onboarding.
*/
constructor() {
super(
"Google Antigravity requires a Google Cloud project. Enable the Antigravity API on a project you control, rerun `opencode auth login`, and supply that project ID when prompted.",
);
}
}
/**
* Builds metadata headers required by the Code Assist API.
*/
function buildMetadata(projectId?: string): Record<string, string> {
const metadata: Record<string, string> = {
ideType: CODE_ASSIST_METADATA.ideType,
platform: CODE_ASSIST_METADATA.platform,
pluginType: CODE_ASSIST_METADATA.pluginType,
};
if (projectId) {
metadata.duetProject = projectId;
}
return metadata;
}
/**
* Selects the default tier ID from the allowed tiers list.
*/
function getDefaultTierId(allowedTiers?: AntigravityUserTier[]): string | undefined {
if (!allowedTiers || allowedTiers.length === 0) {
return undefined;
}
for (const tier of allowedTiers) {
if (tier?.isDefault) {
return tier.id;
}
}
return allowedTiers[0]?.id;
}
/**
* Promise-based delay utility.
*/
function wait(ms: number): Promise<void> {
return new Promise(function (resolve) {
setTimeout(resolve, ms);
});
}
/**
* Extracts the cloudaicompanion project id from loadCodeAssist responses.
*/
function extractManagedProjectId(payload: LoadCodeAssistPayload | null): string | undefined {
if (!payload) {
return undefined;
}
if (typeof payload.cloudaicompanionProject === "string") {
return payload.cloudaicompanionProject;
}
if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject.id === "string") {
return payload.cloudaicompanionProject.id;
}
return undefined;
}
/**
* Generates a cache key for project context based on refresh token.
*/
function getCacheKey(auth: OAuthAuthDetails): string | undefined {
const refresh = auth.refresh?.trim();
return refresh ? refresh : undefined;
}
/**
* Clears cached project context results and pending promises, globally or for a refresh key.
*/
export function invalidateProjectContextCache(refresh?: string): void {
if (!refresh) {
projectContextPendingCache.clear();
projectContextResultCache.clear();
return;
}
projectContextPendingCache.delete(refresh);
projectContextResultCache.delete(refresh);
}
/**
* Loads managed project information for the given access token and optional project.
*/
export async function loadManagedProject(
accessToken: string,
projectId?: string,
): Promise<LoadCodeAssistPayload | null> {
const metadata = buildMetadata(projectId);
const requestBody: Record<string, unknown> = { metadata };
const loadHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
};
const loadEndpoints = Array.from(
new Set<string>([...ANTIGRAVITY_LOAD_ENDPOINTS, ...ANTIGRAVITY_ENDPOINT_FALLBACKS]),
);
for (const baseEndpoint of loadEndpoints) {
try {
const response = await fetch(
`${baseEndpoint}/v1internal:loadCodeAssist`,
{
method: "POST",
headers: loadHeaders,
body: JSON.stringify(requestBody),
},
);
if (!response.ok) {
continue;
}
return (await response.json()) as LoadCodeAssistPayload;
} catch (error) {
log.debug("Failed to load managed project", { endpoint: baseEndpoint, error: String(error) });
continue;
}
}
return null;
}
/**
* Onboards a managed project for the user, optionally retrying until completion.
*/
export async function onboardManagedProject(
accessToken: string,
tierId: string,
projectId?: string,
attempts = 10,
delayMs = 5000,
): Promise<string | undefined> {
const metadata = buildMetadata(projectId);
const requestBody: Record<string, unknown> = {
tierId,
metadata,
};
if (tierId !== "FREE") {
if (!projectId) {
throw new ProjectIdRequiredError();
}
requestBody.cloudaicompanionProject = projectId;
}
for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
const response = await fetch(
`${baseEndpoint}/v1internal:onboardUser`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
...ANTIGRAVITY_HEADERS,
},
body: JSON.stringify(requestBody),
},
);
if (!response.ok) {
break;
}
const payload = (await response.json()) as OnboardUserPayload;
const managedProjectId = payload.response?.cloudaicompanionProject?.id;
if (payload.done && managedProjectId) {
return managedProjectId;
}
if (payload.done && projectId) {
return projectId;
}
} catch (error) {
log.debug("Failed to onboard managed project", { endpoint: baseEndpoint, error: String(error) });
break;
}
await wait(delayMs);
}
}
return undefined;
}
/**
* Resolves an effective project ID for the current auth state, caching results per refresh token.
*/
export async function ensureProjectContext(auth: OAuthAuthDetails): Promise<ProjectContextResult> {
const accessToken = auth.access;
if (!accessToken) {
return { auth, effectiveProjectId: "" };
}
const cacheKey = getCacheKey(auth);
if (cacheKey) {
const cached = projectContextResultCache.get(cacheKey);
if (cached) {
return cached;
}
const pending = projectContextPendingCache.get(cacheKey);
if (pending) {
return pending;
}
}
const resolveContext = async (): Promise<ProjectContextResult> => {
const parts = parseRefreshParts(auth.refresh);
if (parts.managedProjectId) {
return { auth, effectiveProjectId: parts.managedProjectId };
}
const fallbackProjectId = ANTIGRAVITY_DEFAULT_PROJECT_ID;
const persistManagedProject = async (managedProjectId: string): Promise<ProjectContextResult> => {
const updatedAuth: OAuthAuthDetails = {
...auth,
refresh: formatRefreshParts({
refreshToken: parts.refreshToken,
projectId: parts.projectId,
managedProjectId,
}),
};
return { auth: updatedAuth, effectiveProjectId: managedProjectId };
};
// Try to resolve a managed project from Antigravity if possible.
const loadPayload = await loadManagedProject(accessToken, parts.projectId ?? fallbackProjectId);
const resolvedManagedProjectId = extractManagedProjectId(loadPayload);
if (resolvedManagedProjectId) {
return persistManagedProject(resolvedManagedProjectId);
}
if (parts.projectId) {
return { auth, effectiveProjectId: parts.projectId };
}
// No project id present in auth; fall back to the hardcoded id for requests.
return { auth, effectiveProjectId: fallbackProjectId };
};
if (!cacheKey) {
return resolveContext();
}
const promise = resolveContext()
.then((result) => {
const nextKey = getCacheKey(result.auth) ?? cacheKey;
projectContextPendingCache.delete(cacheKey);
projectContextResultCache.set(nextKey, result);
if (nextKey !== cacheKey) {
projectContextResultCache.delete(cacheKey);
}
return result;
})
.catch((error) => {
projectContextPendingCache.delete(cacheKey);
throw error;
});
projectContextPendingCache.set(cacheKey, promise);
return promise;
}