| 1 | // @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. |
| 2 | import * as NodeFSP from "node:fs/promises"; |
| 3 | |
| 4 | import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; |
| 5 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 6 | import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; |
| 7 | import * as Crypto from "effect/Crypto"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Hex from "effect/encoding/Hex"; |
| 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Path from "effect/Path"; |
| 12 | import type * as PlatformError from "effect/PlatformError"; |
| 13 | import * as Schema from "effect/Schema"; |
| 14 | import * as Stream from "effect/Stream"; |
| 15 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 16 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 17 | import * as AcpErrors from "effect-acp/errors"; |
| 18 | |
| 19 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; |
| 20 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 21 | import { |
| 22 | antigravityUserSkillDirectories, |
| 23 | resolveAntigravityUserHome, |
| 24 | } from "./Drivers/AntigravitySkills.ts"; |
| 25 | |
| 26 | export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = |
| 27 | "Open the following link to authenticate the ACP server: "; |
| 28 | export const ANTIGRAVITY_AUTH_BROWSER_MARKER = "__T3_ANTIGRAVITY_AUTH_URL__"; |
| 29 | export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE = |
| 30 | "Sign in to Antigravity in Settings before you continue."; |
| 31 | |
| 32 | const maxAuthorizationUrlLength = 16_384; |
| 33 | const maxBrowserHelperLineLength = |
| 34 | Math.max(ANTIGRAVITY_AUTH_BROWSER_MARKER.length, ANTIGRAVITY_AUTH_STDOUT_PREFIX.length) + |
| 35 | maxAuthorizationUrlLength + |
| 36 | 2; |
| 37 | const maxStdoutLineBytes = 16 * 1024 * 1024; |
| 38 | const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX); |
| 39 | const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString); |
| 40 | const decodeBrowserHelperUrl = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String)); |
| 41 | const ProfileSettingsFile = Schema.Struct({ |
| 42 | auth: Schema.Struct({ type: Schema.String }), |
| 43 | gcp: Schema.optional( |
| 44 | Schema.Struct({ |
| 45 | project: Schema.optional(Schema.String), |
| 46 | location: Schema.optional(Schema.String), |
| 47 | }), |
| 48 | ), |
| 49 | }); |
| 50 | const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSettingsFile)); |
| 51 | const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); |
| 52 | const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError); |
| 53 | |
| 54 | // Python splits BROWSER on the platform path separator before it parses quotes. |
| 55 | // Keep this source free of both colons and semicolons. EPIPE must still exit 0 |
| 56 | // so Python does not fall back to an OS browser after cancellation. |
| 57 | const browserHelperSource = |
| 58 | `process.stderr.on("error",()=>process.exit(0)).write(` + |
| 59 | `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` + |
| 60 | `()=>process.exit(0))`; |
| 61 | const browserPreflightUrl = "https://example.invalid/t3-antigravity-browser-preflight"; |
| 62 | |
| 63 | const removedEnvironmentKeys = new Set([ |
| 64 | "GEMINI_API_KEY", |
| 65 | "GOOGLE_API_KEY", |
| 66 | "GOOGLE_APPLICATION_CREDENTIALS", |
| 67 | "GOOGLE_CLOUD_PROJECT", |
| 68 | "GOOGLE_CLOUD_LOCATION", |
| 69 | "GOOGLE_CLOUD_QUOTA_PROJECT", |
| 70 | "GOOGLE_GENAI_USE_VERTEXAI", |
| 71 | "GCLOUD_PROJECT", |
| 72 | "CLOUDSDK_CORE_PROJECT", |
| 73 | "AGY_ACP_CCPA_PROJECT", |
| 74 | "AGY_ACP_ENABLE_OAUTH", |
| 75 | "GEMINI_HOME", |
| 76 | "AGY_ACP_FORCE_FILE_STORAGE", |
| 77 | "ANTIGRAVITY_HARNESS_PATH", |
| 78 | "BROWSER", |
| 79 | "PYTHONUNBUFFERED", |
| 80 | "ELECTRON_RUN_AS_NODE", |
| 81 | ]); |
| 82 | |
| 83 | export interface AntigravityProfile { |
| 84 | readonly platform: NodeJS.Platform; |
| 85 | readonly geminiHome: string; |
| 86 | readonly acpDirectory: string; |
| 87 | readonly tokenPath: string; |
| 88 | /** Parent of the per-process temp directories PyInstaller unpacks into. */ |
| 89 | readonly tempDirectory: string; |
| 90 | readonly browserCommand: string; |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Credentials for the non-personal ACP auth methods. The agent reads the API |
| 95 | * key from its environment and the GCP project and location from |
| 96 | * `settings.json` in the profile. Empty strings mean "not set". |
| 97 | */ |
| 98 | export interface AntigravityAuthConfig { |
| 99 | readonly authMethod: AntigravityAuthMethod; |
| 100 | readonly apiKey: string; |
| 101 | readonly gcpProject: string; |
| 102 | readonly gcpLocation: string; |
| 103 | } |
| 104 | |
| 105 | export const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = { |
| 106 | authMethod: "oauth-personal", |
| 107 | apiKey: "", |
| 108 | gcpProject: "", |
| 109 | gcpLocation: "", |
| 110 | }; |
| 111 | |
| 112 | /** True for the two methods that open a Google sign-in page. */ |
| 113 | export function antigravityAuthUsesBrowser(authMethod: AntigravityAuthMethod): boolean { |
| 114 | return authMethod === "oauth-personal" || authMethod === "oauth-business"; |
| 115 | } |
| 116 | |
| 117 | /** Label shown on the provider card once the method has authenticated. */ |
| 118 | export function antigravityAuthLabel(authMethod: AntigravityAuthMethod): string { |
| 119 | switch (authMethod) { |
| 120 | case "oauth-personal": |
| 121 | return "Google account"; |
| 122 | case "oauth-business": |
| 123 | return "Gemini Enterprise"; |
| 124 | case "gemini-api-key": |
| 125 | return "Gemini API key"; |
| 126 | case "agent-platform": |
| 127 | return "Agent Platform"; |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Explains what is missing before a non-personal method can authenticate, or |
| 133 | * null when the config is complete. Personal sign-in never needs config. |
| 134 | */ |
| 135 | export function antigravityAuthConfigIssue(auth: AntigravityAuthConfig): string | null { |
| 136 | switch (auth.authMethod) { |
| 137 | case "oauth-personal": |
| 138 | return null; |
| 139 | case "oauth-business": |
| 140 | return auth.gcpProject && auth.gcpLocation |
| 141 | ? null |
| 142 | : "Gemini Enterprise needs a GCP project and location in the Antigravity provider settings."; |
| 143 | case "gemini-api-key": |
| 144 | return auth.apiKey ? null : "Enter a Gemini API key in the Antigravity provider settings."; |
| 145 | case "agent-platform": |
| 146 | return auth.apiKey || (auth.gcpProject && auth.gcpLocation) |
| 147 | ? null |
| 148 | : "Agent Platform needs an API key, or a GCP project and location, in the Antigravity provider settings."; |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * `settings.json` content for the agent's profile. `auth.type` names the |
| 154 | * selected method so a native logout clears only that method's credentials |
| 155 | * instead of every stored token. The GCP block feeds Enterprise and Agent |
| 156 | * Platform. Never holds a credential. |
| 157 | */ |
| 158 | export function antigravityProfileSettings(auth: AntigravityAuthConfig): string { |
| 159 | const gcp = { |
| 160 | ...(auth.gcpProject ? { project: auth.gcpProject } : {}), |
| 161 | ...(auth.gcpLocation ? { location: auth.gcpLocation } : {}), |
| 162 | }; |
| 163 | return `${encodeProfileSettings({ |
| 164 | auth: { type: auth.authMethod }, |
| 165 | ...(Object.keys(gcp).length > 0 ? { gcp } : {}), |
| 166 | })}\n`; |
| 167 | } |
| 168 | |
| 169 | export interface AntigravityAuthorizationUrl { |
| 170 | readonly authorizationUrl: string; |
| 171 | readonly redirectUri: string; |
| 172 | readonly state: string; |
| 173 | } |
| 174 | |
| 175 | function authSupportError(detail: string) { |
| 176 | return new AcpErrors.AcpTransportError({ detail, cause: undefined }); |
| 177 | } |
| 178 | |
| 179 | /** Recognizes native auth failures and interactive login blocked by T3. */ |
| 180 | export function isAntigravitySignInRequiredError(error: unknown): boolean { |
| 181 | return ( |
| 182 | (isAcpRequestError(error) && error.code === -32000) || |
| 183 | (isAcpTransportError(error) && error.detail === ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE) |
| 184 | ); |
| 185 | } |
| 186 | |
| 187 | export interface AntigravityInstanceDirectories { |
| 188 | /** GEMINI_HOME for the agent. Holds the instance's Google sign-in. */ |
| 189 | readonly profile: string; |
| 190 | /** |
| 191 | * Parent of the per-process directories the agent unpacks into. It sits |
| 192 | * beside the profile, not inside it: the agent unpacks members up to 120 |
| 193 | * characters deep, and the profile's longer name would push them past |
| 194 | * Windows' 260-character path limit. |
| 195 | */ |
| 196 | readonly runtimeTemp: string; |
| 197 | } |
| 198 | |
| 199 | /** Hashes the instance ID so case-only differences stay separate on case-insensitive filesystems. */ |
| 200 | export const resolveAntigravityInstanceDirectories = Effect.fn( |
| 201 | "resolveAntigravityInstanceDirectories", |
| 202 | )(function* (stateDir: string, instanceId: ProviderInstanceId) { |
| 203 | const crypto = yield* Crypto.Crypto; |
| 204 | const path = yield* Path.Path; |
| 205 | const key = Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(instanceId))); |
| 206 | return { |
| 207 | profile: path.join(stateDir, "providers", "antigravity", key), |
| 208 | runtimeTemp: path.join(stateDir, "antigravity-tmp", key.slice(0, 12)), |
| 209 | } satisfies AntigravityInstanceDirectories; |
| 210 | }); |
| 211 | |
| 212 | function quoteBrowserArgument(value: string): string { |
| 213 | return `'${value.replaceAll("'", `'"'"'`)}'`; |
| 214 | } |
| 215 | |
| 216 | function antigravityEnvironment( |
| 217 | profile: AntigravityProfile, |
| 218 | baseEnv: NodeJS.ProcessEnv, |
| 219 | auth: AntigravityAuthConfig, |
| 220 | runtimeTempDirectory?: string, |
| 221 | ) { |
| 222 | const environment: NodeJS.ProcessEnv = {}; |
| 223 | for (const [key, value] of Object.entries(baseEnv)) { |
| 224 | // Windows treats environment keys as case-insensitive. Remove aliases too. |
| 225 | if (!removedEnvironmentKeys.has(key.toUpperCase())) environment[key] = value; |
| 226 | } |
| 227 | // Only the configured method's credential reaches the agent. The agent |
| 228 | // prefers GOOGLE_API_KEY over the GCP pair for Agent Platform, so the pair |
| 229 | // goes through settings.json instead of the environment. |
| 230 | const credential = |
| 231 | auth.authMethod === "gemini-api-key" && auth.apiKey |
| 232 | ? { GEMINI_API_KEY: auth.apiKey } |
| 233 | : auth.authMethod === "agent-platform" && auth.apiKey |
| 234 | ? { GOOGLE_API_KEY: auth.apiKey } |
| 235 | : {}; |
| 236 | // The agent is a PyInstaller one-file bundle. It unpacks about 1 GB into |
| 237 | // the system temp directory per launch and a force kill leaves that behind. |
| 238 | // Point it at a T3-owned directory so the driver can reclaim the space. |
| 239 | const tempDirectory = runtimeTempDirectory ?? profile.tempDirectory; |
| 240 | return { |
| 241 | ...environment, |
| 242 | ...credential, |
| 243 | GEMINI_HOME: profile.geminiHome, |
| 244 | AGY_ACP_FORCE_FILE_STORAGE: "1", |
| 245 | BROWSER: profile.browserCommand, |
| 246 | PYTHONUNBUFFERED: "1", |
| 247 | ELECTRON_RUN_AS_NODE: "1", |
| 248 | ...(profile.platform === "win32" |
| 249 | ? { TEMP: tempDirectory, TMP: tempDirectory } |
| 250 | : { TMPDIR: tempDirectory }), |
| 251 | }; |
| 252 | } |
| 253 | |
| 254 | /** |
| 255 | * The agent reads its user-global skills under `GEMINI_HOME`, which T3 points |
| 256 | * at the private profile. Link the two skill directories back to the user's |
| 257 | * real `~/.gemini` so global skills load, while MCP servers, hooks, and |
| 258 | * credentials stay isolated. Best effort: a link that cannot be made only |
| 259 | * costs global skills, never the session. A real directory at the link path |
| 260 | * is the user's own content and is left alone. |
| 261 | */ |
| 262 | const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: { |
| 263 | readonly profileDirectory: string; |
| 264 | readonly userHome: string; |
| 265 | readonly platform: NodeJS.Platform; |
| 266 | }): Effect.fn.Return<void, never, FileSystem.FileSystem | Path.Path> { |
| 267 | const fs = yield* FileSystem.FileSystem; |
| 268 | const path = yield* Path.Path; |
| 269 | const links = antigravityUserSkillDirectories(path, input.profileDirectory); |
| 270 | const targets = antigravityUserSkillDirectories(path, path.join(input.userHome, ".gemini")); |
| 271 | for (const [link, target] of [ |
| 272 | [links[0], targets[0]], |
| 273 | [links[1], targets[1]], |
| 274 | ] as const) { |
| 275 | yield* Effect.gen(function* () { |
| 276 | const existing = yield* fs.readLink(link).pipe( |
| 277 | Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)), |
| 278 | Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined), |
| 279 | ); |
| 280 | if (existing === target) return; |
| 281 | if (existing !== undefined) { |
| 282 | yield* fs.remove(link); |
| 283 | } |
| 284 | yield* fs.makeDirectory(path.dirname(link), { recursive: true }); |
| 285 | yield* Effect.tryPromise(() => |
| 286 | NodeFSP.symlink(target, link, input.platform === "win32" ? "junction" : "dir"), |
| 287 | ); |
| 288 | }).pipe( |
| 289 | // A non-symlink at the link path fails `readLink`; anything else is a |
| 290 | // filesystem refusal. Both leave the profile usable. |
| 291 | Effect.catch((error) => |
| 292 | Effect.logWarning("Antigravity user skills are not linked into the profile.", { |
| 293 | link, |
| 294 | target, |
| 295 | error, |
| 296 | }), |
| 297 | ), |
| 298 | ); |
| 299 | } |
| 300 | }); |
| 301 | |
| 302 | /** Prepares a private profile without reading or copying Google credentials. */ |
| 303 | export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: { |
| 304 | readonly profileDirectory: string; |
| 305 | readonly baseEnv?: NodeJS.ProcessEnv; |
| 306 | readonly runtimeExecutablePath?: string; |
| 307 | readonly platform?: NodeJS.Platform; |
| 308 | readonly auth?: AntigravityAuthConfig; |
| 309 | /** Home the agent expands `~` against. Defaults to the launch environment's. */ |
| 310 | readonly userHome?: string; |
| 311 | /** Parent of per-process temp directories. Defaults to one inside the profile. */ |
| 312 | readonly tempDirectory?: string; |
| 313 | }) { |
| 314 | const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; |
| 315 | const fs = yield* FileSystem.FileSystem; |
| 316 | const path = yield* Path.Path; |
| 317 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 318 | const platform = input.platform ?? (yield* HostProcess.Platform); |
| 319 | const userHome = |
| 320 | input.userHome ?? |
| 321 | resolveAntigravityUserHome( |
| 322 | platform, |
| 323 | input.baseEnv ?? process.env, |
| 324 | yield* HostProcess.HomeDirectory, |
| 325 | ); |
| 326 | const runtimeExecutablePath = |
| 327 | input.runtimeExecutablePath ?? |
| 328 | (yield* resolveNodeExecutable("Antigravity sign-in", input.baseEnv).pipe( |
| 329 | Effect.mapError( |
| 330 | (cause) => |
| 331 | new AcpErrors.AcpTransportError({ |
| 332 | detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"), |
| 333 | cause, |
| 334 | }), |
| 335 | ), |
| 336 | )); |
| 337 | const helperExecutable = |
| 338 | platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; |
| 339 | const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"]; |
| 340 | const browserCommand = browserArguments.map(quoteBrowserArgument).join(" "); |
| 341 | if ( |
| 342 | browserCommand.includes(platform === "win32" ? ";" : ":") || |
| 343 | helperExecutable.includes("\r") || |
| 344 | helperExecutable.includes("\n") || |
| 345 | helperExecutable.includes("\0") || |
| 346 | helperExecutable.includes("%s") |
| 347 | ) { |
| 348 | return yield* authSupportError( |
| 349 | "The T3 runtime path cannot be used to suppress Antigravity browser launches.", |
| 350 | ); |
| 351 | } |
| 352 | |
| 353 | const geminiHome = path.resolve(input.profileDirectory); |
| 354 | const acpDirectory = path.join(geminiHome, "antigravity-acp"); |
| 355 | const tempDirectory = input.tempDirectory ?? path.join(acpDirectory, "tmp"); |
| 356 | const profile: AntigravityProfile = { |
| 357 | platform, |
| 358 | geminiHome, |
| 359 | acpDirectory, |
| 360 | tokenPath: path.join(acpDirectory, "acp_token.json"), |
| 361 | tempDirectory, |
| 362 | browserCommand, |
| 363 | }; |
| 364 | const environment = antigravityEnvironment(profile, input.baseEnv ?? process.env, auth); |
| 365 | yield* Effect.gen(function* () { |
| 366 | const child = yield* spawner.spawn( |
| 367 | ChildProcess.make(helperExecutable, ["-e", browserHelperSource, "--", browserPreflightUrl], { |
| 368 | env: environment, |
| 369 | extendEnv: false, |
| 370 | shell: false, |
| 371 | }), |
| 372 | ); |
| 373 | const [stdout, stderr, exitCode] = yield* Effect.all( |
| 374 | [ |
| 375 | collectUint8StreamText({ stream: child.stdout, maxBytes: 4_096 }), |
| 376 | collectUint8StreamText({ stream: child.stderr, maxBytes: 4_096 }), |
| 377 | child.exitCode, |
| 378 | ], |
| 379 | { concurrency: "unbounded" }, |
| 380 | ); |
| 381 | if ( |
| 382 | Number(exitCode) !== 0 || |
| 383 | stdout.bytes !== 0 || |
| 384 | stdout.truncated || |
| 385 | stderr.truncated || |
| 386 | stderr.text !== `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"${browserPreflightUrl}"\n` |
| 387 | ) { |
| 388 | return yield* authSupportError("Antigravity browser suppression could not be verified."); |
| 389 | } |
| 390 | }).pipe( |
| 391 | Effect.scoped, |
| 392 | Effect.timeoutOrElse({ |
| 393 | duration: "5 seconds", |
| 394 | orElse: () => |
| 395 | Effect.fail(authSupportError("Antigravity browser suppression verification timed out.")), |
| 396 | }), |
| 397 | Effect.mapError((error) => |
| 398 | error._tag === "AcpTransportError" |
| 399 | ? error |
| 400 | : authSupportError("Antigravity browser suppression could not be verified."), |
| 401 | ), |
| 402 | ); |
| 403 | |
| 404 | for (const directory of [geminiHome, acpDirectory, tempDirectory]) { |
| 405 | yield* fs |
| 406 | .makeDirectory(directory, { recursive: true, mode: 0o700 }) |
| 407 | .pipe( |
| 408 | Effect.mapError(() => |
| 409 | authSupportError("The Antigravity profile directory could not be created."), |
| 410 | ), |
| 411 | ); |
| 412 | if (platform !== "win32") { |
| 413 | yield* fs |
| 414 | .chmod(directory, 0o700) |
| 415 | .pipe( |
| 416 | Effect.mapError(() => |
| 417 | authSupportError("The Antigravity profile directory permissions could not be set."), |
| 418 | ), |
| 419 | ); |
| 420 | } |
| 421 | } |
| 422 | // Rewriting on every launch keeps a method, project, or location edit in |
| 423 | // Settings effective. The agent also records auth.type here after a |
| 424 | // sign-in, which matches the value written below. |
| 425 | yield* fs |
| 426 | .writeFileString(path.join(acpDirectory, "settings.json"), antigravityProfileSettings(auth)) |
| 427 | .pipe( |
| 428 | Effect.mapError(() => |
| 429 | authSupportError("The Antigravity profile settings could not be written."), |
| 430 | ), |
| 431 | ); |
| 432 | yield* linkAntigravityUserSkills({ profileDirectory: geminiHome, userHome, platform }); |
| 433 | return profile; |
| 434 | }); |
| 435 | |
| 436 | /** Applies the same subscription-only launch settings to every ACP process. */ |
| 437 | export function buildAntigravityAcpSpawnInput(input: { |
| 438 | readonly installation: { |
| 439 | readonly executablePath: string; |
| 440 | readonly harnessPath: string; |
| 441 | }; |
| 442 | readonly profile: AntigravityProfile; |
| 443 | readonly cwd: string; |
| 444 | readonly baseEnv?: NodeJS.ProcessEnv; |
| 445 | readonly auth?: AntigravityAuthConfig; |
| 446 | /** Per-process temp directory. Defaults to the profile's shared temp directory. */ |
| 447 | readonly runtimeTempDirectory?: string; |
| 448 | }): AcpSessionRuntime.AcpSpawnInput { |
| 449 | return { |
| 450 | command: input.installation.executablePath, |
| 451 | args: input.profile.platform === "linux" ? ["--uid="] : [], |
| 452 | cwd: input.cwd, |
| 453 | env: { |
| 454 | ...antigravityEnvironment( |
| 455 | input.profile, |
| 456 | input.baseEnv ?? process.env, |
| 457 | input.auth ?? ANTIGRAVITY_PERSONAL_AUTH, |
| 458 | input.runtimeTempDirectory, |
| 459 | ), |
| 460 | ANTIGRAVITY_HARNESS_PATH: input.installation.harnessPath, |
| 461 | }, |
| 462 | extendEnv: false, |
| 463 | }; |
| 464 | } |
| 465 | |
| 466 | /** Reads only the public authorization request, never an OAuth token file. */ |
| 467 | export const parseAntigravityAuthorizationUrl = Effect.fn("parseAntigravityAuthorizationUrl")( |
| 468 | function* ( |
| 469 | authorizationUrl: string, |
| 470 | ): Effect.fn.Return<AntigravityAuthorizationUrl, AcpErrors.AcpError> { |
| 471 | const invalidUrl = () => |
| 472 | authSupportError("Antigravity returned an invalid Google sign-in URL."); |
| 473 | if (authorizationUrl.length > maxAuthorizationUrlLength || /\s/.test(authorizationUrl)) { |
| 474 | return yield* invalidUrl(); |
| 475 | } |
| 476 | const url = yield* decodeUrl(authorizationUrl).pipe(Effect.mapError(invalidUrl)); |
| 477 | const state = url.searchParams.get("state"); |
| 478 | const redirectUri = url.searchParams.get("redirect_uri"); |
| 479 | if ( |
| 480 | url.origin !== "https://accounts.google.com" || |
| 481 | url.pathname !== "/o/oauth2/v2/auth" || |
| 482 | url.username !== "" || |
| 483 | url.password !== "" || |
| 484 | url.hash !== "" || |
| 485 | url.searchParams.getAll("state").length !== 1 || |
| 486 | url.searchParams.getAll("redirect_uri").length !== 1 || |
| 487 | url.searchParams.getAll("response_type").length !== 1 || |
| 488 | url.searchParams.get("response_type") !== "code" || |
| 489 | state === null || |
| 490 | state.length === 0 || |
| 491 | state.length > 512 || |
| 492 | /\s/.test(state) || |
| 493 | redirectUri === null || |
| 494 | !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}\/$/.test(redirectUri) |
| 495 | ) { |
| 496 | return yield* invalidUrl(); |
| 497 | } |
| 498 | const redirect = yield* decodeUrl(redirectUri).pipe(Effect.mapError(invalidUrl)); |
| 499 | if (Number(redirect.port) < 1_024) return yield* invalidUrl(); |
| 500 | return { authorizationUrl, redirectUri, state }; |
| 501 | }, |
| 502 | ); |
| 503 | |
| 504 | export function makeAntigravityStdoutTransform( |
| 505 | input: { |
| 506 | readonly onAuthorizationUrl?: ( |
| 507 | authorizationUrl: string, |
| 508 | ) => Effect.Effect<void, AcpErrors.AcpError>; |
| 509 | } = {}, |
| 510 | ) { |
| 511 | const handleLine = Effect.fn("antigravityAuthSupport.handleStdoutLine")(function* ( |
| 512 | line: Uint8Array, |
| 513 | ) { |
| 514 | if (!authPrefixBytes.every((byte, index) => line[index] === byte)) return [line]; |
| 515 | const message = new TextDecoder().decode(line).replace(/\r?\n$/, ""); |
| 516 | const request = yield* parseAntigravityAuthorizationUrl( |
| 517 | message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length), |
| 518 | ); |
| 519 | if (!input.onAuthorizationUrl) { |
| 520 | return yield* authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE); |
| 521 | } |
| 522 | yield* input.onAuthorizationUrl(request.authorizationUrl); |
| 523 | return []; |
| 524 | }); |
| 525 | |
| 526 | return ( |
| 527 | stdout: ChildProcessSpawner.ChildProcessHandle["stdout"], |
| 528 | ): Stream.Stream<Uint8Array, PlatformError.PlatformError | AcpErrors.AcpError> => |
| 529 | Stream.suspend(() => { |
| 530 | let pending: Uint8Array[] = []; |
| 531 | let pendingBytes = 0; |
| 532 | const finishLine = () => { |
| 533 | const line = Buffer.concat(pending, pendingBytes); |
| 534 | pending = []; |
| 535 | pendingBytes = 0; |
| 536 | return line; |
| 537 | }; |
| 538 | return stdout.pipe( |
| 539 | Stream.mapEffect( |
| 540 | Effect.fn("antigravityAuthSupport.splitStdoutLines")(function* (chunk: Uint8Array) { |
| 541 | const lines: Uint8Array[] = []; |
| 542 | let offset = 0; |
| 543 | while (offset < chunk.byteLength) { |
| 544 | const newline = chunk.indexOf(10, offset); |
| 545 | const end = newline === -1 ? chunk.byteLength : newline + 1; |
| 546 | const part = chunk.subarray(offset, end); |
| 547 | if (pendingBytes + part.byteLength > maxStdoutLineBytes) { |
| 548 | return yield* authSupportError( |
| 549 | "Antigravity sent a protocol line that is too large.", |
| 550 | ); |
| 551 | } |
| 552 | pending.push(part); |
| 553 | pendingBytes += part.byteLength; |
| 554 | if (newline !== -1) lines.push(finishLine()); |
| 555 | offset = end; |
| 556 | } |
| 557 | return lines; |
| 558 | }), |
| 559 | ), |
| 560 | Stream.flatMap(Stream.fromIterable), |
| 561 | Stream.concat( |
| 562 | Stream.suspend(() => (pendingBytes > 0 ? Stream.succeed(finishLine()) : Stream.empty)), |
| 563 | ), |
| 564 | Stream.mapEffect(handleLine), |
| 565 | Stream.flatMap(Stream.fromIterable), |
| 566 | ); |
| 567 | }); |
| 568 | } |
| 569 | |
| 570 | /** Receives native 1.1.1 sign-in URLs and T3 browser-helper URLs without logging stderr. */ |
| 571 | export function makeAntigravityStderrHandler( |
| 572 | input: { |
| 573 | readonly onAuthorizationUrl?: ( |
| 574 | authorizationUrl: string, |
| 575 | ) => Effect.Effect<void, AcpErrors.AcpError>; |
| 576 | } = {}, |
| 577 | ) { |
| 578 | let pending = ""; |
| 579 | const handleLine = (line: string) => { |
| 580 | const message = line.endsWith("\r") ? line.slice(0, -1) : line; |
| 581 | if (message.length > maxBrowserHelperLineLength) { |
| 582 | return Effect.void; |
| 583 | } |
| 584 | const url = message.startsWith(ANTIGRAVITY_AUTH_STDOUT_PREFIX) |
| 585 | ? Effect.succeed(message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length)) |
| 586 | : message.startsWith(ANTIGRAVITY_AUTH_BROWSER_MARKER) |
| 587 | ? decodeBrowserHelperUrl(message.slice(ANTIGRAVITY_AUTH_BROWSER_MARKER.length)) |
| 588 | : undefined; |
| 589 | if (url === undefined) return Effect.void; |
| 590 | return url.pipe( |
| 591 | Effect.flatMap(parseAntigravityAuthorizationUrl), |
| 592 | Effect.matchEffect({ |
| 593 | onFailure: () => Effect.void, |
| 594 | onSuccess: (request) => |
| 595 | input.onAuthorizationUrl |
| 596 | ? input.onAuthorizationUrl(request.authorizationUrl) |
| 597 | : Effect.fail(authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE)), |
| 598 | }), |
| 599 | ); |
| 600 | }; |
| 601 | |
| 602 | return Effect.fn("antigravityAuthSupport.handleStderr")(function* (text: string) { |
| 603 | const lines = `${pending}${text}`.split("\n"); |
| 604 | pending = lines.pop() ?? ""; |
| 605 | if (pending.length > maxBrowserHelperLineLength) pending = ""; |
| 606 | yield* Effect.forEach(lines, handleLine, { discard: true }); |
| 607 | }); |
| 608 | } |