| 1 | // @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest or free-space query. |
| 2 | import * as EffectNodeStream from "@effect/platform-node/NodeStream"; |
| 3 | import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; |
| 4 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 5 | import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; |
| 6 | import * as Clock from "effect/Clock"; |
| 7 | import * as Cause from "effect/Cause"; |
| 8 | import * as Context from "effect/Context"; |
| 9 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; |
| 12 | import * as Fiber from "effect/Fiber"; |
| 13 | import * as FileSystem from "effect/FileSystem"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; |
| 16 | import * as Path from "effect/Path"; |
| 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as Scope from "effect/Scope"; |
| 19 | import * as Semaphore from "effect/Semaphore"; |
| 20 | import * as Stream from "effect/Stream"; |
| 21 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 22 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 23 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 24 | import * as NodeCrypto from "node:crypto"; |
| 25 | import * as NodeFSP from "node:fs/promises"; |
| 26 | |
| 27 | import * as ServerConfig from "../config.ts"; |
| 28 | import { openZipArchive } from "../zipArchive.ts"; |
| 29 | import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts"; |
| 30 | import { |
| 31 | buildAntigravityAcpSpawnInput, |
| 32 | prepareAntigravityProfile, |
| 33 | } from "./antigravityAuthSupport.ts"; |
| 34 | import { |
| 35 | resolveAntigravityReleaseAsset, |
| 36 | type AntigravityReleaseAsset, |
| 37 | } from "./antigravityRelease.ts"; |
| 38 | |
| 39 | const DRIVER = ProviderDriverKind.make("antigravity"); |
| 40 | const DOWNLOAD_TIMEOUT = "45 minutes"; |
| 41 | const VALIDATION_TIMEOUT = "90 seconds"; |
| 42 | const FREE_SPACE_MARGIN = 256 * 1024 * 1024; |
| 43 | const RECORD_MAX_BYTES = 8 * 1024; |
| 44 | const RELEASE_RECORD = ".install-complete.json"; |
| 45 | |
| 46 | const ReleaseId = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u)); |
| 47 | const ActiveRelease = Schema.Struct({ releaseId: ReleaseId }); |
| 48 | const InstalledRelease = Schema.Struct({ |
| 49 | releaseId: ReleaseId, |
| 50 | version: Schema.String, |
| 51 | executable: Schema.Struct({ name: Schema.String, bytes: Schema.Number }), |
| 52 | harness: Schema.Struct({ name: Schema.String, bytes: Schema.Number }), |
| 53 | }); |
| 54 | type InstalledRelease = typeof InstalledRelease.Type; |
| 55 | const encodeActiveRelease = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease)); |
| 56 | const encodeInstalledRelease = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease)); |
| 57 | |
| 58 | export class AntigravityInstallationError extends Schema.TaggedError<AntigravityInstallationError>()( |
| 59 | "AntigravityInstallationError", |
| 60 | { |
| 61 | operation: Schema.String, |
| 62 | detail: Schema.String, |
| 63 | cause: Schema.optional(Schema.Defect()), |
| 64 | }, |
| 65 | ) { |
| 66 | override get message() { |
| 67 | return this.detail; |
| 68 | } |
| 69 | } |
| 70 | const isInstallationError = Schema.is(AntigravityInstallationError); |
| 71 | |
| 72 | export interface AntigravityExecutable { |
| 73 | readonly executablePath: string; |
| 74 | readonly harnessPath: string; |
| 75 | readonly source: "override" | "managed" | "path"; |
| 76 | readonly version: string | null; |
| 77 | readonly managedVersionDirectory: string | null; |
| 78 | } |
| 79 | |
| 80 | interface AntigravityInstallationService { |
| 81 | readonly managedDirectory: string; |
| 82 | readonly resolve: ( |
| 83 | binaryPath?: string, |
| 84 | environment?: NodeJS.ProcessEnv, |
| 85 | ) => Effect.Effect<AntigravityExecutable, AntigravityInstallationError>; |
| 86 | /** Hold the lease until the spawned process has exited. */ |
| 87 | readonly acquire: ( |
| 88 | binaryPath?: string, |
| 89 | environment?: NodeJS.ProcessEnv, |
| 90 | ) => Effect.Effect<AntigravityExecutable, AntigravityInstallationError, Scope.Scope>; |
| 91 | readonly start: Effect.Effect<ProviderInstallState, AntigravityInstallationError>; |
| 92 | readonly cancel: ( |
| 93 | operationId: string, |
| 94 | ) => Effect.Effect<ProviderInstallState, AntigravityInstallationError>; |
| 95 | readonly state: Effect.Effect<ProviderInstallState>; |
| 96 | readonly changes: Stream.Stream<ProviderInstallState>; |
| 97 | readonly remove: ( |
| 98 | protectedBinaryPaths?: ReadonlyArray<string>, |
| 99 | ) => Effect.Effect<void, AntigravityInstallationError>; |
| 100 | } |
| 101 | |
| 102 | export class AntigravityInstallation extends Context.Service< |
| 103 | AntigravityInstallation, |
| 104 | AntigravityInstallationService |
| 105 | >()("t3/provider/AntigravityInstallation") { |
| 106 | static readonly layer = Layer.effect( |
| 107 | AntigravityInstallation, |
| 108 | Effect.gen(function* () { |
| 109 | const config = yield* ServerConfig.ServerConfig; |
| 110 | return yield* makeAntigravityInstallation({ baseDir: config.baseDir }); |
| 111 | }), |
| 112 | ); |
| 113 | } |
| 114 | |
| 115 | export interface AntigravityInstallationOptions { |
| 116 | readonly baseDir: string; |
| 117 | readonly releaseAsset?: AntigravityReleaseAsset | null; |
| 118 | readonly validate?: ( |
| 119 | executable: AntigravityExecutable, |
| 120 | expectedVersion: string, |
| 121 | ) => Effect.Effect<void, AntigravityInstallationError, Scope.Scope>; |
| 122 | } |
| 123 | |
| 124 | const installationError = (operation: string, detail: string, cause?: unknown) => |
| 125 | new AntigravityInstallationError({ |
| 126 | operation, |
| 127 | detail, |
| 128 | ...(cause === undefined ? {} : { cause }), |
| 129 | }); |
| 130 | |
| 131 | const wrapFailure = (operation: string, detail: string) => (cause: unknown) => |
| 132 | isInstallationError(cause) ? cause : installationError(operation, detail, cause); |
| 133 | |
| 134 | function executableNames(platform: NodeJS.Platform) { |
| 135 | return platform === "win32" |
| 136 | ? { executable: "agy_acp_server.exe", harness: "localharness_external.exe" } |
| 137 | : { executable: "agy_acp_server.par", harness: "localharness_external" }; |
| 138 | } |
| 139 | |
| 140 | function isRunning(state: ProviderInstallState) { |
| 141 | return ( |
| 142 | state.phase === "downloading" || state.phase === "extracting" || state.phase === "verifying" |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.make")(function* ( |
| 147 | options: AntigravityInstallationOptions, |
| 148 | ) { |
| 149 | const fs = yield* FileSystem.FileSystem; |
| 150 | const path = yield* Path.Path; |
| 151 | const crypto = yield* Crypto.Crypto; |
| 152 | const http = yield* HttpClient.HttpClient; |
| 153 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 154 | const serviceScope = yield* Effect.scope; |
| 155 | const platform = yield* HostProcess.Platform; |
| 156 | const arch = yield* HostProcess.Architecture; |
| 157 | const environment = yield* HostProcess.Environment; |
| 158 | const releaseAsset = |
| 159 | options.releaseAsset === undefined |
| 160 | ? resolveAntigravityReleaseAsset(platform, arch) |
| 161 | : options.releaseAsset; |
| 162 | const names = executableNames(platform); |
| 163 | const managedDirectory = path.join( |
| 164 | options.baseDir, |
| 165 | "tools", |
| 166 | "antigravity-acp", |
| 167 | `${platform}-${arch}`, |
| 168 | ); |
| 169 | const versionsDirectory = path.join(managedDirectory, "versions"); |
| 170 | const activePath = path.join(managedDirectory, "active.json"); |
| 171 | const gate = yield* Semaphore.make(1); |
| 172 | const leases = new Map<string, number>(); |
| 173 | let running: { readonly operationId: string; readonly fiber: Fiber.Fiber<void> } | undefined; |
| 174 | const state = yield* SubscriptionRef.make<ProviderInstallState>({ |
| 175 | driver: DRIVER, |
| 176 | operationId: null, |
| 177 | phase: "idle", |
| 178 | downloadedBytes: 0, |
| 179 | totalBytes: releaseAsset?.archiveBytes ?? null, |
| 180 | version: releaseAsset?.version ?? null, |
| 181 | installedVersion: null, |
| 182 | canRemove: false, |
| 183 | message: null, |
| 184 | }); |
| 185 | |
| 186 | const readRecord = Effect.fn("AntigravityInstallation.readRecord")(function* <A>( |
| 187 | filePath: string, |
| 188 | schema: Schema.Codec<A>, |
| 189 | ) { |
| 190 | const info = yield* fs.stat(filePath); |
| 191 | if (info.type !== "File" || Number(info.size) > RECORD_MAX_BYTES) { |
| 192 | return yield* installationError( |
| 193 | "resolve", |
| 194 | "The managed runtime record is invalid. Reinstall Antigravity.", |
| 195 | ); |
| 196 | } |
| 197 | const contents = yield* fs.readFileString(filePath); |
| 198 | return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(contents); |
| 199 | }); |
| 200 | |
| 201 | const executableFile = Effect.fn("AntigravityInstallation.executableFile")(function* ( |
| 202 | filePath: string, |
| 203 | bytes?: number, |
| 204 | ) { |
| 205 | const info = yield* fs.stat(filePath).pipe(Effect.option); |
| 206 | return ( |
| 207 | Option.isSome(info) && |
| 208 | info.value.type === "File" && |
| 209 | (bytes === undefined || Number(info.value.size) === bytes) && |
| 210 | (platform === "win32" || (info.value.mode & 0o111) !== 0) |
| 211 | ); |
| 212 | }); |
| 213 | |
| 214 | const completedRelease = Effect.fn("AntigravityInstallation.completedRelease")(function* ( |
| 215 | releaseId: string, |
| 216 | ) { |
| 217 | const directory = path.join(versionsDirectory, releaseId); |
| 218 | const record = yield* readRecord(path.join(directory, RELEASE_RECORD), InstalledRelease); |
| 219 | if ( |
| 220 | record.releaseId !== releaseId || |
| 221 | record.executable.name !== names.executable || |
| 222 | record.harness.name !== names.harness || |
| 223 | !Number.isSafeInteger(record.executable.bytes) || |
| 224 | record.executable.bytes <= 0 || |
| 225 | !Number.isSafeInteger(record.harness.bytes) || |
| 226 | record.harness.bytes <= 0 || |
| 227 | !record.version.trim() || |
| 228 | !(yield* executableFile(path.join(directory, names.executable), record.executable.bytes)) || |
| 229 | !(yield* executableFile(path.join(directory, names.harness), record.harness.bytes)) |
| 230 | ) { |
| 231 | return yield* installationError( |
| 232 | "resolve", |
| 233 | "The managed Antigravity runtime is incomplete. Reinstall it.", |
| 234 | ); |
| 235 | } |
| 236 | return { |
| 237 | executablePath: path.join(directory, names.executable), |
| 238 | harnessPath: path.join(directory, names.harness), |
| 239 | source: "managed", |
| 240 | version: record.version, |
| 241 | managedVersionDirectory: directory, |
| 242 | } satisfies AntigravityExecutable; |
| 243 | }); |
| 244 | |
| 245 | const fromExternal = Effect.fn("AntigravityInstallation.fromExternal")(function* ( |
| 246 | candidate: string, |
| 247 | source: "override" | "path", |
| 248 | ) { |
| 249 | if (!(yield* executableFile(candidate))) return null; |
| 250 | const executablePath = yield* fs.realPath(candidate); |
| 251 | const directory = path.dirname(executablePath); |
| 252 | const harnessPath = path.join(directory, names.harness); |
| 253 | if (!(yield* executableFile(harnessPath))) return null; |
| 254 | const realVersions = yield* fs.realPath(versionsDirectory).pipe(Effect.option); |
| 255 | if ( |
| 256 | Option.isSome(realVersions) && |
| 257 | path.dirname(directory) === realVersions.value && |
| 258 | /^[a-f0-9]{64}$/u.test(path.basename(directory)) |
| 259 | ) { |
| 260 | const installed = yield* completedRelease(path.basename(directory)); |
| 261 | return { ...installed, executablePath, harnessPath, source } satisfies AntigravityExecutable; |
| 262 | } |
| 263 | return { |
| 264 | executablePath, |
| 265 | harnessPath, |
| 266 | source, |
| 267 | version: null, |
| 268 | managedVersionDirectory: null, |
| 269 | } satisfies AntigravityExecutable; |
| 270 | }); |
| 271 | |
| 272 | const pathCandidates = (binary: string, processEnvironment = environment) => { |
| 273 | const pathValue = |
| 274 | platform === "win32" |
| 275 | ? Object.entries(processEnvironment).findLast(([key]) => key.toUpperCase() === "PATH")?.[1] |
| 276 | : processEnvironment.PATH; |
| 277 | return (pathValue ?? "") |
| 278 | .split(platform === "win32" ? ";" : ":") |
| 279 | .map((directory) => directory.trim().replace(/^"|"$/gu, "")) |
| 280 | .filter((directory) => directory.length > 0) |
| 281 | .map((directory) => path.resolve(directory, binary)); |
| 282 | }; |
| 283 | |
| 284 | const resolve: AntigravityInstallationService["resolve"] = Effect.fn( |
| 285 | "AntigravityInstallation.resolve", |
| 286 | )( |
| 287 | function* (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) { |
| 288 | const override = binaryPath?.trim(); |
| 289 | if (override) { |
| 290 | const candidates = |
| 291 | path.isAbsolute(override) || override.includes("/") || override.includes("\\") |
| 292 | ? [path.resolve(override)] |
| 293 | : pathCandidates(override, processEnvironment); |
| 294 | for (const candidate of candidates) { |
| 295 | const selected = yield* fromExternal(candidate, "override"); |
| 296 | if (selected) return selected; |
| 297 | } |
| 298 | return yield* installationError( |
| 299 | "resolve", |
| 300 | "The custom Antigravity executable or its localharness_external sibling is missing or not executable.", |
| 301 | ); |
| 302 | } |
| 303 | if (yield* fs.exists(activePath)) { |
| 304 | const active = yield* readRecord(activePath, ActiveRelease); |
| 305 | return yield* completedRelease(active.releaseId); |
| 306 | } |
| 307 | for (const candidate of pathCandidates(names.executable, processEnvironment)) { |
| 308 | const selected = yield* fromExternal(candidate, "path"); |
| 309 | if (selected) return selected; |
| 310 | } |
| 311 | return yield* installationError( |
| 312 | "resolve", |
| 313 | releaseAsset |
| 314 | ? "Antigravity is not installed. Install it in this environment or set a custom executable path." |
| 315 | : `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported environment or a custom executable.`, |
| 316 | ); |
| 317 | }, |
| 318 | Effect.mapError( |
| 319 | wrapFailure( |
| 320 | "resolve", |
| 321 | "Could not read the Antigravity installation. Reinstall it or set a custom executable path.", |
| 322 | ), |
| 323 | ), |
| 324 | ); |
| 325 | |
| 326 | const acquire = (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) => |
| 327 | Effect.acquireRelease( |
| 328 | gate.withPermit( |
| 329 | Effect.gen(function* () { |
| 330 | const executable = yield* resolve(binaryPath, processEnvironment); |
| 331 | const directory = executable.managedVersionDirectory; |
| 332 | if (directory) leases.set(directory, (leases.get(directory) ?? 0) + 1); |
| 333 | return executable; |
| 334 | }), |
| 335 | ), |
| 336 | (executable) => |
| 337 | gate.withPermit( |
| 338 | Effect.sync(() => { |
| 339 | const directory = executable.managedVersionDirectory; |
| 340 | if (!directory) return; |
| 341 | const remaining = (leases.get(directory) ?? 1) - 1; |
| 342 | if (remaining > 0) leases.set(directory, remaining); |
| 343 | else leases.delete(directory); |
| 344 | }), |
| 345 | ), |
| 346 | ); |
| 347 | |
| 348 | const validate = |
| 349 | options.validate ?? |
| 350 | Effect.fn("AntigravityInstallation.validate")( |
| 351 | function* (executable: AntigravityExecutable, expectedVersion: string) { |
| 352 | const profileDirectory = yield* fs.makeTempDirectoryScoped({ |
| 353 | prefix: "t3-antigravity-validate-", |
| 354 | }); |
| 355 | const profile = yield* prepareAntigravityProfile({ |
| 356 | profileDirectory, |
| 357 | platform, |
| 358 | baseEnv: environment, |
| 359 | // The profile is scoped, so it cleans up the unpack; a shallow |
| 360 | // root keeps it under Windows' path limit. |
| 361 | tempDirectory: profileDirectory, |
| 362 | }); |
| 363 | const runtime = yield* makeAntigravityAcpRuntime({ |
| 364 | spawn: buildAntigravityAcpSpawnInput({ |
| 365 | installation: executable, |
| 366 | profile, |
| 367 | cwd: profileDirectory, |
| 368 | baseEnv: environment, |
| 369 | }), |
| 370 | cwd: profileDirectory, |
| 371 | childProcessSpawner: spawner, |
| 372 | clientInfo: { name: "t3-code", version: "0.0.0" }, |
| 373 | }); |
| 374 | const initialized = yield* runtime.initialize(); |
| 375 | if ( |
| 376 | initialized.agentInfo?.name !== "antigravity-acp" || |
| 377 | initialized.agentInfo.version !== expectedVersion || |
| 378 | // Antigravity 1.1.1 can report 2 with the legacy ACP response shape. |
| 379 | // The ACP client chooses the session wire format from that shape. |
| 380 | (initialized.protocolVersion !== 1 && initialized.protocolVersion !== 2) || |
| 381 | initialized.agentCapabilities?.loadSession !== true || |
| 382 | !initialized.agentCapabilities.sessionCapabilities?.resume || |
| 383 | !initialized.agentCapabilities.auth?.logout || |
| 384 | !initialized.authMethods?.some((method) => method.id === "oauth-personal") |
| 385 | ) { |
| 386 | return yield* installationError( |
| 387 | "verify", |
| 388 | "The downloaded runtime did not identify as the expected Google Antigravity release.", |
| 389 | ); |
| 390 | } |
| 391 | }, |
| 392 | Effect.provideService(FileSystem.FileSystem, fs), |
| 393 | Effect.provideService(Path.Path, path), |
| 394 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 395 | Effect.provideService(Crypto.Crypto, crypto), |
| 396 | Effect.mapError( |
| 397 | wrapFailure( |
| 398 | "verify", |
| 399 | "The downloaded Antigravity runtime could not start in this environment.", |
| 400 | ), |
| 401 | ), |
| 402 | ); |
| 403 | |
| 404 | const install = Effect.fn("AntigravityInstallation.install")( |
| 405 | function* (asset: AntigravityReleaseAsset) { |
| 406 | yield* resolveNodeExecutable("Antigravity", environment).pipe( |
| 407 | Effect.provideService(FileSystem.FileSystem, fs), |
| 408 | Effect.provideService(Path.Path, path), |
| 409 | Effect.provideService(HostProcess.Platform, platform), |
| 410 | Effect.mapError((cause) => |
| 411 | installationError("verify", nodeRuntimeUnavailableMessage("Antigravity"), cause), |
| 412 | ), |
| 413 | ); |
| 414 | const report = (phase: ProviderInstallState["phase"], message: string | null) => |
| 415 | SubscriptionRef.update(state, (current) => ({ ...current, phase, message })); |
| 416 | yield* fs.makeDirectory(versionsDirectory, { recursive: true }); |
| 417 | yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true })); |
| 418 | const destination = path.join(versionsDirectory, asset.sha256); |
| 419 | const activate = Effect.fn("AntigravityInstallation.activate")( |
| 420 | function* () { |
| 421 | const pointerDirectory = yield* fs.makeTempDirectoryScoped({ |
| 422 | directory: managedDirectory, |
| 423 | prefix: "active.json.", |
| 424 | }); |
| 425 | const pointerPath = path.join(pointerDirectory, "contents.tmp"); |
| 426 | yield* fs.writeFileString( |
| 427 | pointerPath, |
| 428 | yield* encodeActiveRelease({ releaseId: asset.sha256 }), |
| 429 | { flag: "wx", mode: 0o600 }, |
| 430 | ); |
| 431 | yield* fs.rename(pointerPath, activePath); |
| 432 | // The pointer commits the install. Later temp cleanup cannot undo it. |
| 433 | yield* SubscriptionRef.update( |
| 434 | state, |
| 435 | (current) => |
| 436 | ({ |
| 437 | ...current, |
| 438 | phase: "succeeded", |
| 439 | installedVersion: asset.version, |
| 440 | message: null, |
| 441 | }) satisfies ProviderInstallState, |
| 442 | ); |
| 443 | }, |
| 444 | Effect.scoped, |
| 445 | Effect.mapError( |
| 446 | wrapFailure( |
| 447 | "activate", |
| 448 | "Could not activate Antigravity. The previous runtime is unchanged. Check for locked files and try again.", |
| 449 | ), |
| 450 | ), |
| 451 | Effect.uninterruptible, |
| 452 | ); |
| 453 | |
| 454 | if (yield* fs.exists(destination)) { |
| 455 | const existing = yield* completedRelease(asset.sha256); |
| 456 | if (existing.version !== asset.version) { |
| 457 | return yield* installationError( |
| 458 | "verify", |
| 459 | "The existing managed release has the wrong version. Remove it before reinstalling.", |
| 460 | ); |
| 461 | } |
| 462 | yield* report("verifying", "Checking the installed runtime."); |
| 463 | yield* validate(existing, asset.version).pipe( |
| 464 | Effect.scoped, |
| 465 | Effect.timeout(VALIDATION_TIMEOUT), |
| 466 | ); |
| 467 | yield* activate(); |
| 468 | return; |
| 469 | } |
| 470 | |
| 471 | const available = yield* Effect.tryPromise(() => |
| 472 | NodeFSP.statfs(versionsDirectory, { bigint: true }), |
| 473 | ).pipe(Effect.option); |
| 474 | const required = |
| 475 | asset.archiveBytes + asset.executable.bytes + asset.harness.bytes + FREE_SPACE_MARGIN; |
| 476 | if ( |
| 477 | Option.isSome(available) && |
| 478 | available.value.bavail * available.value.bsize < BigInt(required) |
| 479 | ) { |
| 480 | return yield* installationError( |
| 481 | "download", |
| 482 | `Antigravity needs at least ${Math.ceil(required / 1024 / 1024)} MiB of free space to install.`, |
| 483 | ); |
| 484 | } |
| 485 | const staging = yield* fs.makeTempDirectoryScoped({ |
| 486 | directory: versionsDirectory, |
| 487 | prefix: ".install-", |
| 488 | }); |
| 489 | const archivePath = path.join(staging, "download.zip"); |
| 490 | const pairDirectory = path.join(staging, "runtime"); |
| 491 | yield* fs.makeDirectory(pairDirectory); |
| 492 | const hash = NodeCrypto.createHash("sha256"); |
| 493 | let downloadedBytes = 0; |
| 494 | let lastProgressAt = yield* Clock.currentTimeMillis; |
| 495 | yield* Effect.gen(function* () { |
| 496 | const response = yield* http |
| 497 | .execute(HttpClientRequest.get(asset.url)) |
| 498 | .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); |
| 499 | // dl.google.com gzips the zip when the client accepts it, so |
| 500 | // `content-length` is the encoded size. The decoded stream is still |
| 501 | // checked against the pinned byte count and hash below. |
| 502 | const contentLength = response.headers["content-length"]; |
| 503 | const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase(); |
| 504 | const identityBody = contentEncoding === undefined || contentEncoding === "identity"; |
| 505 | if ( |
| 506 | identityBody && |
| 507 | contentLength !== undefined && |
| 508 | Number(contentLength) !== asset.archiveBytes |
| 509 | ) { |
| 510 | return yield* installationError( |
| 511 | "download", |
| 512 | "The Antigravity download size did not match the pinned release.", |
| 513 | ); |
| 514 | } |
| 515 | yield* response.stream.pipe( |
| 516 | Stream.tap((chunk) => |
| 517 | Effect.gen(function* () { |
| 518 | downloadedBytes += chunk.byteLength; |
| 519 | if (downloadedBytes > asset.archiveBytes) { |
| 520 | return yield* installationError( |
| 521 | "download", |
| 522 | "The Antigravity download exceeded the pinned release size.", |
| 523 | ); |
| 524 | } |
| 525 | hash.update(chunk); |
| 526 | const now = yield* Clock.currentTimeMillis; |
| 527 | if (now - lastProgressAt >= 250 || downloadedBytes === asset.archiveBytes) { |
| 528 | lastProgressAt = now; |
| 529 | yield* SubscriptionRef.update(state, (current) => ({ |
| 530 | ...current, |
| 531 | downloadedBytes, |
| 532 | })); |
| 533 | } |
| 534 | }), |
| 535 | ), |
| 536 | Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })), |
| 537 | ); |
| 538 | }).pipe(Effect.timeout(DOWNLOAD_TIMEOUT)); |
| 539 | if (downloadedBytes !== asset.archiveBytes || hash.digest("hex") !== asset.sha256) { |
| 540 | return yield* installationError( |
| 541 | "download", |
| 542 | "The Antigravity download failed its size or SHA-256 check. Nothing was installed.", |
| 543 | ); |
| 544 | } |
| 545 | |
| 546 | yield* report("extracting", "Extracting the verified runtime."); |
| 547 | yield* Effect.gen(function* () { |
| 548 | const archive = yield* openZipArchive(archivePath, (detail, cause) => |
| 549 | installationError("extract", detail, cause), |
| 550 | ); |
| 551 | if (archive.entryCount !== 2) { |
| 552 | return yield* installationError( |
| 553 | "extract", |
| 554 | "The archive must contain exactly the Antigravity executable and its harness.", |
| 555 | ); |
| 556 | } |
| 557 | const seen = new Set<string>(); |
| 558 | for (;;) { |
| 559 | const entry = yield* archive.next; |
| 560 | if (!entry) break; |
| 561 | const expected = [asset.executable, asset.harness].find( |
| 562 | (file) => file.name === entry.fileName, |
| 563 | ); |
| 564 | const unixType = (entry.externalFileAttributes >>> 16) & 0o170000; |
| 565 | if ( |
| 566 | !expected || |
| 567 | seen.has(entry.fileName) || |
| 568 | entry.fileName.includes("/") || |
| 569 | entry.fileName.includes("\\") || |
| 570 | (unixType !== 0 && unixType !== 0o100000) || |
| 571 | (entry.externalFileAttributes & 0x10) !== 0 || |
| 572 | (entry.generalPurposeBitFlag & 1) !== 0 || |
| 573 | ![0, 8].includes(entry.compressionMethod) || |
| 574 | entry.uncompressedSize !== expected.bytes |
| 575 | ) { |
| 576 | return yield* installationError( |
| 577 | "extract", |
| 578 | "The archive contains an unexpected, unsafe, or incorrectly sized member.", |
| 579 | ); |
| 580 | } |
| 581 | seen.add(entry.fileName); |
| 582 | yield* Effect.gen(function* () { |
| 583 | const readable = yield* archive.streamEntry(entry); |
| 584 | let extractedBytes = 0; |
| 585 | yield* EffectNodeStream.fromReadable<Uint8Array, AntigravityInstallationError>({ |
| 586 | evaluate: () => readable, |
| 587 | onError: wrapFailure("extract", "Could not extract the Antigravity runtime."), |
| 588 | }).pipe( |
| 589 | Stream.tap((chunk) => |
| 590 | Effect.gen(function* () { |
| 591 | extractedBytes += chunk.byteLength; |
| 592 | if (extractedBytes > expected.bytes) { |
| 593 | return yield* installationError( |
| 594 | "extract", |
| 595 | "An archive member exceeded its pinned size.", |
| 596 | ); |
| 597 | } |
| 598 | }), |
| 599 | ), |
| 600 | Stream.run( |
| 601 | fs.sink(path.join(pairDirectory, entry.fileName), { flag: "wx", mode: 0o700 }), |
| 602 | ), |
| 603 | ); |
| 604 | if (extractedBytes !== expected.bytes) { |
| 605 | return yield* installationError("extract", "An archive member was truncated."); |
| 606 | } |
| 607 | }).pipe(Effect.scoped); |
| 608 | } |
| 609 | if (!seen.has(asset.executable.name) || !seen.has(asset.harness.name)) { |
| 610 | return yield* installationError( |
| 611 | "extract", |
| 612 | "The archive is missing the Antigravity executable or its harness.", |
| 613 | ); |
| 614 | } |
| 615 | }).pipe(Effect.scoped); |
| 616 | yield* fs.remove(archivePath); |
| 617 | if (platform !== "win32") { |
| 618 | yield* fs.chmod(path.join(pairDirectory, asset.executable.name), 0o755); |
| 619 | yield* fs.chmod(path.join(pairDirectory, asset.harness.name), 0o755); |
| 620 | } |
| 621 | yield* report("verifying", "Checking the downloaded runtime."); |
| 622 | yield* validate( |
| 623 | { |
| 624 | executablePath: path.join(pairDirectory, asset.executable.name), |
| 625 | harnessPath: path.join(pairDirectory, asset.harness.name), |
| 626 | source: "managed", |
| 627 | version: asset.version, |
| 628 | managedVersionDirectory: pairDirectory, |
| 629 | }, |
| 630 | asset.version, |
| 631 | ).pipe(Effect.scoped, Effect.timeout(VALIDATION_TIMEOUT)); |
| 632 | const record: InstalledRelease = { |
| 633 | releaseId: asset.sha256, |
| 634 | version: asset.version, |
| 635 | executable: asset.executable, |
| 636 | harness: asset.harness, |
| 637 | }; |
| 638 | yield* fs.writeFileString( |
| 639 | path.join(pairDirectory, RELEASE_RECORD), |
| 640 | yield* encodeInstalledRelease(record), |
| 641 | { flag: "wx", mode: 0o600 }, |
| 642 | ); |
| 643 | yield* fs.rename(pairDirectory, destination).pipe( |
| 644 | Effect.catch((cause) => |
| 645 | completedRelease(asset.sha256).pipe( |
| 646 | Effect.flatMap((existing) => |
| 647 | existing.version === asset.version |
| 648 | ? validate(existing, asset.version).pipe( |
| 649 | Effect.scoped, |
| 650 | Effect.timeout(VALIDATION_TIMEOUT), |
| 651 | ) |
| 652 | : Effect.fail( |
| 653 | installationError( |
| 654 | "activate", |
| 655 | "Another installation published a different Antigravity release.", |
| 656 | ), |
| 657 | ), |
| 658 | ), |
| 659 | Effect.mapError(() => |
| 660 | installationError( |
| 661 | "activate", |
| 662 | "Could not publish the Antigravity runtime. The previous release is unchanged. Try again.", |
| 663 | cause, |
| 664 | ), |
| 665 | ), |
| 666 | ), |
| 667 | ), |
| 668 | ); |
| 669 | yield* activate(); |
| 670 | }, |
| 671 | Effect.scoped, |
| 672 | Effect.mapError( |
| 673 | wrapFailure( |
| 674 | "install", |
| 675 | "Could not install Antigravity. Check free disk space and directory access, then try again.", |
| 676 | ), |
| 677 | ), |
| 678 | ); |
| 679 | |
| 680 | const start = gate |
| 681 | .withPermit( |
| 682 | Effect.gen(function* () { |
| 683 | const current = yield* SubscriptionRef.get(state); |
| 684 | if (isRunning(current)) return current; |
| 685 | if (!releaseAsset) { |
| 686 | return yield* installationError( |
| 687 | "start", |
| 688 | `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`, |
| 689 | ); |
| 690 | } |
| 691 | const operationId = yield* crypto.randomUUIDv4(); |
| 692 | const next: ProviderInstallState = { |
| 693 | driver: DRIVER, |
| 694 | operationId, |
| 695 | phase: "downloading", |
| 696 | downloadedBytes: 0, |
| 697 | totalBytes: releaseAsset.archiveBytes, |
| 698 | version: releaseAsset.version, |
| 699 | installedVersion: current.installedVersion, |
| 700 | canRemove: current.canRemove, |
| 701 | message: "Downloading Google's official Antigravity runtime.", |
| 702 | }; |
| 703 | yield* SubscriptionRef.set(state, next); |
| 704 | const work = install(releaseAsset).pipe( |
| 705 | Effect.onExit((exit) => |
| 706 | Exit.isFailure(exit) |
| 707 | ? SubscriptionRef.update(state, (value) => { |
| 708 | if (value.operationId !== operationId || value.phase === "succeeded") |
| 709 | return value; |
| 710 | const error = Cause.findErrorOption(exit.cause); |
| 711 | const cancelled = Cause.hasInterruptsOnly(exit.cause); |
| 712 | return { |
| 713 | ...value, |
| 714 | phase: cancelled ? "cancelled" : "failed", |
| 715 | message: cancelled |
| 716 | ? "Installation cancelled. The previous runtime is unchanged." |
| 717 | : Option.isSome(error) |
| 718 | ? error.value.detail |
| 719 | : "Could not finish the Antigravity installation. Check disk space and directory access.", |
| 720 | } satisfies ProviderInstallState; |
| 721 | }) |
| 722 | : Effect.void, |
| 723 | ), |
| 724 | Effect.ignoreCause, |
| 725 | Effect.ensuring( |
| 726 | Effect.sync(() => { |
| 727 | if (running?.operationId === operationId) running = undefined; |
| 728 | }), |
| 729 | ), |
| 730 | ); |
| 731 | const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope); |
| 732 | running = { operationId, fiber }; |
| 733 | return next; |
| 734 | }).pipe(Effect.uninterruptible), |
| 735 | ) |
| 736 | .pipe(Effect.mapError(wrapFailure("start", "Could not start the Antigravity installation."))); |
| 737 | |
| 738 | const cancel = Effect.fn("AntigravityInstallation.cancel")(function* (operationId: string) { |
| 739 | return yield* gate.withPermit( |
| 740 | Effect.gen(function* () { |
| 741 | const current = yield* SubscriptionRef.get(state); |
| 742 | if (current.operationId !== operationId) { |
| 743 | return yield* installationError( |
| 744 | "cancel", |
| 745 | "This installation is no longer current. Refresh its status before cancelling.", |
| 746 | ); |
| 747 | } |
| 748 | if (running?.operationId === operationId && isRunning(current)) { |
| 749 | yield* Fiber.interrupt(running.fiber); |
| 750 | } |
| 751 | return yield* SubscriptionRef.get(state); |
| 752 | }), |
| 753 | ); |
| 754 | }); |
| 755 | |
| 756 | const remove = Effect.fn("AntigravityInstallation.remove")( |
| 757 | function* (protectedBinaryPaths: ReadonlyArray<string> = []) { |
| 758 | yield* gate.withPermit( |
| 759 | Effect.gen(function* () { |
| 760 | if (isRunning(yield* SubscriptionRef.get(state)) || leases.size > 0) { |
| 761 | return yield* installationError( |
| 762 | "remove", |
| 763 | "Stop Antigravity sessions and sign-in flows before removing its managed runtime.", |
| 764 | ); |
| 765 | } |
| 766 | const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option); |
| 767 | if (Option.isSome(realManaged)) { |
| 768 | for (const binaryPath of protectedBinaryPaths) { |
| 769 | if (!binaryPath.trim()) continue; |
| 770 | const selected = yield* resolve(binaryPath).pipe(Effect.option); |
| 771 | if (Option.isSome(selected) && selected.value.managedVersionDirectory) { |
| 772 | return yield* installationError( |
| 773 | "remove", |
| 774 | "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.", |
| 775 | ); |
| 776 | } |
| 777 | const resolved = yield* fs.realPath(binaryPath).pipe(Effect.option); |
| 778 | const candidate = Option.getOrElse(resolved, () => path.resolve(binaryPath)); |
| 779 | if (candidate.startsWith(`${realManaged.value}${path.sep}`)) { |
| 780 | return yield* installationError( |
| 781 | "remove", |
| 782 | "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.", |
| 783 | ); |
| 784 | } |
| 785 | } |
| 786 | } |
| 787 | yield* fs.remove(managedDirectory, { recursive: true, force: true }); |
| 788 | yield* SubscriptionRef.update( |
| 789 | state, |
| 790 | (current) => |
| 791 | ({ |
| 792 | ...current, |
| 793 | operationId: null, |
| 794 | phase: "idle", |
| 795 | downloadedBytes: 0, |
| 796 | installedVersion: null, |
| 797 | canRemove: false, |
| 798 | message: null, |
| 799 | }) satisfies ProviderInstallState, |
| 800 | ); |
| 801 | }).pipe(Effect.uninterruptible), |
| 802 | ); |
| 803 | }, |
| 804 | Effect.mapError( |
| 805 | wrapFailure( |
| 806 | "remove", |
| 807 | "Could not remove the managed Antigravity runtime. Check for open processes and try again.", |
| 808 | ), |
| 809 | ), |
| 810 | ); |
| 811 | |
| 812 | yield* Effect.gen(function* () { |
| 813 | const canRemove = yield* fs.exists(managedDirectory); |
| 814 | yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove })); |
| 815 | if (!(yield* fs.exists(activePath))) return; |
| 816 | const active = yield* readRecord(activePath, ActiveRelease); |
| 817 | const installed = yield* completedRelease(active.releaseId); |
| 818 | yield* SubscriptionRef.update(state, (current) => ({ |
| 819 | ...current, |
| 820 | installedVersion: installed.version, |
| 821 | })); |
| 822 | }).pipe( |
| 823 | Effect.catch(() => |
| 824 | SubscriptionRef.update( |
| 825 | state, |
| 826 | (current) => |
| 827 | ({ |
| 828 | ...current, |
| 829 | phase: "failed", |
| 830 | message: "The managed Antigravity runtime is incomplete. Remove it and reinstall.", |
| 831 | }) satisfies ProviderInstallState, |
| 832 | ), |
| 833 | ), |
| 834 | ); |
| 835 | |
| 836 | return AntigravityInstallation.of({ |
| 837 | managedDirectory, |
| 838 | resolve, |
| 839 | acquire, |
| 840 | start, |
| 841 | cancel, |
| 842 | state: SubscriptionRef.get(state), |
| 843 | changes: SubscriptionRef.changes(state), |
| 844 | remove, |
| 845 | }); |
| 846 | }); |