// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. import * as NodeFSP from "node:fs/promises"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Digest from "effect/crypto/Digest"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import type * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as AcpErrors from "effect-acp/errors"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; import { antigravityUserSkillDirectories, resolveAntigravityUserHome, } from "./Drivers/AntigravitySkills.ts"; export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = "Open the following link to authenticate the ACP server: "; export const ANTIGRAVITY_AUTH_BROWSER_MARKER = "__T3_ANTIGRAVITY_AUTH_URL__"; export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE = "Sign in to Antigravity in Settings before you continue."; const maxAuthorizationUrlLength = 16_384; const maxBrowserHelperLineLength = Math.max(ANTIGRAVITY_AUTH_BROWSER_MARKER.length, ANTIGRAVITY_AUTH_STDOUT_PREFIX.length) + maxAuthorizationUrlLength + 2; const maxStdoutLineBytes = 16 * 1024 * 1024; const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX); const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString); const decodeBrowserHelperUrl = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String)); const ProfileSettingsFile = Schema.Struct({ auth: Schema.Struct({ type: Schema.String }), gcp: Schema.optional( Schema.Struct({ project: Schema.optional(Schema.String), location: Schema.optional(Schema.String), }), ), }); const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSettingsFile)); const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError); // Python splits BROWSER on path separators before parsing quotes. Exit 0 on EPIPE to prevent browser fallback after cancellation. const browserHelperSource = `process.stderr.on("error",()=>process.exit(0)).write(` + `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` + `()=>process.exit(0))`; const browserPreflightUrl = "https://example.invalid/t3-antigravity-browser-preflight"; const removedEnvironmentKeys = new Set([ "GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_QUOTA_PROJECT", "GOOGLE_GENAI_USE_VERTEXAI", "GCLOUD_PROJECT", "CLOUDSDK_CORE_PROJECT", "AGY_ACP_CCPA_PROJECT", "AGY_ACP_ENABLE_OAUTH", "GEMINI_HOME", "AGY_ACP_FORCE_FILE_STORAGE", "ANTIGRAVITY_HARNESS_PATH", "BROWSER", "PYTHONUNBUFFERED", "ELECTRON_RUN_AS_NODE", ]); export interface AntigravityProfile { readonly platform: NodeJS.Platform; readonly geminiHome: string; readonly acpDirectory: string; readonly tokenPath: string; readonly tempDirectory: string; readonly browserCommand: string; readonly caBundlePath?: string; } export interface AntigravityAuthConfig { readonly authMethod: AntigravityAuthMethod; readonly apiKey: string; readonly gcpProject: string; readonly gcpLocation: string; } const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = { authMethod: "oauth-personal", apiKey: "", gcpProject: "", gcpLocation: "", }; export function antigravityAuthUsesBrowser(authMethod: AntigravityAuthMethod): boolean { return authMethod === "oauth-personal" || authMethod === "oauth-business"; } export function antigravityAuthLabel(authMethod: AntigravityAuthMethod): string { switch (authMethod) { case "oauth-personal": return "Google account"; case "oauth-business": return "Gemini Enterprise"; case "gemini-api-key": return "Gemini API key"; case "agent-platform": return "Agent Platform"; } } export function antigravityAuthConfigIssue(auth: AntigravityAuthConfig): string | null { switch (auth.authMethod) { case "oauth-personal": return null; case "oauth-business": return auth.gcpProject && auth.gcpLocation ? null : "Gemini Enterprise needs a GCP project and location in the Antigravity provider settings."; case "gemini-api-key": return auth.apiKey ? null : "Enter a Gemini API key in the Antigravity provider settings."; case "agent-platform": return auth.apiKey || (auth.gcpProject && auth.gcpLocation) ? null : "Agent Platform needs an API key, or a GCP project and location, in the Antigravity provider settings."; } } /** * `auth.type` names the selected method so a native logout clears only that method's credentials * instead of every stored token. Never holds a credential. */ function antigravityProfileSettings(auth: AntigravityAuthConfig): string { const gcp = { ...(auth.gcpProject ? { project: auth.gcpProject } : {}), ...(auth.gcpLocation ? { location: auth.gcpLocation } : {}), }; return `${encodeProfileSettings({ auth: { type: auth.authMethod }, ...(Object.keys(gcp).length > 0 ? { gcp } : {}), })}\n`; } export interface AntigravityAuthorizationUrl { readonly authorizationUrl: string; readonly redirectUri: string; readonly state: string; } function authSupportError(detail: string) { return new AcpErrors.AcpTransportError({ detail, cause: undefined }); } export function isAntigravitySignInRequiredError(error: unknown): boolean { return ( (isAcpRequestError(error) && error.code === -32000) || (isAcpTransportError(error) && error.detail === ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE) ); } export interface AntigravityInstanceDirectories { readonly profile: string; /** Keep beside the profile: 120-character archive members under its longer name would exceed Windows' 260-character limit. */ readonly runtimeTemp: string; } /** Hashes the instance ID so case-only differences stay separate on case-insensitive filesystems. */ export const resolveAntigravityInstanceDirectories = Effect.fn( "resolveAntigravityInstanceDirectories", )(function* (stateDir: string, instanceId: ProviderInstanceId) { const digest = yield* Digest.Digest; const path = yield* Path.Path; const key = Hex.encode(yield* digest.digest(Digest.sha256, new TextEncoder().encode(instanceId))); return { profile: path.join(stateDir, "providers", "antigravity", key), runtimeTemp: path.join(stateDir, "antigravity-tmp", key.slice(0, 12)), } satisfies AntigravityInstanceDirectories; }); function quoteBrowserArgument(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } function antigravityEnvironment( profile: AntigravityProfile, baseEnv: NodeJS.ProcessEnv, auth: AntigravityAuthConfig, runtimeTempDirectory?: string, ) { // Application Default Credentials are the selected credential here, not an ambient override. const usesApplicationDefaultCredentials = auth.authMethod === "agent-platform" && !auth.apiKey; const environment: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(baseEnv)) { // Windows treats environment keys as case-insensitive. Remove aliases too. const name = key.toUpperCase(); if ( !removedEnvironmentKeys.has(name) || (usesApplicationDefaultCredentials && name === "GOOGLE_APPLICATION_CREDENTIALS") ) { environment[key] = value; } } // Put the GCP pair in settings.json because GOOGLE_API_KEY takes precedence in the environment. const credential = auth.authMethod === "gemini-api-key" && auth.apiKey ? { GEMINI_API_KEY: auth.apiKey } : auth.authMethod === "agent-platform" && auth.apiKey ? { GOOGLE_API_KEY: auth.apiKey } : {}; // Each launch unpacks about 1 GB; use T3-owned temp so the driver can reclaim files after force-kill. const tempDirectory = runtimeTempDirectory ?? profile.tempDirectory; return { ...environment, ...credential, ...(profile.caBundlePath !== undefined && baseEnv.SSL_CERT_FILE === undefined && baseEnv.SSL_CERT_DIR === undefined ? { SSL_CERT_FILE: profile.caBundlePath } : {}), GEMINI_HOME: profile.geminiHome, AGY_ACP_FORCE_FILE_STORAGE: "1", BROWSER: profile.browserCommand, PYTHONUNBUFFERED: "1", ELECTRON_RUN_AS_NODE: "1", ...(profile.platform === "win32" ? { TEMP: tempDirectory, TMP: tempDirectory } : { TMPDIR: tempDirectory }), }; } /** Private GEMINI_HOME isolates credentials/hooks/MCP; link global skills from the real ~/.gemini. */ const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: { readonly profileDirectory: string; readonly userHome: string; readonly platform: NodeJS.Platform; }): Effect.fn.Return { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const links = antigravityUserSkillDirectories(path, input.profileDirectory); const targets = antigravityUserSkillDirectories(path, path.join(input.userHome, ".gemini")); for (const [link, target] of [ [links[0], targets[0]], [links[1], targets[1]], ] as const) { yield* Effect.gen(function* () { // The harness creates these folders itself, and mkdir fails on a dangling link. if (!(yield* fs.exists(target))) { const linked = yield* fs.readLink(link).pipe( Effect.as(true), Effect.orElseSucceed(() => false), ); if (linked) yield* fs.remove(link); return; } const existing = yield* fs.readLink(link).pipe( Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)), Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined), // An empty folder is the harness's placeholder from a launch without the link. // rmdir refuses a non-empty folder, so content added meanwhile is never deleted. Effect.catch((error) => Effect.tryPromise(() => NodeFSP.rmdir(link)).pipe( Effect.as(undefined), Effect.mapError(() => error), ), ), ); if (existing === target) return; if (existing !== undefined) { yield* fs.remove(link); } yield* fs.makeDirectory(path.dirname(link), { recursive: true }); yield* Effect.tryPromise(() => NodeFSP.symlink(target, link, input.platform === "win32" ? "junction" : "dir"), ); }).pipe( Effect.catch((error) => Effect.logWarning("Antigravity user skills are not linked into the profile.", { link, target, error, }), ), ); } }); export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: { readonly profileDirectory: string; readonly baseEnv?: NodeJS.ProcessEnv; readonly runtimeExecutablePath?: string; readonly platform?: NodeJS.Platform; readonly auth?: AntigravityAuthConfig; readonly userHome?: string; readonly tempDirectory?: string; }) { const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const platform = input.platform ?? (yield* HostProcess.Platform); const userHome = input.userHome ?? resolveAntigravityUserHome( platform, input.baseEnv ?? process.env, yield* HostProcess.HomeDirectory, ); const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* resolveNodeExecutable("Antigravity sign-in", input.baseEnv).pipe( Effect.mapError( (cause) => new AcpErrors.AcpTransportError({ detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), cause, }), ), )); const helperExecutable = platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"]; const browserCommand = browserArguments.map(quoteBrowserArgument).join(" "); if ( browserCommand.includes(platform === "win32" ? ";" : ":") || helperExecutable.includes("\r") || helperExecutable.includes("\n") || helperExecutable.includes("\0") || helperExecutable.includes("%s") ) { return yield* authSupportError( "The T3 runtime path cannot be used to suppress Antigravity browser launches.", ); } const geminiHome = path.resolve(input.profileDirectory); const acpDirectory = path.join(geminiHome, "antigravity-acp"); const tempDirectory = input.tempDirectory ?? path.join(acpDirectory, "tmp"); let caBundlePath: string | undefined; if (platform === "linux") { // Bundled Python may look for certificates at paths absent on this distribution. // Relative paths would resolve differently when the agent runs in the project directory. for (const candidate of [ (input.baseEnv ?? process.env).NIX_SSL_CERT_FILE, "/etc/ssl/certs/ca-bundle.crt", "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem", ]) { if ( candidate && path.isAbsolute(candidate) && (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) ) { caBundlePath = candidate; break; } } } const profile: AntigravityProfile = { platform, geminiHome, acpDirectory, tokenPath: path.join(acpDirectory, "acp_token.json"), tempDirectory, browserCommand, ...(caBundlePath === undefined ? {} : { caBundlePath }), }; const environment = antigravityEnvironment(profile, input.baseEnv ?? process.env, auth); yield* Effect.gen(function* () { const child = yield* spawner.spawn( ChildProcess.make(helperExecutable, ["-e", browserHelperSource, "--", browserPreflightUrl], { env: environment, extendEnv: false, shell: false, }), ); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectUint8StreamText({ stream: child.stdout, maxBytes: 4_096 }), collectUint8StreamText({ stream: child.stderr, maxBytes: 4_096 }), child.exitCode, ], { concurrency: "unbounded" }, ); if ( Number(exitCode) !== 0 || stdout.bytes !== 0 || stdout.truncated || stderr.truncated || stderr.text !== `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"${browserPreflightUrl}"\n` ) { return yield* authSupportError("Antigravity browser suppression could not be verified."); } }).pipe( Effect.scoped, Effect.timeoutOrElse({ duration: "5 seconds", orElse: () => Effect.fail(authSupportError("Antigravity browser suppression verification timed out.")), }), Effect.mapError((error) => error._tag === "AcpTransportError" ? error : authSupportError("Antigravity browser suppression could not be verified."), ), ); for (const directory of [geminiHome, acpDirectory, tempDirectory]) { yield* fs .makeDirectory(directory, { recursive: true, mode: 0o700 }) .pipe( Effect.mapError(() => authSupportError("The Antigravity profile directory could not be created."), ), ); if (platform !== "win32") { yield* fs .chmod(directory, 0o700) .pipe( Effect.mapError(() => authSupportError("The Antigravity profile directory permissions could not be set."), ), ); } } yield* fs .writeFileString(path.join(acpDirectory, "settings.json"), antigravityProfileSettings(auth)) .pipe( Effect.mapError(() => authSupportError("The Antigravity profile settings could not be written."), ), ); yield* linkAntigravityUserSkills({ profileDirectory: geminiHome, userHome, platform }); return profile; }); export function buildAntigravityAcpSpawnInput(input: { readonly installation: { readonly executablePath: string; readonly harnessPath: string; }; readonly profile: AntigravityProfile; readonly cwd: string; readonly baseEnv?: NodeJS.ProcessEnv; readonly auth?: AntigravityAuthConfig; readonly runtimeTempDirectory?: string; }): AcpSessionRuntime.AcpSpawnInput { return { command: input.installation.executablePath, args: input.profile.platform === "linux" ? ["--uid="] : [], cwd: input.cwd, env: { ...antigravityEnvironment( input.profile, input.baseEnv ?? process.env, input.auth ?? ANTIGRAVITY_PERSONAL_AUTH, input.runtimeTempDirectory, ), ANTIGRAVITY_HARNESS_PATH: input.installation.harnessPath, }, extendEnv: false, }; } export const parseAntigravityAuthorizationUrl = Effect.fn("parseAntigravityAuthorizationUrl")( function* ( authorizationUrl: string, ): Effect.fn.Return { const invalidUrl = () => authSupportError("Antigravity returned an invalid Google sign-in URL."); if (authorizationUrl.length > maxAuthorizationUrlLength || /\s/.test(authorizationUrl)) { return yield* invalidUrl(); } const url = yield* decodeUrl(authorizationUrl).pipe(Effect.mapError(invalidUrl)); const state = url.searchParams.get("state"); const redirectUri = url.searchParams.get("redirect_uri"); if ( url.origin !== "https://accounts.google.com" || url.pathname !== "/o/oauth2/v2/auth" || url.username !== "" || url.password !== "" || url.hash !== "" || url.searchParams.getAll("state").length !== 1 || url.searchParams.getAll("redirect_uri").length !== 1 || url.searchParams.getAll("response_type").length !== 1 || url.searchParams.get("response_type") !== "code" || state === null || state.length === 0 || state.length > 512 || /\s/.test(state) || redirectUri === null || !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}\/$/.test(redirectUri) ) { return yield* invalidUrl(); } const redirect = yield* decodeUrl(redirectUri).pipe(Effect.mapError(invalidUrl)); if (Number(redirect.port) < 1_024) return yield* invalidUrl(); return { authorizationUrl, redirectUri, state }; }, ); export function makeAntigravityStdoutTransform( input: { readonly onAuthorizationUrl?: ( authorizationUrl: string, ) => Effect.Effect; } = {}, ) { const handleLine = Effect.fn("antigravityAuthSupport.handleStdoutLine")(function* ( line: Uint8Array, ) { if (!authPrefixBytes.every((byte, index) => line[index] === byte)) return [line]; const message = new TextDecoder().decode(line).replace(/\r?\n$/, ""); const request = yield* parseAntigravityAuthorizationUrl( message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length), ); if (!input.onAuthorizationUrl) { return yield* authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE); } yield* input.onAuthorizationUrl(request.authorizationUrl); return []; }); return ( stdout: ChildProcessSpawner.ChildProcessHandle["stdout"], ): Stream.Stream => Stream.suspend(() => { let pending: Uint8Array[] = []; let pendingBytes = 0; const finishLine = () => { const line = Buffer.concat(pending, pendingBytes); pending = []; pendingBytes = 0; return line; }; return stdout.pipe( Stream.mapEffect( Effect.fn("antigravityAuthSupport.splitStdoutLines")(function* (chunk: Uint8Array) { const lines: Uint8Array[] = []; let offset = 0; while (offset < chunk.byteLength) { const newline = chunk.indexOf(10, offset); const end = newline === -1 ? chunk.byteLength : newline + 1; const part = chunk.subarray(offset, end); if (pendingBytes + part.byteLength > maxStdoutLineBytes) { return yield* authSupportError( "Antigravity sent a protocol line that is too large.", ); } pending.push(part); pendingBytes += part.byteLength; if (newline !== -1) lines.push(finishLine()); offset = end; } return lines; }), ), Stream.flatMap(Stream.fromIterable), Stream.concat( Stream.suspend(() => (pendingBytes > 0 ? Stream.succeed(finishLine()) : Stream.empty)), ), Stream.mapEffect(handleLine), Stream.flatMap(Stream.fromIterable), ); }); } export function makeAntigravityStderrHandler( input: { readonly onAuthorizationUrl?: ( authorizationUrl: string, ) => Effect.Effect; } = {}, ) { let pending = ""; const handleLine = (line: string) => { const message = line.endsWith("\r") ? line.slice(0, -1) : line; if (message.length > maxBrowserHelperLineLength) { return Effect.void; } const url = message.startsWith(ANTIGRAVITY_AUTH_STDOUT_PREFIX) ? Effect.succeed(message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length)) : message.startsWith(ANTIGRAVITY_AUTH_BROWSER_MARKER) ? decodeBrowserHelperUrl(message.slice(ANTIGRAVITY_AUTH_BROWSER_MARKER.length)) : undefined; if (url === undefined) return Effect.void; return url.pipe( Effect.flatMap(parseAntigravityAuthorizationUrl), Effect.matchEffect({ onFailure: () => Effect.void, onSuccess: (request) => input.onAuthorizationUrl ? input.onAuthorizationUrl(request.authorizationUrl) : Effect.fail(authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE)), }), ); }; return Effect.fn("antigravityAuthSupport.handleStderr")(function* (text: string) { const lines = `${pending}${text}`.split("\n"); pending = lines.pop() ?? ""; if (pending.length > maxBrowserHelperLineLength) pending = ""; yield* Effect.forEach(lines, handleLine, { discard: true }); }); }