Skip to content
Open
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
19 changes: 19 additions & 0 deletions ANTIGRAVITY-INTEGRATION-REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Google Antigravity ACP integration

Updated 24 September 2026. After testing an installed-CLI bridge, the user chose Google's official ACP server. The CLI's headless mode soft-denied tool actions that needed approval. The official server speaks ACP directly, allowing Pipper to display and answer permission requests. [Google's Zed setup](https://antigravity.google/docs/ide/extensions/zed/), [ACP registry entry](https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json), [CLI headless permissions](https://antigravity.google/docs/cli/headless/).

## Implementation

- Pipper fetches the pinned Google ACP server version 1.2.1 on first use into a versioned user cache. The application bundle does not contain the Google server. macOS ARM64 and Windows x64 correspond to Pipper's current packaged targets; each archive has a release-pinned SHA-256 and exact expected file list. Installation uses a temporary directory, checksum verification, and atomic promotion. Concurrent callers share the install, with a cross-process lock. The macOS archive is 107 MiB compressed and includes the server and its `localharness_external` sibling.
- The existing ACP connection lifecycle, session routing, transcript rendering, MCP attachment, and client tool handlers now talk directly to Google's server. The first-party CLI bridge and npm adapter launch are removed. Permission requests enter Pipper's existing approval UI. An unanswered approval now cancels after two minutes instead of auto-allowing.
- Pipper offers the server's advertised Google OAuth, enterprise OAuth, Gemini API-key, and agent-platform authentication methods in setup. The CLI's cached credentials did not authenticate the ACP server in the local handshake, so users must complete ACP sign-in. The agent decides how each method obtains credentials; Pipper does not read credential files.
- Antigravity restoration errors preserve the thread's existing Pipper snapshot and session ID rather than silently starting a replacement. Prior CLI-bridge session IDs are explicitly identified as incompatible with the official server. A new Antigravity thread is required to continue those conversations.
- The app and public agent setup copy now describe the official server rather than the installed CLI.

## Verification and limits

The real macOS ARM64 server archive was downloaded and its SHA-256 recorded. A local install exercised checksum verification, extraction of both required files, atomic cache promotion, and a repeated cache hit. The verified files were moved to this machine's Pipper cache. The official executable negotiated ACP protocol version 1, advertised load/resume and four auth methods, and returned `auth_required` for `session/new` before ACP sign-in. Pipper's actual handshake probe classified this as `needs-auth` and returned those methods.

The earlier authenticated text-prompt test applied to the CLI bridge and does not prove an authenticated official-server prompt. A user must complete ACP sign-in to validate live prompts and interactive tool approvals. Windows installation and permission behavior require a Windows runtime check. Existing npm-adapter and CLI-bridge sessions remain displayable in Pipper but are not imported into Google's server.

Repository checks after the switch: app build, Electron TypeScript check, lint, React Doctor (100/100), and the full 443-test suite passed. The marketing site build was unavailable in this worktree because its separate `astro` dependency is not installed.
271 changes: 203 additions & 68 deletions electron/agent-connection-manager.ts

Large diffs are not rendered by default.

177 changes: 177 additions & 0 deletions electron/agents/antigravity-official.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { mkdtemp, mkdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import {
antigravityCacheRoot,
antigravityRelease,
ensureAntigravityInstalled,
type AntigravityRelease,
} from "./antigravity-official.ts";
import { getAgentDescriptor, resolveAgentSpawn } from "./registry.ts";

const originalPlatform = process.platform;
let temporary: string | null = null;
afterEach(async () => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
Object.defineProperty(process, "platform", { value: originalPlatform });
if (temporary) await rm(temporary, { recursive: true, force: true });
temporary = null;
});

function zipAvailable(): boolean {
try {
execFileSync("zip", ["-v"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}

/** Build a real zip fixture with the given entries, then return its bytes. */
function makeFixtureZip(directory: string, files: Record<string, string>): Buffer {
for (const [name, content] of Object.entries(files)) {
writeFileSync(join(directory, name), content);
}
execFileSync("zip", ["-q", "fixture.zip", ...Object.keys(files)], { cwd: directory });
return readFileSync(join(directory, "fixture.zip"));
}

function fixtureRelease(archive: Buffer): AntigravityRelease {
return {
archive: "https://dl.google.com/agy-extensions/releases/fixture.zip",
sha256: createHash("sha256").update(archive).digest("hex"),
executable: "agy_acp_server.exe",
files: ["agy_acp_server.exe", "localharness_external.exe"],
args: [],
};
}

function stubArchiveFetch(archive: Buffer) {
const fetch = vi.fn(async () => {
const response = new Response(new Uint8Array(archive));
Object.defineProperty(response, "url", {
value: "https://dl.google.com/agy-extensions/releases/fixture.zip",
});
return response;
});
vi.stubGlobal("fetch", fetch);
return fetch;
}

test.skipIf(!antigravityRelease())(
"reuses a verified cached server without a download",
async () => {
Comment on lines +66 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Installation success remains untested These tests cover a fabricated cache hit and a bad checksum, but not successful Windows extraction or sign-in followed by session/new in a fresh server process. Both paths are needed by the supported Windows target and the new setup flow; without success-path coverage, archive-layout and cross-process sign-in regressions can go undetected.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

const release = antigravityRelease()!;
temporary = await mkdtemp(join(tmpdir(), "pipper-agy-acp-test-"));
vi.stubEnv("PIPPER_ACP_AGENT_CACHE", temporary);
const root = antigravityCacheRoot();
await mkdir(root, { recursive: true });
const sizes: Record<string, number> = {};
for (const file of release.files) {
await writeFile(join(root, file), "fixture");
sizes[file] = (await stat(join(root, file))).size;
}
await writeFile(
join(root, "install.json"),
JSON.stringify({ version: "1.2.1", sha256: release.sha256, files: sizes }),
);
const fetch = vi.fn(() => Promise.reject(new Error("Unexpected download")));
vi.stubGlobal("fetch", fetch);
const executable = await ensureAntigravityInstalled();
expect(executable).toBe(join(root, release.executable));
expect(resolveAgentSpawn(getAgentDescriptor("antigravity-acp")!).command).toBe(executable);
expect(fetch).not.toHaveBeenCalled();
},
);

test.skipIf(!antigravityRelease())("repairs a cache whose executable was truncated", async () => {
const release = antigravityRelease()!;
temporary = await mkdtemp(join(tmpdir(), "pipper-agy-acp-test-"));
vi.stubEnv("PIPPER_ACP_AGENT_CACHE", temporary);
const root = antigravityCacheRoot();
await mkdir(root, { recursive: true });
const sizes: Record<string, number> = {};
for (const file of release.files) {
await writeFile(join(root, file), "fixture");
sizes[file] = (await stat(join(root, file))).size;
}
await writeFile(
join(root, "install.json"),
JSON.stringify({ version: "1.2.1", sha256: release.sha256, files: sizes }),
);
// A present-but-damaged executable must not pass the cache check: the
// truncated file fails the recorded size and triggers a reinstall.
await writeFile(join(root, release.executable), "trunc");
const fetch = vi.fn(() => Promise.reject(new Error("Unexpected download")));
vi.stubGlobal("fetch", fetch);
await expect(ensureAntigravityInstalled()).rejects.toThrow("Unexpected download");
expect(fetch).toHaveBeenCalledTimes(1);
});

test.skipIf(!antigravityRelease())(
"reclaims a stale installer lock instead of waiting it out",
async () => {
temporary = await mkdtemp(join(tmpdir(), "pipper-agy-acp-test-"));
vi.stubEnv("PIPPER_ACP_AGENT_CACHE", temporary);
const root = antigravityCacheRoot();
await mkdir(join(root, ".."), { recursive: true });
const lockPath = `${root}.lock`;
await writeFile(lockPath, "dead-installer-nonce");
const stale = new Date(Date.now() - 10 * 60_000);
await utimes(lockPath, stale, stale);
const fetch = vi.fn(() => Promise.reject(new Error("Unexpected download")));
vi.stubGlobal("fetch", fetch);
// Reaching the download proves the stale lock was reclaimed; otherwise the
// installer would sit on its 6-minute wait.
await expect(ensureAntigravityInstalled()).rejects.toThrow("Unexpected download");
expect(fetch).toHaveBeenCalledTimes(1);
},
);

test.skipIf(process.platform !== "darwin" || !zipAvailable())(
"installs through the Windows tar layout",
async () => {
// Exercise the win32 branch (tar -tf / tar -xOf, no chmod) with macOS
// bsdtar, which reads the same zip archive the Windows release ships.
Object.defineProperty(process, "platform", { value: "win32" });
temporary = await mkdtemp(join(tmpdir(), "pipper-agy-acp-test-"));
vi.stubEnv("PIPPER_ACP_AGENT_CACHE", temporary);
const fixtureDir = join(temporary, "fixture");
await mkdir(fixtureDir, { recursive: true });
const archive = makeFixtureZip(fixtureDir, {
"agy_acp_server.exe": "server-binary",
"localharness_external.exe": "harness-binary",
});
const release = fixtureRelease(archive);
const fetch = stubArchiveFetch(archive);

const executable = await ensureAntigravityInstalled({ release });
const root = antigravityCacheRoot();
expect(executable).toBe(join(root, "agy_acp_server.exe"));
expect(await readFile(executable, "utf8")).toBe("server-binary");
expect(await readFile(join(root, "localharness_external.exe"), "utf8")).toBe("harness-binary");
// The promoted cache carries per-file sizes and is reused without a fetch.
const marker = JSON.parse(await readFile(join(root, "install.json"), "utf8"));
expect(marker.files["agy_acp_server.exe"]).toBe("server-binary".length);
await expect(ensureAntigravityInstalled({ release })).resolves.toBe(executable);
expect(fetch).toHaveBeenCalledTimes(1);
},
);

test.skipIf(!antigravityRelease())("rejects an archive with the wrong checksum", async () => {
temporary = await mkdtemp(join(tmpdir(), "pipper-agy-acp-test-"));
vi.stubEnv("PIPPER_ACP_AGENT_CACHE", temporary);
const fetch = vi.fn(async () => {
const response = new Response("not the pinned archive");
Object.defineProperty(response, "url", { value: antigravityRelease()!.archive });
return response;
});
vi.stubGlobal("fetch", fetch);
await expect(ensureAntigravityInstalled()).rejects.toThrow("checksum");
expect(fetch).toHaveBeenCalledTimes(1);
});
Loading
Loading