Skip to content
Source
Sign inSign up

AntigravityInstallation.ts

1,177 lines47.5 KB

1// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest or free-space query.
2import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
3import * as ModelManifest from "./ModelManifest.ts";
4import { resolveProviderCompatibility } from "./providerCompatibility.ts";
5import { cliArchiveTarCommand } from "@t3tools/shared/cliRelease";
6import { compareSemverVersions } from "@t3tools/shared/semver";
7import * as EffectNodeStream from "@effect/platform-node/NodeStream";
8import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
11import { resolveCommandPath } from "@t3tools/shared/shell";
12import * as Clock from "effect/Clock";
13import * as Cause from "effect/Cause";
14import * as Context from "effect/Context";
15import * as SecureRandom from "effect/crypto/SecureRandom";
16import * as Effect from "effect/Effect";
17import * as Exit from "effect/Exit";
18import * as Fiber from "effect/Fiber";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import * as Path from "effect/Path";
23import * as Schema from "effect/Schema";
24import * as Schedule from "effect/Schedule";
25import * as Scope from "effect/Scope";
26import * as Semaphore from "effect/Semaphore";
27import * as Stream from "effect/Stream";
28import * as SubscriptionRef from "effect/SubscriptionRef";
29import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
30import * as ChildProcess from "effect/process/ChildProcess";
31import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
32import * as NodeCrypto from "node:crypto";
33import * as NodeFSP from "node:fs/promises";
34import * as EffectAcpErrors from "effect-acp/errors";
35​
36import * as ServerConfig from "../config.ts";
37import * as ServerSettings from "../serverSettings.ts";
38import {
39 ANTIGRAVITY_CLI_MANIFEST_BASE,
40 antigravityCliTarget,
41 parseAntigravityCliRelease,
42 type AntigravityCliReleaseAsset,
43} from "./antigravityCliRelease.ts";
44import { openZipArchive } from "../zipArchive.ts";
45import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts";
46import {
47 buildAntigravityAcpSpawnInput,
48 prepareAntigravityProfile,
49} from "./antigravityAuthSupport.ts";
50import { type AntigravityReleaseAsset } from "./antigravityRelease.ts";
51​
52const DRIVER = ProviderDriverKind.make("antigravity");
53const DOWNLOAD_TIMEOUT = "45 minutes";
54const VALIDATION_TIMEOUT = "90 seconds";
55const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
56const MAX_EXECUTABLE_BYTES = 512 * 1024 * 1024;
57const UPDATE_CHECK_INTERVAL = 60 * 60 * 1000;
58const FREE_SPACE_MARGIN = 256 * 1024 * 1024;
59const RECORD_MAX_BYTES = 8 * 1024;
60const RELEASE_RECORD = ".install-complete.json";
61const LARGE_PAGE_KERNEL_MESSAGE =
62 "Google's linux-arm64 Antigravity runtime needs a kernel with 4 KiB memory pages, and this kernel uses larger pages (as on Asahi Linux or Raspberry Pi 5). Use a remote environment or a custom Antigravity executable.";
63​
64const ReleaseId = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u));
65const ActiveRelease = Schema.Struct({ releaseId: ReleaseId });
66const InstalledRelease = Schema.Struct({
67 releaseId: ReleaseId,
68 version: Schema.String,
69 executable: Schema.Struct({ name: Schema.String, bytes: Schema.Number }),
70 harness: Schema.NullOr(Schema.Struct({ name: Schema.String, bytes: Schema.Number })),
71});
72type InstalledRelease = typeof InstalledRelease.Type;
73const encodeActiveRelease = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease));
74const encodeInstalledRelease = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease));
75​
76export class AntigravityInstallationError extends Schema.TaggedError<AntigravityInstallationError>()(
77 "AntigravityInstallationError",
78 {
79 operation: Schema.String,
80 detail: Schema.String,
81 cause: Schema.optional(Schema.Defect()),
82 },
83) {
84 override get message() {
85 return this.detail;
86 }
88const isInstallationError = Schema.is(AntigravityInstallationError);
89const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError);
90​
91export interface AntigravityExecutable {
92 readonly executablePath: string;
93 /** Null for the `agy` CLI, which embeds the harness and runs as `agy acp`. */
94 readonly harnessPath: string | null;
95 readonly source: "override" | "managed" | "path";
96 readonly version: string | null;
97 readonly managedVersionDirectory: string | null;
99​
100interface AntigravityInstallationService {
101 readonly managedDirectory: string;
102 readonly resolve: (
103 binaryPath?: string,
104 environment?: NodeJS.ProcessEnv,
105 ) => Effect.Effect<AntigravityExecutable, AntigravityInstallationError>;
106 /** Hold the lease until the spawned process has exited. */
107 readonly acquire: (
108 binaryPath?: string,
109 environment?: NodeJS.ProcessEnv,
110 ) => Effect.Effect<AntigravityExecutable, AntigravityInstallationError, Scope.Scope>;
111 readonly start: Effect.Effect<ProviderInstallState, AntigravityInstallationError>;
112 readonly cancel: (
113 operationId: string,
114 ) => Effect.Effect<ProviderInstallState, AntigravityInstallationError>;
115 readonly state: Effect.Effect<ProviderInstallState>;
116 readonly changes: Stream.Stream<ProviderInstallState>;
117 readonly checkForUpdate: Effect.Effect<void>;
118 readonly remove: (
119 protectedBinaryPaths?: ReadonlyArray<string>,
120 ) => Effect.Effect<void, AntigravityInstallationError>;
122​
123export class AntigravityInstallation extends Context.Service<
124 AntigravityInstallation,
125 AntigravityInstallationService
126>()("t3/provider/AntigravityInstallation") {
127 static readonly layer = Layer.effect(
128 AntigravityInstallation,
129 Effect.gen(function* () {
130 const config = yield* ServerConfig.ServerConfig;
131 const settings = yield* ServerSettings.ServerSettingsService;
132 return yield* makeAntigravityInstallation({
133 baseDir: config.baseDir,
134 updateChecksEnabled: settings.getSettings.pipe(
135 Effect.map((value) => value.enableProviderUpdateChecks),
136 Effect.orElseSucceed(() => false),
137 ),
138 });
139 }),
140 );
142​
143export interface AntigravityInstallationOptions {
144 readonly baseDir: string;
145 readonly releaseAsset?: AntigravityReleaseAsset | AntigravityCliReleaseAsset | null;
146 readonly updateChecksEnabled?: Effect.Effect<boolean>;
147 readonly validate?: (
148 executable: AntigravityExecutable,
149 expectedVersion: string,
150 ) => Effect.Effect<void, AntigravityInstallationError, Scope.Scope>;
152​
153const installationError = (operation: string, detail: string, cause?: unknown) =>
154 new AntigravityInstallationError({
155 operation,
156 detail,
157 ...(cause === undefined ? {} : { cause }),
158 });
159​
160const wrapFailure = (operation: string, detail: string) => (cause: unknown) =>
161 isInstallationError(cause) ? cause : installationError(operation, detail, cause);
162​
163function executableNames(platform: NodeJS.Platform) {
164 return platform === "win32"
165 ? { executable: "agy_acp_server.exe", harness: "localharness_external.exe" }
166 : { executable: "agy_acp_server.par", harness: "localharness_external" };
168​
169/** Antigravity CLI 1.5.0+ serves ACP itself (`agy acp`), so it has no `localharness_external` sibling. */
170function isAntigravityCli(...candidates: ReadonlyArray<string>) {
171 return candidates.some((candidate) =>
172 /^(?:agy|antigravity)(?:\.(?:exe|cmd|bat))?$/iu.test(candidate.split(/[\\/]/u).at(-1) ?? ""),
173 );
175​
176function isRunning(state: ProviderInstallState) {
177 return (
178 state.phase === "downloading" || state.phase === "extracting" || state.phase === "verifying"
179 );
181​
182export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.make")(function* (
183 options: AntigravityInstallationOptions,
184) {
185 const manifestService = yield* ModelManifest.ModelManifest;
186 const fs = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188 const random = yield* SecureRandom.SecureRandom;
189 const http = yield* HttpClient.HttpClient;
190 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
191 const serviceScope = yield* Effect.scope;
192 const platform = yield* HostProcess.Platform;
193 const arch = yield* HostProcess.Architecture;
194 const environment = yield* HostProcess.Environment;
195 let releaseAsset = options.releaseAsset ?? null;
196 const supported =
197 options.releaseAsset === undefined
198 ? antigravityCliTarget(platform, arch) !== null
199 : options.releaseAsset !== null;
200 const names = executableNames(platform);
201 const managedDirectory = path.join(
202 options.baseDir,
203 "tools",
204 "antigravity-acp",
205 `${platform}-${arch}`,
206 );
207 const versionsDirectory = path.join(managedDirectory, "versions");
208 const activePath = path.join(managedDirectory, "active.json");
209 const gate = yield* Semaphore.make(1);
210 const leases = new Map<string, number>();
211 let running: { readonly operationId: string; readonly fiber: Fiber.Fiber<void> } | undefined;
212 const state = yield* SubscriptionRef.make<ProviderInstallState>({
213 driver: DRIVER,
214 operationId: null,
215 phase: "idle",
216 downloadedBytes: 0,
217 totalBytes:
218 releaseAsset !== null && "archiveBytes" in releaseAsset ? releaseAsset.archiveBytes : null,
219 version: releaseAsset?.version ?? null,
220 installedVersion: null,
221 canRemove: false,
222 message: null,
223 });
224​
225 const isWithdrawn = Effect.fn("AntigravityInstallation.isWithdrawn")(function* (version: string) {
226 const manifest = yield* manifestService.current;
227 const status = (
228 resolveProviderCompatibility(manifest.compatibility, DRIVER, version) ??
229 resolveProviderCompatibility(
230 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
231 DRIVER,
232 version,
233 )
234 )?.status;
235 return status === "broken" || status === "unsupported";
236 });
237 const ensureEligible = Effect.fn("AntigravityInstallation.ensureEligible")(function* (
238 version: string,
239 ) {
240 if (yield* isWithdrawn(version))
241 return yield* installationError(
242 "verify",
243 "This Antigravity release was withdrawn. Check for updates and try again.",
244 );
245 });
246​
247 const readRecord = Effect.fn("AntigravityInstallation.readRecord")(function* <A>(
248 filePath: string,
249 schema: Schema.Codec<A>,
250 ) {
251 const info = yield* fs.stat(filePath);
252 if (info.type !== "File" || Number(info.size) > RECORD_MAX_BYTES) {
253 return yield* installationError(
254 "resolve",
255 "The managed runtime record is invalid. Reinstall Antigravity.",
256 );
257 }
258 const contents = yield* fs.readFileString(filePath);
259 return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(contents);
260 });
261​
262 const executableFile = Effect.fn("AntigravityInstallation.executableFile")(function* (
263 filePath: string,
264 bytes?: number,
265 ) {
266 const info = yield* fs.stat(filePath).pipe(Effect.option);
267 return (
268 Option.isSome(info) &&
269 info.value.type === "File" &&
270 (bytes === undefined || Number(info.value.size) === bytes) &&
271 (platform === "win32" || (info.value.mode & 0o111) !== 0)
272 );
273 });
274​
275 const completedRelease = Effect.fn("AntigravityInstallation.completedRelease")(function* (
276 releaseId: string,
277 ) {
278 const directory = path.join(versionsDirectory, releaseId);
279 const record = yield* readRecord(path.join(directory, RELEASE_RECORD), InstalledRelease);
280 if (
281 record.releaseId !== releaseId ||
282 (record.harness === null
283 ? record.executable.name !== (platform === "win32" ? "agy.exe" : "antigravity")
284 : record.executable.name !== names.executable || record.harness.name !== names.harness) ||
285 !Number.isSafeInteger(record.executable.bytes) ||
286 record.executable.bytes <= 0 ||
287 (record.harness !== null &&
288 (!Number.isSafeInteger(record.harness.bytes) || record.harness.bytes <= 0)) ||
289 !record.version.trim() ||
290 !(yield* executableFile(
291 path.join(directory, record.executable.name),
292 record.executable.bytes,
293 )) ||
294 (record.harness !== null &&
295 !(yield* executableFile(path.join(directory, record.harness.name), record.harness.bytes)))
296 ) {
297 return yield* installationError(
298 "resolve",
299 "The managed Antigravity runtime is incomplete. Reinstall it.",
300 );
301 }
302 return {
303 executablePath: path.join(directory, record.executable.name),
304 harnessPath: record.harness === null ? null : path.join(directory, record.harness.name),
305 source: "managed",
306 version: record.version,
307 managedVersionDirectory: directory,
308 } satisfies AntigravityExecutable;
309 });
310​
311 const fromExternal = Effect.fn("AntigravityInstallation.fromExternal")(function* (
312 candidate: string,
313 source: "override" | "path",
314 ) {
315 if (!(yield* executableFile(candidate))) return null;
316 const executablePath = yield* fs.realPath(candidate);
317 const realVersions = yield* fs.realPath(versionsDirectory).pipe(Effect.option);
318 const directory = path.dirname(executablePath);
319 if (
320 Option.isSome(realVersions) &&
321 path.dirname(directory) === realVersions.value &&
322 /^[a-f0-9]{64}$/u.test(path.basename(directory))
323 ) {
324 const installed = yield* completedRelease(path.basename(directory));
325 return { ...installed, source };
326 }
327 // Keep launcher symlinks intact: version-manager shims dispatch by their invoked name.
328 if (isAntigravityCli(candidate, executablePath)) {
329 return {
330 executablePath: candidate,
331 harnessPath: null,
332 source,
333 version: null,
334 managedVersionDirectory: null,
335 } satisfies AntigravityExecutable;
336 }
337 const harnessPath = path.join(directory, names.harness);
338 if (!(yield* executableFile(harnessPath))) return null;
339 return {
340 executablePath,
341 harnessPath,
342 source,
343 version: null,
344 managedVersionDirectory: null,
345 } satisfies AntigravityExecutable;
346 });
347​
348 const commandPath = (command: string, env: NodeJS.ProcessEnv) =>
349 resolveCommandPath(command, { env }).pipe(
350 Effect.map((resolved) => (resolved === null ? null : path.resolve(resolved))),
351 Effect.provideService(HostProcess.Platform, platform),
352 Effect.provideService(FileSystem.FileSystem, fs),
353 Effect.provideService(Path.Path, path),
354 );
355​
356 // Resolve per PATH entry so an executable without its harness falls through to later entries.
357 const commandCandidates = Effect.fn("AntigravityInstallation.commandCandidates")(function* (
358 command: string,
359 processEnvironment = environment,
360 ) {
361 if (command.includes("/") || command.includes("\\")) {
362 const resolved = yield* commandPath(command, processEnvironment);
363 return resolved === null ? [] : [resolved];
364 }
365 const pathValue =
366 platform === "win32"
367 ? Object.entries(processEnvironment).findLast(([key]) => key.toUpperCase() === "PATH")?.[1]
368 : processEnvironment.PATH;
369 const candidates: string[] = [];
370 for (const directory of (pathValue ?? "").split(platform === "win32" ? ";" : ":")) {
371 const entry = directory.trim().replace(/^"|"$/gu, "");
372 if (entry.length === 0) continue;
373 const resolved = yield* commandPath(command, { ...processEnvironment, PATH: entry });
374 if (resolved !== null) candidates.push(resolved);
375 }
376 return candidates;
377 });
378​
379 const resolve: AntigravityInstallationService["resolve"] = Effect.fn(
380 "AntigravityInstallation.resolve",
381 )(
382 function* (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) {
383 const override = expandHomePath(binaryPath?.trim() ?? "", yield* HostProcess.HomeDirectory);
384 if (override) {
385 for (const candidate of yield* commandCandidates(override, processEnvironment)) {
386 const selected = yield* fromExternal(candidate, "override");
387 if (selected) return selected;
388 }
389 return yield* installationError(
390 "resolve",
391 "The custom Antigravity executable is missing or not executable, or it needs a localharness_external sibling. The agy CLI does not.",
392 );
393 }
394 if (yield* fs.exists(activePath)) {
395 const active = yield* readRecord(activePath, ActiveRelease);
396 const installed = yield* completedRelease(active.releaseId);
397 if (yield* isWithdrawn(installed.version))
398 return yield* installationError(
399 "resolve",
400 `Antigravity ${installed.version} no longer works with this T3 Code release. Update it in Antigravity setup.`,
401 );
402 return installed;
403 }
404 for (const candidate of yield* commandCandidates(names.executable, processEnvironment)) {
405 const selected = yield* fromExternal(candidate, "path");
406 if (selected) return selected;
407 }
408 return yield* installationError(
409 "resolve",
410 supported
411 ? "Antigravity ACP runtime is not installed. Install it in this environment's provider settings or set a custom ACP executable path. The Antigravity app, IDE, and CLI are separate installations."
412 : `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported environment or a custom executable.`,
413 );
414 },
415 Effect.mapError(
416 wrapFailure(
417 "resolve",
418 "Could not read the Antigravity installation. Reinstall it or set a custom executable path.",
419 ),
420 ),
421 );
422​
423 const acquire = (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) =>
424 Effect.acquireRelease(
425 gate.withPermit(
426 Effect.gen(function* () {
427 const executable = yield* resolve(binaryPath, processEnvironment);
428 const directory = executable.managedVersionDirectory;
429 if (directory) leases.set(directory, (leases.get(directory) ?? 0) + 1);
430 return executable;
431 }),
432 ),
433 (executable) =>
434 gate.withPermit(
435 Effect.sync(() => {
436 const directory = executable.managedVersionDirectory;
437 if (!directory) return;
438 const remaining = (leases.get(directory) ?? 1) - 1;
439 if (remaining > 0) leases.set(directory, remaining);
440 else leases.delete(directory);
441 }),
442 ),
443 );
444​
445 const validate =
446 options.validate ??
447 Effect.fn("AntigravityInstallation.validate")(
448 function* (executable: AntigravityExecutable, expectedVersion: string) {
449 const validationScope = yield* Effect.scope;
450 const profileDirectory = yield* Effect.acquireRelease(
451 fs.makeTempDirectory({ prefix: "t3-antigravity-validate-" }),
452 (directory) =>
453 fs.remove(directory, { recursive: true, force: true }).pipe(
454 // Windows may retain DLL handles briefly after taskkill returns.
455 Effect.retry({
456 times: platform === "win32" ? 10 : 0,
457 schedule: Schedule.spaced("100 millis"),
458 while: (error) =>
459 error.reason._tag === "PermissionDenied" ||
460 error.reason._tag === "Busy" ||
461 error.reason._tag === "Unknown",
462 }),
463 Effect.orDie,
464 ),
465 );
466 const profile = yield* prepareAntigravityProfile({
467 profileDirectory,
468 platform,
469 baseEnv: environment,
470 // A shallow scoped profile cleans up unpacked files within Windows' path limit.
471 tempDirectory: profileDirectory,
472 });
473 const runtime = yield* makeAntigravityAcpRuntime({
474 runtimeVersion: expectedVersion,
475 spawn: buildAntigravityAcpSpawnInput({
476 installation: executable,
477 profile,
478 cwd: profileDirectory,
479 baseEnv: environment,
480 }),
481 cwd: profileDirectory,
482 childProcessSpawner:
483 platform === "win32"
484 ? ChildProcessSpawner.make(
485 Effect.fnUntraced(function* (command) {
486 const child = yield* spawner.spawn(command);
487 // Stop PyInstaller's tree before ACP closes pipes; after launcher exit, taskkill cannot reach children holding DLLs open.
488 yield* Scope.addFinalizer(
489 validationScope,
490 child.kill({ forceKillAfter: "1 second" }).pipe(Effect.orDie),
491 );
492 return child;
493 }),
494 )
495 : spawner,
496 });
497 const initialized = yield* runtime.initialize();
498 if (
499 (executable.harnessPath === null
500 ? initialized.agentInfo?.name !== "Antigravity ACP Server"
501 : initialized.agentInfo?.name !== "antigravity-acp") ||
502 initialized.agentInfo?.version !== expectedVersion ||
503 // Antigravity 1.1.1 may report protocol 2 with legacy ACP; choose wire format from the response shape.
504 (initialized.protocolVersion !== 1 && initialized.protocolVersion !== 2) ||
505 initialized.agentCapabilities?.loadSession !== true ||
506 !initialized.agentCapabilities.sessionCapabilities?.resume ||
507 !initialized.agentCapabilities.auth?.logout ||
508 !initialized.authMethods?.some((method) => method.id === "oauth-personal")
509 ) {
510 return yield* installationError(
511 "verify",
512 "The downloaded runtime did not identify as the expected Google Antigravity release.",
513 );
514 }
515 },
516 Effect.provideService(FileSystem.FileSystem, fs),
517 Effect.provideService(Path.Path, path),
518 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
519 Effect.provideService(SecureRandom.SecureRandom, random),
520 Effect.mapError((cause) => {
521 if (isInstallationError(cause)) return cause;
522 const stderr = isAcpProcessExitedError(cause) ? (cause.stderr ?? "") : "";
523 const detail = /\b(?:AF_INET6|enforce_kernel_ipv6_support)\b/.test(stderr)
524 ? "Antigravity requires IPv6 socket support. Enable IPv6 in this environment. On WSL, remove ipv6.disable=1 from .wslconfig and run wsl --shutdown."
525 : /page-size\.cc|Compile-time page size does not match/.test(stderr)
526 ? LARGE_PAGE_KERNEL_MESSAGE
527 : "The downloaded Antigravity runtime could not start in this environment.";
528 return installationError("verify", detail, cause);
529 }),
530 );
531​
532 const install = Effect.fn("AntigravityInstallation.install")(
533 function* (asset: AntigravityReleaseAsset | AntigravityCliReleaseAsset) {
534 yield* ensureEligible(asset.version);
535 const cli = "sha512" in asset;
536 // Existing installation records use 64-character IDs, including native SHA-512 prefixes.
537 const releaseId = cli ? asset.sha512.slice(0, 64) : asset.sha256;
538 const archiveBytes = cli ? null : asset.archiveBytes;
539 yield* resolveNodeExecutable("Antigravity", environment).pipe(
540 Effect.provideService(FileSystem.FileSystem, fs),
541 Effect.provideService(Path.Path, path),
542 Effect.provideService(HostProcess.Platform, platform),
543 Effect.mapError((cause) =>
544 installationError("verify", nodeRuntimeUnavailableMessage("Antigravity"), cause),
545 ),
546 );
547 if (!cli && platform === "linux" && arch === "arm64") {
548 const pageSize = yield* spawner
549 .string(ChildProcess.make("getconf", ["PAGESIZE"], { stdin: "ignore", stderr: "ignore" }))
550 .pipe(
551 Effect.map((output) => Number(output.trim())),
552 Effect.timeout("5 seconds"),
553 Effect.orElseSucceed(() => Number.NaN),
554 );
555 if (pageSize > 4096) {
556 return yield* installationError("download", LARGE_PAGE_KERNEL_MESSAGE);
557 }
558 }
559 const report = (phase: ProviderInstallState["phase"], message: string | null) =>
560 SubscriptionRef.update(state, (current) => ({ ...current, phase, message }));
561 yield* fs.makeDirectory(versionsDirectory, { recursive: true });
562 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true }));
563 const destination = path.join(versionsDirectory, releaseId);
564 const activate = Effect.fn("AntigravityInstallation.activate")(
565 function* () {
566 yield* ensureEligible(asset.version);
567 const pointerDirectory = yield* fs.makeTempDirectoryScoped({
568 directory: managedDirectory,
569 prefix: "active.json.",
570 });
571 const pointerPath = path.join(pointerDirectory, "contents.tmp");
572 yield* fs.writeFileString(pointerPath, yield* encodeActiveRelease({ releaseId }), {
573 flag: "wx",
574 mode: 0o600,
575 });
576 yield* fs.rename(pointerPath, activePath);
577 yield* SubscriptionRef.update(
578 state,
579 (current) =>
580 ({
581 ...current,
582 phase: "succeeded",
583 installedVersion: asset.version,
584 message: null,
585 }) satisfies ProviderInstallState,
586 );
587 },
588 Effect.scoped,
589 Effect.mapError(
590 wrapFailure(
591 "activate",
592 "Could not activate Antigravity. The previous runtime is unchanged. Check for locked files and try again.",
593 ),
594 ),
595 Effect.uninterruptible,
596 );
597​
598 if (yield* fs.exists(destination)) {
599 const existing = yield* completedRelease(releaseId);
600 if (existing.version !== asset.version) {
601 return yield* installationError(
602 "verify",
603 "The existing managed release has the wrong version. Remove it before reinstalling.",
604 );
605 }
606 yield* report("verifying", "Checking the installed runtime.");
607 yield* validate(existing, asset.version).pipe(
608 Effect.scoped,
609 Effect.timeout(VALIDATION_TIMEOUT),
610 );
611 yield* activate();
612 return;
613 }
614​
615 const available = yield* Effect.tryPromise({
616 try: () => NodeFSP.statfs(versionsDirectory, { bigint: true }),
617 catch: (cause) => installationError("download", "Could not check free disk space.", cause),
618 }).pipe(Effect.option);
619 const required = cli
620 ? MAX_DOWNLOAD_BYTES + MAX_EXECUTABLE_BYTES + FREE_SPACE_MARGIN
621 : asset.archiveBytes + asset.executable.bytes + asset.harness.bytes + FREE_SPACE_MARGIN;
622 if (
623 Option.isSome(available) &&
624 available.value.bavail * available.value.bsize < BigInt(required)
625 ) {
626 return yield* installationError(
627 "download",
628 `Antigravity needs at least ${Math.ceil(required / 1024 / 1024)} MiB of free space to install.`,
629 );
630 }
631 const staging = yield* fs.makeTempDirectoryScoped({
632 directory: versionsDirectory,
633 prefix: ".install-",
634 });
635 const archivePath = path.join(staging, "download.zip");
636 const pairDirectory = path.join(staging, "runtime");
637 yield* fs.makeDirectory(pairDirectory);
638 const hash = NodeCrypto.createHash(cli ? "sha512" : "sha256");
639 let downloadedBytes = 0;
640 let lastProgressAt = yield* Clock.currentTimeMillis;
641 yield* Effect.gen(function* () {
642 const response = yield* http
643 .execute(HttpClientRequest.get(asset.url))
644 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
645 // dl.google.com may gzip the zip: content-length is encoded, while byte/hash checks use decoded data.
646 const contentLength = response.headers["content-length"];
647 const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase();
648 const identityBody = contentEncoding === undefined || contentEncoding === "identity";
649 if (
650 identityBody &&
651 contentLength !== undefined &&
652 archiveBytes !== null &&
653 Number(contentLength) !== archiveBytes
654 ) {
655 return yield* installationError(
656 "download",
657 "The Antigravity download size did not match the pinned release.",
658 );
659 }
660 yield* response.stream.pipe(
661 Stream.tap((chunk) =>
662 Effect.gen(function* () {
663 downloadedBytes += chunk.byteLength;
664 if (downloadedBytes > (archiveBytes ?? MAX_DOWNLOAD_BYTES)) {
665 return yield* installationError(
666 "download",
667 "The Antigravity download exceeded its allowed size.",
668 );
669 }
670 hash.update(chunk);
671 const now = yield* Clock.currentTimeMillis;
672 if (now - lastProgressAt >= 250 || downloadedBytes === archiveBytes) {
673 lastProgressAt = now;
674 yield* SubscriptionRef.update(state, (current) => ({
675 ...current,
676 downloadedBytes,
677 }));
678 }
679 }),
680 ),
681 Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })),
682 );
683 }).pipe(
684 Effect.timeout(DOWNLOAD_TIMEOUT),
685 Effect.mapError((cause) =>
686 HttpClientError.isHttpClientError(cause) || Cause.isTimeoutError(cause)
687 ? installationError(
688 "download",
689 "Could not download Antigravity from Google. Check the network connection and any proxy settings, then try again.",
690 cause,
691 )
692 : cause,
693 ),
694 );
695 if (
696 (archiveBytes !== null && downloadedBytes !== archiveBytes) ||
697 hash.digest("hex") !== (cli ? asset.sha512 : asset.sha256)
698 ) {
699 return yield* installationError(
700 "download",
701 "The Antigravity download failed its size or checksum check. Nothing was installed.",
702 );
703 }
704​
705 yield* report("extracting", "Extracting the verified runtime.");
706 if (cli) {
707 const executablePath = path.join(pairDirectory, asset.executable.name);
708 if (asset.format === "executable") {
709 yield* fs.copyFile(archivePath, executablePath);
710 } else {
711 const tar = cliArchiveTarCommand(platform, environment);
712 const list = yield* spawner.string(ChildProcess.make(tar, ["-tzf", archivePath]));
713 const types = yield* spawner.string(ChildProcess.make(tar, ["-tvzf", archivePath]));
714 if (
715 list.trim() !== asset.executable.name ||
716 types.trim().split(/\r?\n/u).length !== 1 ||
717 !types.startsWith("-")
718 ) {
719 return yield* installationError(
720 "extract",
721 "The Antigravity archive must contain one regular executable.",
722 );
723 }
724 yield* Effect.gen(function* () {
725 const child = yield* spawner.spawn(
726 ChildProcess.make(tar, ["-xOzf", archivePath, asset.executable.name], {
727 stdout: "pipe",
728 stderr: "pipe",
729 }),
730 );
731 let extractedBytes = 0;
732 yield* child.stdout.pipe(
733 Stream.tap((chunk) =>
734 Effect.gen(function* () {
735 extractedBytes += chunk.byteLength;
736 if (extractedBytes > MAX_EXECUTABLE_BYTES)
737 return yield* installationError(
738 "extract",
739 "The Antigravity executable exceeded its size limit.",
740 );
741 }),
742 ),
743 Stream.run(fs.sink(executablePath, { flag: "wx", mode: 0o700 })),
744 );
745 if ((yield* child.exitCode) !== 0 || extractedBytes === 0)
746 return yield* installationError(
747 "extract",
748 "Could not extract the Antigravity executable.",
749 );
750 }).pipe(Effect.scoped);
751 }
752 } else
753 yield* Effect.gen(function* () {
754 const archive = yield* openZipArchive(archivePath, (detail, cause) =>
755 installationError("extract", detail, cause),
756 );
757 if (archive.entryCount !== 2) {
758 return yield* installationError(
759 "extract",
760 "The archive must contain exactly the Antigravity executable and its harness.",
761 );
762 }
763 const seen = new Set<string>();
764 for (;;) {
765 const entry = yield* archive.next;
766 if (!entry) break;
767 const expected = [asset.executable, asset.harness].find(
768 (file) => file.name === entry.fileName,
769 );
770 const unixType = (entry.externalFileAttributes >>> 16) & 0o170000;
771 if (
772 !expected ||
773 seen.has(entry.fileName) ||
774 entry.fileName.includes("/") ||
775 entry.fileName.includes("\\") ||
776 (unixType !== 0 && unixType !== 0o100000) ||
777 (entry.externalFileAttributes & 0x10) !== 0 ||
778 (entry.generalPurposeBitFlag & 1) !== 0 ||
779 ![0, 8].includes(entry.compressionMethod) ||
780 entry.uncompressedSize !== expected.bytes
781 ) {
782 return yield* installationError(
783 "extract",
784 "The archive contains an unexpected, unsafe, or incorrectly sized member.",
785 );
786 }
787 seen.add(entry.fileName);
788 yield* Effect.gen(function* () {
789 const readable = yield* archive.streamEntry(entry);
790 let extractedBytes = 0;
791 yield* EffectNodeStream.fromReadable<Uint8Array, AntigravityInstallationError>({
792 evaluate: () => readable,
793 onError: wrapFailure("extract", "Could not extract the Antigravity runtime."),
794 }).pipe(
795 Stream.tap((chunk) =>
796 Effect.gen(function* () {
797 extractedBytes += chunk.byteLength;
798 if (extractedBytes > expected.bytes) {
799 return yield* installationError(
800 "extract",
801 "An archive member exceeded its pinned size.",
802 );
803 }
804 }),
805 ),
806 Stream.run(
807 fs.sink(path.join(pairDirectory, entry.fileName), { flag: "wx", mode: 0o700 }),
808 ),
809 );
810 if (extractedBytes !== expected.bytes) {
811 return yield* installationError("extract", "An archive member was truncated.");
812 }
813 }).pipe(Effect.scoped);
814 }
815 if (!seen.has(asset.executable.name) || !seen.has(asset.harness.name)) {
816 return yield* installationError(
817 "extract",
818 "The archive is missing the Antigravity executable or its harness.",
819 );
820 }
821 }).pipe(Effect.scoped);
822 yield* fs.remove(archivePath);
823 const executablePath = path.join(pairDirectory, asset.executable.name);
824 const harnessPath = cli ? null : path.join(pairDirectory, asset.harness.name);
825 if (platform !== "win32") {
826 yield* fs.chmod(executablePath, 0o755);
827 if (harnessPath !== null) yield* fs.chmod(harnessPath, 0o755);
828 }
829 if (!cli && platform === "darwin" && arch === "x64") {
830 // Google signs the Intel build with the hardened runtime but no JIT entitlement, so
831 // libffi's writable+executable trampolines spin forever. Re-sign ad hoc after the hash check.
832 const exitCode = yield* spawner
833 .spawn(
834 ChildProcess.make(
835 "/usr/bin/codesign",
836 [
837 "--force",
838 "--sign",
839 "-",
840 executablePath,
841 ...(harnessPath === null ? [] : [harnessPath]),
842 ],
843 { stdout: "ignore", stderr: "ignore" },
844 ),
845 )
846 .pipe(
847 Effect.flatMap((child) => child.exitCode),
848 Effect.scoped,
849 );
850 if (exitCode !== 0) {
851 return yield* installationError(
852 "extract",
853 "Could not prepare the Antigravity runtime for this Mac. Check that codesign is available.",
854 );
855 }
856 }
857 yield* report("verifying", "Checking the downloaded runtime.");
858 yield* validate(
859 {
860 executablePath,
861 harnessPath,
862 source: "managed",
863 version: asset.version,
864 managedVersionDirectory: pairDirectory,
865 },
866 asset.version,
867 ).pipe(Effect.scoped, Effect.timeout(VALIDATION_TIMEOUT));
868 const record: InstalledRelease = {
869 releaseId,
870 version: asset.version,
871 executable: {
872 name: asset.executable.name,
873 bytes: Number((yield* fs.stat(executablePath)).size),
874 },
875 harness:
876 cli || harnessPath === null
877 ? null
878 : { name: asset.harness.name, bytes: Number((yield* fs.stat(harnessPath)).size) },
879 };
880 yield* fs.writeFileString(
881 path.join(pairDirectory, RELEASE_RECORD),
882 yield* encodeInstalledRelease(record),
883 { flag: "wx", mode: 0o600 },
884 );
885 yield* fs.rename(pairDirectory, destination).pipe(
886 Effect.catch((cause) =>
887 completedRelease(releaseId).pipe(
888 Effect.flatMap((existing) =>
889 existing.version === asset.version
890 ? validate(existing, asset.version).pipe(
891 Effect.scoped,
892 Effect.timeout(VALIDATION_TIMEOUT),
893 )
894 : Effect.fail(
895 installationError(
896 "activate",
897 "Another installation published a different Antigravity release.",
898 ),
899 ),
900 ),
901 Effect.mapError(() =>
902 installationError(
903 "activate",
904 "Could not publish the Antigravity runtime. The previous release is unchanged. Try again.",
905 cause,
906 ),
907 ),
908 ),
909 ),
910 );
911 yield* activate();
912 },
913 Effect.scoped,
914 Effect.mapError(
915 wrapFailure(
916 "install",
917 "Could not install Antigravity. Check free disk space and directory access, then try again.",
918 ),
919 ),
920 );
921​
922 let lastUpdateCheck: number | undefined;
923 const refreshRelease = Effect.fn("AntigravityInstallation.refreshRelease")(function* (
924 explicit = false,
925 ) {
926 if (options.releaseAsset !== undefined || !supported) return;
927 if (!explicit && !(yield* options.updateChecksEnabled ?? Effect.succeed(true))) return;
928 const now = yield* Clock.currentTimeMillis;
929 if (!explicit && lastUpdateCheck !== undefined && now - lastUpdateCheck < UPDATE_CHECK_INTERVAL)
930 return;
931 lastUpdateCheck = now;
932 const musl =
933 platform === "linux" &&
934 (yield* fs.exists(
935 arch === "arm64" ? "/lib/libc.musl-aarch64.so.1" : "/lib/libc.musl-x86_64.so.1",
936 ));
937 const target = antigravityCliTarget(platform, arch, musl);
938 const latest = yield* http.get(`${ANTIGRAVITY_CLI_MANIFEST_BASE}/${target}.json`).pipe(
939 Effect.flatMap(HttpClientResponse.filterStatusOk),
940 Effect.flatMap((response) => response.json),
941 Effect.map((json) => parseAntigravityCliRelease(json, platform, arch, musl)),
942 Effect.timeout("10 seconds"),
943 Effect.orElseSucceed(() => null),
944 );
945 if (latest === null) return;
946 if (yield* isWithdrawn(latest.version)) {
947 releaseAsset = null;
948 return;
949 }
950 releaseAsset = latest;
951 yield* SubscriptionRef.updateEffect(state, (value) =>
952 isRunning(value)
953 ? Effect.succeed(value)
954 : Effect.gen(function* () {
955 const keepInstalled =
956 value.installedVersion !== null &&
957 compareSemverVersions(value.installedVersion, latest.version) > 0 &&
958 !(yield* isWithdrawn(value.installedVersion));
959 return {
960 ...value,
961 version: keepInstalled ? value.installedVersion : latest.version,
962 totalBytes: null,
963 };
964 }),
965 );
966 });
967​
968 const start = Effect.gen(function* () {
969 const current = yield* SubscriptionRef.get(state);
970 if (isRunning(current)) return current;
971 yield* refreshRelease(true);
972 return yield* gate.withPermit(
973 Effect.gen(function* () {
974 const current = yield* SubscriptionRef.get(state);
975 if (isRunning(current)) return current;
976 if (!supported) {
977 return yield* installationError(
978 "start",
979 `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`,
980 );
981 }
982 const asset =
983 releaseAsset !== null && !(yield* isWithdrawn(releaseAsset.version))
984 ? releaseAsset
985 : null;
986 const installed = yield* resolve().pipe(Effect.option);
987 const reuse =
988 Option.isSome(installed) &&
989 installed.value.source === "managed" &&
990 installed.value.harnessPath === null &&
991 (asset === null || compareSemverVersions(installed.value.version!, asset.version) > 0)
992 ? installed.value
993 : undefined;
994 if (asset === null && reuse === undefined)
995 return yield* installationError(
996 "start",
997 "Could not find a native Antigravity CLI release. Check the network connection and try again.",
998 );
999 const operationId = yield* random.uuidV4();
1000 const next: ProviderInstallState = {
1001 driver: DRIVER,
1002 operationId,
1003 phase: reuse === undefined ? "downloading" : "verifying",
1004 downloadedBytes: 0,
1005 totalBytes: asset !== null && "archiveBytes" in asset ? asset.archiveBytes : null,
1006 version: reuse?.version ?? asset?.version ?? null,
1007 installedVersion: current.installedVersion,
1008 canRemove: current.canRemove,
1009 message:
1010 reuse === undefined
1011 ? "Downloading Google's official Antigravity runtime."
1012 : "Checking the installed runtime.",
1013 };
1014 yield* SubscriptionRef.set(state, next);
1015 const work = (
1016 reuse === undefined
1017 ? install(asset!)
1018 : validate(reuse, reuse.version!).pipe(
1019 Effect.scoped,
1020 Effect.timeout(VALIDATION_TIMEOUT),
1021 Effect.andThen(ensureEligible(reuse.version!)),
1022 Effect.andThen(
1023 SubscriptionRef.update(state, (value) => ({
1024 ...value,
1025 phase: "succeeded" as const,
1026 installedVersion: reuse.version,
1027 message: null,
1028 })),
1029 ),
1031 ).pipe(
1032 Effect.mapError(
1033 wrapFailure("verify", "Could not verify the installed Antigravity runtime."),
1034 ),
1035 Effect.onExit((exit) =>
1036 Exit.isFailure(exit)
1037 ? SubscriptionRef.update(state, (value) => {
1038 if (value.operationId !== operationId || value.phase === "succeeded")
1039 return value;
1040 const error = Cause.findErrorOption(exit.cause);
1041 const cancelled = Cause.hasInterruptsOnly(exit.cause);
1042 return {
1043 ...value,
1044 phase: cancelled ? "cancelled" : "failed",
1045 message: cancelled
1046 ? "Installation cancelled. The previous runtime is unchanged."
1047 : Option.isSome(error)
1048 ? error.value.detail
1049 : "Could not finish the Antigravity installation. Check disk space and directory access.",
1050 } satisfies ProviderInstallState;
1051 })
1052 : Effect.void,
1053 ),
1054 Effect.ignoreCause,
1055 Effect.ensuring(
1056 Effect.sync(() => {
1057 if (running?.operationId === operationId) running = undefined;
1058 }),
1059 ),
1060 );
1061 const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope);
1062 running = { operationId, fiber };
1063 return next;
1064 }).pipe(Effect.uninterruptible),
1065 );
1066 }).pipe(Effect.mapError(wrapFailure("start", "Could not start the Antigravity installation.")));
1067​
1068 const cancel = Effect.fn("AntigravityInstallation.cancel")(function* (operationId: string) {
1069 return yield* gate.withPermit(
1070 Effect.gen(function* () {
1071 const current = yield* SubscriptionRef.get(state);
1072 if (current.operationId !== operationId) {
1073 return yield* installationError(
1074 "cancel",
1075 "This installation is no longer current. Refresh its status before cancelling.",
1076 );
1078 if (running?.operationId === operationId && isRunning(current)) {
1079 yield* Fiber.interrupt(running.fiber);
1081 return yield* SubscriptionRef.get(state);
1082 }),
1083 );
1084 });
1085​
1086 const remove = Effect.fn("AntigravityInstallation.remove")(
1087 function* (protectedBinaryPaths: ReadonlyArray<string> = []) {
1088 yield* gate.withPermit(
1089 Effect.gen(function* () {
1090 if (isRunning(yield* SubscriptionRef.get(state)) || leases.size > 0) {
1091 return yield* installationError(
1092 "remove",
1093 "Stop Antigravity sessions and sign-in flows before removing its managed runtime.",
1094 );
1096 const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option);
1097 if (Option.isSome(realManaged)) {
1098 for (const binaryPath of protectedBinaryPaths) {
1099 if (!binaryPath.trim()) continue;
1100 const selected = yield* resolve(binaryPath).pipe(Effect.option);
1101 if (Option.isSome(selected) && selected.value.managedVersionDirectory) {
1102 return yield* installationError(
1103 "remove",
1104 "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.",
1105 );
1107 const resolved = yield* fs.realPath(binaryPath).pipe(Effect.option);
1108 const candidate = Option.getOrElse(resolved, () => path.resolve(binaryPath));
1109 if (candidate.startsWith(`${realManaged.value}${path.sep}`)) {
1110 return yield* installationError(
1111 "remove",
1112 "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.",
1113 );
1117 yield* fs.remove(managedDirectory, { recursive: true, force: true });
1118 yield* SubscriptionRef.update(
1119 state,
1120 (current) =>
1121 ({
1122 ...current,
1123 operationId: null,
1124 phase: "idle",
1125 downloadedBytes: 0,
1126 installedVersion: null,
1127 canRemove: false,
1128 message: null,
1129 }) satisfies ProviderInstallState,
1130 );
1131 }).pipe(Effect.uninterruptible),
1132 );
1133 },
1134 Effect.mapError(
1135 wrapFailure(
1136 "remove",
1137 "Could not remove the managed Antigravity runtime. Check for open processes and try again.",
1138 ),
1139 ),
1140 );
1141​
1142 yield* Effect.gen(function* () {
1143 const canRemove = yield* fs.exists(managedDirectory);
1144 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove }));
1145 if (!(yield* fs.exists(activePath))) return;
1146 const active = yield* readRecord(activePath, ActiveRelease);
1147 const installed = yield* completedRelease(active.releaseId);
1148 yield* SubscriptionRef.update(state, (current) => ({
1149 ...current,
1150 installedVersion: installed.version,
1151 }));
1152 }).pipe(
1153 Effect.catch(() =>
1154 SubscriptionRef.update(
1155 state,
1156 (current) =>
1157 ({
1158 ...current,
1159 phase: "failed",
1160 message: "The managed Antigravity runtime is incomplete. Remove it and reinstall.",
1161 }) satisfies ProviderInstallState,
1162 ),
1163 ),
1164 );
1165​
1166 return AntigravityInstallation.of({
1167 managedDirectory,
1168 resolve,
1169 acquire,
1170 start,
1171 cancel,
1172 state: SubscriptionRef.get(state),
1173 changes: SubscriptionRef.changes(state),
1174 checkForUpdate: refreshRelease().pipe(Effect.ignore),
1175 remove,
1176 });
1177});