Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 85 additions & 3 deletions apps/server/src/provider/AntigravityInstallation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {},
) {
Expand All @@ -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),
Expand All @@ -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
? {}
: {
Expand All @@ -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,
Expand Down Expand Up @@ -740,18 +749,38 @@ 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",
});
yield* fs.remove(externalHarness);
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();
Expand Down Expand Up @@ -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([]);
}),
);
});
36 changes: 27 additions & 9 deletions apps/server/src/provider/AntigravityInstallation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
) {
Expand All @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions apps/server/src/provider/antigravityRelease.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
29 changes: 28 additions & 1 deletion apps/server/src/provider/antigravityRelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AntigravityReleaseArch> = {
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<string, AntigravityReleaseAsset>([
Expand Down Expand Up @@ -75,9 +92,19 @@ const releaseAssets = new Map<string, AntigravityReleaseAsset>([
],
]);

/** 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;
}