From f916391129a73e44196e7f9f82c4290ab69fd8d8 Mon Sep 17 00:00:00 2001 From: priyamkarn Date: Tue, 7 Jul 2026 23:22:23 +0530 Subject: [PATCH 1/2] Fix Antigravity hook wrapper tests for platform-specific exit behavior --- apps/cli/main.ts | 2 +- libs/install/platforms/antigravity.ts | 117 +++++++++++++++++++++++--- libs/install/platforms/opencode.ts | 3 +- scripts/check-antigravity-hooks.js | 84 ++++++++++++++++++ 4 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 scripts/check-antigravity-hooks.js diff --git a/apps/cli/main.ts b/apps/cli/main.ts index e18df82..05efeac 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -149,7 +149,7 @@ const cliCommands = [ { key: "hookIngest", path: ["hook", "ingest"], - usage: "hook ingest --platform codex|claude|copilot|opencode|openhands|factory-droid", + usage: "hook ingest --platform codex|claude|copilot|opencode|openhands|factory-droid|antigravity", handler: runHookIngest, }, { diff --git a/libs/install/platforms/antigravity.ts b/libs/install/platforms/antigravity.ts index 30299b1..117c8dc 100644 --- a/libs/install/platforms/antigravity.ts +++ b/libs/install/platforms/antigravity.ts @@ -1,5 +1,7 @@ +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { readJsonObject, writeJson } from "../hook-config.js"; import { copyBundledSkills } from "../skills.js"; import type { PlatformInstallContext, PlatformInstallResult, PlatformInstaller, WorkingMemoryUpdateInput } from "./types.js"; @@ -13,23 +15,68 @@ function antigravityHome(): string { return process.env.ANTIGRAVITY_HOME ?? join(homedir(), ".gemini", "antigravity-cli"); } +// Confirmed from a Google Cloud Community deep-dive with tested working +// examples (not just prose docs): Antigravity CLI hooks live in a +// project-local .agents/hooks.json, namespaced per tool at the top level +// (so multiple tools -- cmux, claude-mem, greplica -- can share one file +// without clobbering each other), e.g.: +// +// { "greplica": { "Stop": [{ "hooks": [{ "type": "command", "command": "", "timeout": 30 }] }] } } +// +// Two things this differs from the shared mergeHookConfig()/hooks.json shape +// every other installer uses: +// 1. No single top-level "hooks" key -- each tool's entries live under its +// own name at the root instead. +// 2. "command" must be an absolute path to a directly-executable script +// file, not a shell command string with arguments -- so we generate a +// small wrapper script here rather than writing an inline command. +// +// Scope of this first pass: only the Stop event. It's the one event that's +// consistent across every source found (official docs, real production +// integrations, live bug reports) with zero contradictions, and it's +// confirmed to have no veto power (unlike PreToolUse-style gating events, +// where a failing hook can block a tool call or, per one source, an entire +// session). SessionStart is deliberately left out: some real hooks.json +// dumps show it, but Google's own documented event list doesn't include it, +// and that contradiction isn't worth resolving for what Greplica needs -- +// Stop alone is enough to trigger the background working-memory update. +const antigravityHookNamespace = "greplica"; + export const antigravityInstaller: PlatformInstaller = { platform: "antigravity", - // Antigravity CLI hooks are configured via a workspace-local - // .agents/hooks.json and, for tool-approval events like PreToolUse, drive - // decisions through a JSON stdin / JSON stdout allow-deny contract -- - // materially different from the command + exit-code hooks.json shape the - // other installers share via mergeHookConfig(). Whether a plain - // fire-and-forget SessionStart/Stop hook (what Greplica actually needs) - // uses that same contract or something simpler isn't confirmed from - // documentation available at the time this was written. Rather than - // guess and risk writing a hooks.json that breaks a user's Antigravity - // setup, this installs skills only for now, the same approach already - // taken for OpenCode. Someone running Antigravity CLI day to day is best - // placed to confirm the real hook contract and extend this. - install(_context: PlatformInstallContext): PlatformInstallResult { + install(context: PlatformInstallContext): PlatformInstallResult { + const skills = copyBundledSkills(join(antigravityHome(), "skills")); + if (!context.hooks) return { skills }; + + // process.argv[1] is the currently-running greplica entrypoint -- the + // same self-reference install.ts already uses to relaunch itself for + // the embedding prewarm. Without it we can't build an absolute command, + // so hooks are skipped rather than writing something broken. + const scriptPath = process.argv[1]; + if (scriptPath === undefined) return { skills }; + + const wrapperPath = writeStopHookScript(context.repoRoot, process.execPath, scriptPath); + const command = `${process.execPath} ${scriptPath} hook ingest --platform antigravity`; + + const hookConfigPath = join(context.repoRoot, ".agents", "hooks.json"); + const config = readJsonObject(hookConfigPath); + config[antigravityHookNamespace] = { + Stop: [ + { + hooks: [{ type: "command", command: wrapperPath, timeout: 30 }], + }, + ], + }; + writeJson(hookConfigPath, config); + return { - skills: copyBundledSkills(join(antigravityHome(), "skills")), + skills, + hooks: { + platform: "antigravity", + configFiles: [hookConfigPath, wrapperPath], + events: ["Stop"], + command, + }, }; }, sessionSourceRef(_sessionId: string): string { @@ -45,3 +92,45 @@ export const antigravityInstaller: PlatformInstaller = { throw new Error("Antigravity background working-memory updates are not supported yet."); }, }; + +// Antigravity spawns the "command" path directly rather than interpreting a +// shell string, so the greplica CLI invocation (which needs a node +// executable + script path + args) has to live inside a small wrapper +// script instead of being written inline. The wrapper always exits 0 +// regardless of what the inner command does: Stop hooks aren't +// veto-capable in Antigravity as far as every source agrees, but there's no +// reason to risk it -- a real, documented cmux bug shows exactly what +// happens when a hook that was never supposed to gate anything propagates +// a non-zero exit code into Antigravity's hook runner. +function writeStopHookScript(repoRoot: string, execPath: string, scriptPath: string): string { + const hooksDir = join(repoRoot, ".agents", "hooks"); + mkdirSync(hooksDir, { recursive: true }); + + const isWindows = process.platform === "win32"; + const wrapperPath = join(hooksDir, isWindows ? "greplica-stop.cmd" : "greplica-stop.sh"); + + const content = isWindows + ? [ + "@echo off", + "rem Greplica Stop hook for Antigravity CLI. Always exits 0 -- Stop has no", + "rem veto power in Antigravity, and a failing side-effect must never surface", + "rem as a hook error to the user.", + `"${execPath}" "${scriptPath}" hook ingest --platform antigravity >nul 2>&1`, + "exit /b 0", + "", + ].join("\r\n") + : [ + "#!/usr/bin/env bash", + "# Greplica Stop hook for Antigravity CLI. Always exits 0 -- Stop has no", + "# veto power in Antigravity, and a failing side-effect must never surface", + "# as a hook error to the user.", + `"${execPath}" "${scriptPath}" hook ingest --platform antigravity >/dev/null 2>&1`, + "exit 0", + "", + ].join("\n"); + + writeFileSync(wrapperPath, content, "utf8"); + if (!isWindows) chmodSync(wrapperPath, 0o755); + + return wrapperPath; +} diff --git a/libs/install/platforms/opencode.ts b/libs/install/platforms/opencode.ts index 85c0b8d..3623029 100644 --- a/libs/install/platforms/opencode.ts +++ b/libs/install/platforms/opencode.ts @@ -5,7 +5,8 @@ import Database from "better-sqlite3"; import { hookCommand, hookEvents, mergeHookConfig, readJsonObject, writeJson } from "../hook-config.js"; import { copyBundledSkills } from "../skills.js"; import { runOpenCodeAgent } from "../../agent-runner/opencode.js"; -import { hookSessionId, type HookInput } from "../../hooks/hook-input.js"; +import { hookSessionId } from "../../hooks/hook-input.js"; +import type { HookInput } from "../../hooks/types.js"; import { isRecord, parseJsonLine, diff --git a/scripts/check-antigravity-hooks.js b/scripts/check-antigravity-hooks.js new file mode 100644 index 0000000..971ca6c --- /dev/null +++ b/scripts/check-antigravity-hooks.js @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +const { installPlatform } = await import(new URL("dist/libs/install/platforms/index.js", root)); + +const tmp = mkdtempSync(join(tmpdir(), "greplica-antigravity-hooks-test-")); +process.env.ANTIGRAVITY_HOME = join(tmp, "antigravity-home"); +const repoRoot = join(tmp, "repo"); + +const result = installPlatform("antigravity", { repoRoot, hooks: true }); + +assert.ok(result.hooks, "expected hooks to be installed when hooks: true"); +assert.deepEqual(result.hooks.events, ["Stop"]); +assert.equal(result.hooks.configFiles.length, 2); + +const hooksJsonPath = join(repoRoot, ".agents", "hooks.json"); +const wrapperPath = result.hooks.configFiles[1]; +assert.ok(existsSync(hooksJsonPath), "expected .agents/hooks.json to be written"); +assert.ok(existsSync(wrapperPath), "expected the wrapper script to be written"); + +const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, "utf8")); +assert.ok(hooksConfig.greplica, "expected a top-level greplica namespace"); +assert.equal(hooksConfig.greplica.Stop.length, 1); +assert.equal(hooksConfig.greplica.Stop[0].hooks[0].command, wrapperPath); +assert.equal(typeof hooksConfig.greplica.Stop[0].hooks[0].timeout, "number"); + +// Re-running install must not duplicate entries under our own namespace. +installPlatform("antigravity", { repoRoot, hooks: true }); +const afterReinstall = JSON.parse(readFileSync(hooksJsonPath, "utf8")); +assert.equal(afterReinstall.greplica.Stop.length, 1, "reinstall must not duplicate Stop entries"); +assert.equal(afterReinstall.greplica.Stop[0].hooks.length, 1, "reinstall must not duplicate hook handlers"); + +// Other tools' namespaces in the same shared hooks.json must survive untouched. +const withOtherTool = JSON.parse(readFileSync(hooksJsonPath, "utf8")); +withOtherTool.cmux = { PreToolUse: [{ hooks: [{ type: "command", command: "/some/other/tool.sh" }] }] }; +writeFileSync(hooksJsonPath, JSON.stringify(withOtherTool, null, 2)); +installPlatform("antigravity", { repoRoot, hooks: true }); +const afterOtherToolPresent = JSON.parse(readFileSync(hooksJsonPath, "utf8")); +assert.deepEqual(afterOtherToolPresent.cmux, withOtherTool.cmux, "another tool's namespace must be preserved untouched"); +assert.equal(afterOtherToolPresent.greplica.Stop.length, 1); + +// The wrapper script must exit 0 even when the command it wraps fails -- +// Stop hooks are documented as non-veto-capable, but the wrapper should +// never depend on that alone (mirrors a real, documented failure mode from +// another tool's Antigravity integration: a hook that propagated a +// non-zero exit code ended up blocking every tool call). +// +// Structural check: the script's last real instruction must unconditionally +// exit 0, regardless of what came before it. +const wrapperLines = readFileSync(wrapperPath, "utf8").trimEnd().split("\n"); +const expectedLastLine = process.platform === "win32" ? "exit /b 0" : "exit 0"; +assert.equal(wrapperLines[wrapperLines.length - 1], expectedLastLine, "wrapper must unconditionally exit 0 as its last instruction"); + +// Behavioral check: build a wrapper using the exact same template, but +// pointing at a command that deliberately fails, and confirm it still +// exits 0 end-to-end. Built per-platform since Windows has no bash/chmod. +const isWindows = process.platform === "win32"; + +const failingInner = join(tmp, isWindows ? "failing-inner-command.cmd" : "failing-inner-command.sh"); +writeFileSync( + failingInner, + isWindows ? "@echo off\r\nmore >nul\r\nexit /b 1\r\n" : "#!/usr/bin/env bash\ncat >/dev/null\nexit 1\n", + "utf8", +); +if (!isWindows) spawnSync("chmod", ["+x", failingInner]); + +const testWrapperPath = join(tmp, isWindows ? "test-wrapper-with-failing-inner.cmd" : "test-wrapper-with-failing-inner.sh"); +writeFileSync( + testWrapperPath, + isWindows + ? `@echo off\r\ncall "${failingInner}" >nul 2>&1\r\nexit /b 0\r\n` + : ["#!/usr/bin/env bash", `"${failingInner}" >/dev/null 2>&1`, "exit 0", ""].join("\n"), + "utf8", +); +if (!isWindows) spawnSync("chmod", ["+x", testWrapperPath]); + +const wrapperRun = spawnSync(testWrapperPath, [], { input: "{}", encoding: "utf8", shell: isWindows }); +assert.equal(wrapperRun.status, 0, "wrapper must exit 0 even when the wrapped command fails"); + +console.log("Antigravity hook installer checks passed."); \ No newline at end of file From 5017c0f4b6743697c5cc38967ff2497e205c8ca7 Mon Sep 17 00:00:00 2001 From: priyamkarn Date: Tue, 7 Jul 2026 23:42:10 +0530 Subject: [PATCH 2/2] Add antigravity hook check to test script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7cce4b1..57089f8 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "smoke:openhands": "npm run build && node scripts/smoke-openhands-install.mjs", "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", - "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js", + "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-antigravity-hooks.js", "test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js", "test:repo-context": "npm run build && node scripts/check-repo-context.js", "eval:bootstrap-current": "npm run build && node dist/evals/cases/bootstrap-current-repo-at-8038fe8/run.js",