diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index e2bab831712a..4b15ec4a23ef 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -22,6 +22,8 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { makeAntigravityInstallation, @@ -130,13 +132,18 @@ interface HarnessOptions { readonly contentLength?: number; readonly contentEncoding?: string; readonly platform?: NodeJS.Platform; + readonly arch?: NodeJS.Architecture; + readonly hostMachine?: string; readonly path?: string; readonly previous?: boolean; readonly fileSystem?: FileSystem.FileSystem; readonly validate?: AntigravityInstallationOptions["validate"]; readonly useDefaultValidation?: boolean; + /** When false, resolve the pinned host archive instead of the fixture zip. */ + readonly pinRelease?: boolean; } +/** Build an isolated Antigravity installer fixture, optionally pinning the host archive. */ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* ( options: HarnessOptions = {}, ) { @@ -145,9 +152,10 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* ( const baseDir = options.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-test-" })); const platform = options.platform ?? hostPlatform; + const arch = options.arch ?? "x64"; const archive = options.archive ?? completeArchive; const asset = options.asset === undefined ? releaseAsset(archive, platform) : options.asset; - const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-x64`); + const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-${arch}`); if (options.previous) { yield* writeRelease(managedDirectory, { ...releaseAsset(archive, platform), @@ -172,7 +180,8 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* ( }); const installation = yield* makeAntigravityInstallation({ baseDir, - releaseAsset: asset, + hostMachine: options.hostMachine ?? arch, + ...(options.pinRelease === false ? {} : { releaseAsset: asset }), ...(options.useDefaultValidation ? {} : { @@ -184,7 +193,7 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* ( }).pipe( Effect.provideService(FileSystem.FileSystem, trackedFs), Effect.provideService(HostProcessPlatform, platform), - Effect.provideService(HostProcessArchitecture, "x64"), + Effect.provideService(HostProcessArchitecture, arch), Effect.provideService(HostProcessEnvironment, { PATH: options.path ?? "" }), Effect.provideService( HttpClient.HttpClient, @@ -740,6 +749,18 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { source: "override", managedVersionDirectory: null, }); + expect(yield* installation.resolve(externalDirectory)).toMatchObject({ + executablePath: externalExecutable, + source: "override", + managedVersionDirectory: null, + }); + const relativeFromHome = NodePath.relative(NodeOS.homedir(), externalDirectory); + expect( + yield* installation.resolve(`~/${relativeFromHome.split(NodePath.sep).join("/")}`), + ).toMatchObject({ + executablePath: externalExecutable, + source: "override", + }); expect(yield* installation.resolve(executableName)).toMatchObject({ source: "override", }); @@ -747,11 +768,19 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { expect(yield* installation.resolve(externalExecutable).pipe(Effect.flip)).toMatchObject({ operation: "resolve", }); + expect(yield* installation.resolve(externalDirectory).pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); expect( yield* installation.resolve(path.join(baseDir, "missing")).pipe(Effect.flip), ).toMatchObject({ operation: "resolve", }); + const emptyDirectory = path.join(baseDir, "empty"); + yield* fs.makeDirectory(emptyDirectory); + expect(yield* installation.resolve(emptyDirectory).pipe(Effect.flip)).toMatchObject({ + operation: "resolve", + }); yield* expectPreviousRelease(installation); yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 }); yield* installation.remove(); @@ -931,4 +960,57 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { expect(requests).toEqual([]); }), ); + + it.effect("pins the official linux-arm64 archive on arm64 hosts without downloading", () => + Effect.gen(function* () { + const { installation, requests, path, baseDir } = yield* makeHarness({ + pinRelease: false, + platform: "linux", + arch: "arm64", + }); + expect(yield* installation.state).toMatchObject({ + phase: "idle", + totalBytes: 656_572_786, + version: "agy_acp_server_1.1.1", + }); + expect(installation.managedDirectory).toBe( + path.join(baseDir, "tools", "antigravity-acp", "linux-arm64"), + ); + expect(requests).toEqual([]); + }), + ); + + it.effect("prefers the aarch64 host machine over an x64 Node compile arch", () => + Effect.gen(function* () { + const { installation, path, baseDir } = yield* makeHarness({ + pinRelease: false, + platform: "linux", + arch: "x64", + hostMachine: "aarch64", + }); + expect(yield* installation.state).toMatchObject({ + totalBytes: 656_572_786, + version: "agy_acp_server_1.1.1", + }); + expect(installation.managedDirectory).toBe( + path.join(baseDir, "tools", "antigravity-acp", "linux-arm64"), + ); + }), + ); + + it.effect("refuses linux hosts with no published CPU archive without downloading", () => + Effect.gen(function* () { + const { installation, requests } = yield* makeHarness({ + pinRelease: false, + platform: "linux", + arch: "ia32", + }); + expect(yield* installation.start.pipe(Effect.flip)).toMatchObject({ + operation: "start", + detail: expect.stringMatching(/does not publish an Antigravity runtime for linux-ia32/u), + }); + expect(yield* installation.state).toMatchObject({ phase: "idle", operationId: null }); + expect(requests).toEqual([]); + }), + ); }); diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 24eb4e3d6df8..bd9ffdef6963 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -27,16 +27,19 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as NodeCrypto from "node:crypto"; import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; import type * as NodeStream from "node:stream"; import * as Yauzl from "yauzl"; import { ServerConfig } from "../config.ts"; +import { expandHomePathWith } from "../pathExpansion.ts"; import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts"; import { buildAntigravityAcpSpawnInput, prepareAntigravityProfile, } from "./antigravityAuthSupport.ts"; import { + antigravityReleaseArch, resolveAntigravityReleaseAsset, type AntigravityReleaseAsset, } from "./antigravityRelease.ts"; @@ -120,6 +123,8 @@ export class AntigravityInstallation extends Context.Service< export interface AntigravityInstallationOptions { readonly baseDir: string; readonly releaseAsset?: AntigravityReleaseAsset | null; + /** uname -m when known. Defaults to the host machine so aarch64 wins over an x64 Node. */ + readonly hostMachine?: string; readonly validate?: ( executable: AntigravityExecutable, expectedVersion: string, @@ -263,6 +268,7 @@ const openArchive = Effect.fn("AntigravityInstallation.openArchive")(function* ( return { entryCount: opened.zip.entryCount, next, streamEntry }; }); +/** Own the environment Antigravity runtime, using the host machine arch for managed installs. */ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.make")(function* ( options: AntigravityInstallationOptions, ) { @@ -275,16 +281,18 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; const environment = yield* HostProcessEnvironment; + const hostMachine = options.hostMachine ?? NodeOS.machine(); + const releaseArch = antigravityReleaseArch(hostMachine) ?? antigravityReleaseArch(arch) ?? arch; const releaseAsset = options.releaseAsset === undefined - ? resolveAntigravityReleaseAsset(platform, arch) + ? resolveAntigravityReleaseAsset(platform, arch, hostMachine) : options.releaseAsset; const names = executableNames(platform); const managedDirectory = path.join( options.baseDir, "tools", "antigravity-acp", - `${platform}-${arch}`, + `${platform}-${releaseArch}`, ); const versionsDirectory = path.join(managedDirectory, "versions"); const activePath = path.join(managedDirectory, "active.json"); @@ -362,12 +370,20 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma } satisfies AntigravityExecutable; }); + /** Resolve a custom path, including a directory that contains the ACP executable. */ const fromExternal = Effect.fn("AntigravityInstallation.fromExternal")(function* ( candidate: string, source: "override" | "path", ) { - if (!(yield* executableFile(candidate))) return null; - const executablePath = yield* fs.realPath(candidate); + // Manual CDN extracts are often a directory. Health checks resolve the + // ACP file inside it instead of treating the folder as missing. + const info = yield* fs.stat(candidate).pipe(Effect.option); + const executableCandidate = + Option.isSome(info) && info.value.type === "Directory" + ? path.join(candidate, names.executable) + : candidate; + if (!(yield* executableFile(executableCandidate))) return null; + const executablePath = yield* fs.realPath(executableCandidate); const directory = path.dirname(executablePath); const harnessPath = path.join(directory, names.harness); if (!(yield* executableFile(harnessPath))) return null; @@ -401,16 +417,18 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma .map((directory) => path.resolve(directory, binary)); }; + /** Find a managed, PATH, or custom Antigravity executable, expanding `~` in overrides. */ const resolve: AntigravityInstallationService["resolve"] = Effect.fn( "AntigravityInstallation.resolve", )( function* (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) { const override = binaryPath?.trim(); if (override) { + const expanded = expandHomePathWith(override, path); const candidates = - path.isAbsolute(override) || override.includes("/") || override.includes("\\") - ? [path.resolve(override)] - : pathCandidates(override, processEnvironment); + path.isAbsolute(expanded) || expanded.includes("/") || expanded.includes("\\") + ? [path.resolve(expanded)] + : pathCandidates(expanded, processEnvironment); for (const candidate of candidates) { const selected = yield* fromExternal(candidate, "override"); if (selected) return selected; @@ -432,7 +450,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma "resolve", releaseAsset ? "Antigravity is not installed. Install it in this environment or set a custom executable path." - : `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported environment or a custom executable.`, + : `Google does not publish an Antigravity runtime for ${platform}-${releaseArch}. Use a supported environment or a custom executable.`, ); }, Effect.mapError( @@ -798,7 +816,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma if (!releaseAsset) { return yield* installationError( "start", - `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`, + `Google does not publish an Antigravity runtime for ${platform}-${releaseArch}. Use a supported remote environment or a custom executable.`, ); } const operationId = yield* crypto.randomUUIDv4; diff --git a/apps/server/src/provider/antigravityRelease.test.ts b/apps/server/src/provider/antigravityRelease.test.ts new file mode 100644 index 000000000000..9b281df9b2b3 --- /dev/null +++ b/apps/server/src/provider/antigravityRelease.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { antigravityReleaseArch, resolveAntigravityReleaseAsset } from "./antigravityRelease.ts"; + +describe("antigravityRelease", () => { + it("maps Node, uname, and ACP-registry CPU names onto the pinned asset keys", () => { + expect(antigravityReleaseArch("arm64")).toBe("arm64"); + expect(antigravityReleaseArch("aarch64")).toBe("arm64"); + expect(antigravityReleaseArch("AARCH64")).toBe("arm64"); + expect(antigravityReleaseArch("x64")).toBe("x64"); + expect(antigravityReleaseArch("x86_64")).toBe("x64"); + expect(antigravityReleaseArch("amd64")).toBe("x64"); + expect(antigravityReleaseArch("ia32")).toBeNull(); + expect(antigravityReleaseArch("arm")).toBeNull(); + }); + + it("selects the linux-arm64 runtime for Node arm64 and uname aarch64", () => { + const arm64 = resolveAntigravityReleaseAsset("linux", "arm64"); + const aarch64 = resolveAntigravityReleaseAsset("linux", "aarch64"); + expect(arm64?.url).toContain("linux-arm64.zip"); + expect(aarch64).toEqual(arm64); + expect(aarch64?.archiveBytes).toBe(656_572_786); + }); + + it("prefers the host machine when Node was compiled for a different CPU", () => { + const fromX64NodeOnArm = resolveAntigravityReleaseAsset("linux", "x64", "aarch64"); + expect(fromX64NodeOnArm).toEqual(resolveAntigravityReleaseAsset("linux", "arm64")); + const fromArmNodeOnX64 = resolveAntigravityReleaseAsset("linux", "arm64", "x86_64"); + expect(fromArmNodeOnX64).toEqual(resolveAntigravityReleaseAsset("linux", "x64")); + }); + + it("does not invent an archive for unpublished CPUs", () => { + expect(resolveAntigravityReleaseAsset("linux", "ia32")).toBeNull(); + expect(resolveAntigravityReleaseAsset("linux", "arm")).toBeNull(); + expect(resolveAntigravityReleaseAsset("freebsd", "arm64")).toBeNull(); + }); +}); diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index 90ba309c10c6..87f8047de098 100644 --- a/apps/server/src/provider/antigravityRelease.ts +++ b/apps/server/src/provider/antigravityRelease.ts @@ -15,6 +15,23 @@ export interface AntigravityReleaseAsset { }; } +/** Asset-map CPU names. Node uses arm64/x64; uname and the ACP registry use aarch64/x86_64. */ +export type AntigravityReleaseArch = "arm64" | "x64"; + +const RELEASE_ARCH_ALIASES: Record = { + arm64: "arm64", + aarch64: "arm64", + arm64e: "arm64", + x64: "x64", + x86_64: "x64", + amd64: "x64", +}; + +/** Maps Node, uname, and ACP-registry CPU names onto the pinned `${platform}-${arch}` keys. */ +export function antigravityReleaseArch(arch: string): AntigravityReleaseArch | null { + return RELEASE_ARCH_ALIASES[arch.trim().toLowerCase()] ?? null; +} + // URLs come from the official registry. Hashes and sizes were checked on 2026-09-03. // https://github.com/agentclientprotocol/registry/blob/81bf71b55e15f630c4fb8a86d20d3088071d2071/antigravity-acp/agent.json const releaseAssets = new Map([ @@ -75,9 +92,19 @@ const releaseAssets = new Map([ ], ]); +/** Pick the pinned Antigravity archive, preferring the host machine CPU over Node's compile arch. */ export function resolveAntigravityReleaseAsset( platform: NodeJS.Platform, arch: string, + hostMachine: string = arch, ): AntigravityReleaseAsset | null { - return releaseAssets.get(`${platform}-${arch}`) ?? null; + // Prefer the host machine (uname -m / registry aarch64) when it has a pin, + // then the Node compile arch, so linux aarch64 never falls through to x64. + for (const candidate of [hostMachine, arch]) { + const normalized = antigravityReleaseArch(candidate); + if (!normalized) continue; + const asset = releaseAssets.get(`${platform}-${normalized}`); + if (asset) return asset; + } + return null; }