From 42a978767f82ee7e5524110e6dde06cbede3b0b4 Mon Sep 17 00:00:00 2001 From: Daniel Rosales Date: Tue, 21 Jul 2026 03:21:05 -0500 Subject: [PATCH] fix(engram): make Antigravity registration recoverable --- internal/components/engram/inject.go | 239 ++++++++++++--- internal/components/engram/inject_test.go | 275 +++++++++++++++++- internal/components/golden_test.go | 4 +- testdata/golden/engram-antigravity-mcp.golden | 3 +- 4 files changed, 469 insertions(+), 52 deletions(-) diff --git a/internal/components/engram/inject.go b/internal/components/engram/inject.go index 2522235c9..ecb087998 100644 --- a/internal/components/engram/inject.go +++ b/internal/components/engram/inject.go @@ -1,7 +1,9 @@ package engram import ( + "bytes" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -129,17 +131,11 @@ func engramOverlayJSON(agentID model.AgentID, cmd string) []byte { }, } } else { - args := []string{"mcp", "--tools=agent"} - if agentID == model.AgentAntigravity { - // Antigravity should launch the default Engram MCP server without - // narrowing the exposed tool set. - args = []string{"mcp"} - } cfg = map[string]any{ "mcpServers": map[string]any{ "engram": map[string]any{ "command": cmd, - "args": args, + "args": []string{"mcp", "--tools=agent"}, }, }, } @@ -262,36 +258,198 @@ func ensureJSONFileIfMissing(path string) (filemerge.WriteResult, error) { return filemerge.WriteFileAtomic(path, []byte("{}\n"), 0o644) } -func installAntigravityEngramPlugin(homeDir, engramCommand string) (bool, []string, error) { - pluginDir := filepath.Join(homeDir, ".gemini", "antigravity-cli", "plugins", "gentle-ai-engram") - files := make([]string, 0, 3) - changed := false +type fileImage struct { + data []byte + exists bool +} + +func isActiveAntigravityManifest(image fileImage) bool { + if !image.exists { + return false + } + var manifest map[string]json.RawMessage + if err := json.Unmarshal(image.data, &manifest); err != nil || manifest == nil { + return false + } + var name string + return json.Unmarshal(manifest["name"], &name) == nil && name == "gentle-ai-engram" +} + +var antigravityWriteFile = filemerge.WriteFileAtomic +var antigravityReadFile = os.ReadFile + +func readImage(path string) (fileImage, error) { + b, err := antigravityReadFile(path) + if os.IsNotExist(err) { + return fileImage{}, nil + } + return fileImage{data: b, exists: err == nil}, err +} + +func sameImage(a, b fileImage) bool { return a.exists == b.exists && bytes.Equal(a.data, b.data) } + +func writeReconciled(path string, before fileImage, desired []byte) (bool, string, error) { + r, err := antigravityWriteFile(path, desired, 0o644) + if err == nil { + return r.Changed, "post-replacement", nil + } + after, readErr := readImage(path) + if readErr != nil { + return false, "unknown", errors.Join(err, fmt.Errorf("reread %q after atomic-write error: %w", path, readErr)) + } + state := "unknown" + if sameImage(after, before) { + state = "pre-replacement" + } else if after.exists && bytes.Equal(after.data, desired) { + state = "post-replacement" + } + return !sameImage(before, after), state, fmt.Errorf("atomic write %q failed; observed %s: %w", path, state, err) +} - pluginPath := filepath.Join(pluginDir, "plugin.json") - pluginWrite, err := filemerge.WriteFileAtomic(pluginPath, []byte(antigravityEngramPluginJSON), 0o644) +func antigravityConfigs(globalPath, pluginPath string, manifestActive bool) (fileImage, []byte, string, bool, error) { + global, err := readImage(globalPath) if err != nil { - return false, nil, fmt.Errorf("write Antigravity Engram plugin manifest: %w", err) + return global, nil, "", false, fmt.Errorf("read Antigravity global config %q: %w", globalPath, err) + } + var root map[string]json.RawMessage + if global.exists && json.Unmarshal(global.data, &root) != nil { + return global, nil, "", false, fmt.Errorf("parse Antigravity global config %q", globalPath) + } + if global.exists && root == nil { + return global, nil, "", false, fmt.Errorf("parse Antigravity global config %q: expected object", globalPath) + } + servers := map[string]json.RawMessage{} + if raw, ok := root["mcpServers"]; ok && json.Unmarshal(raw, &servers) != nil { + return global, nil, "", false, fmt.Errorf("parse Antigravity global mcpServers %q", globalPath) + } + if servers == nil { + return global, nil, "", false, fmt.Errorf("parse Antigravity global mcpServers %q: expected object", globalPath) } - changed = changed || pluginWrite.Changed - files = append(files, pluginPath) + _, migrate := servers["engram"] + legacy, _ := existingMergedEngramCommand(global.data, model.AgentAntigravity) + delete(servers, "engram") + if migrate { + root["mcpServers"], _ = json.Marshal(servers) + } + globalDesired, _ := json.MarshalIndent(root, "", " ") + globalDesired = append(globalDesired, '\n') - pluginMCPPath := filepath.Join(pluginDir, "mcp_config.json") - mcpWrite, err := filemerge.WriteFileAtomic(pluginMCPPath, engramOverlayJSON(model.AgentAntigravity, engramCommand), 0o644) + plugin, err := readImage(pluginPath) if err != nil { - return false, nil, fmt.Errorf("write Antigravity Engram plugin MCP config: %w", err) + return global, nil, "", false, fmt.Errorf("read Antigravity plugin config %q: %w", pluginPath, err) + } + root, servers = map[string]json.RawMessage{}, map[string]json.RawMessage{} + if plugin.exists && json.Unmarshal(plugin.data, &root) != nil { + return global, nil, "", false, fmt.Errorf("parse Antigravity plugin config %q", pluginPath) + } + if plugin.exists && root == nil { + return global, nil, "", false, fmt.Errorf("parse Antigravity plugin config %q: expected object", pluginPath) + } + if raw, ok := root["mcpServers"]; ok && json.Unmarshal(raw, &servers) != nil { + return global, nil, "", false, fmt.Errorf("parse Antigravity plugin mcpServers %q", pluginPath) + } + if servers == nil { + return global, nil, "", false, fmt.Errorf("parse Antigravity plugin mcpServers %q: expected object", pluginPath) } - changed = changed || mcpWrite.Changed - files = append(files, pluginMCPPath) + cmd, ok := existingMergedEngramCommand(plugin.data, model.AgentAntigravity) + if !manifestActive && legacy != "" { + cmd = legacy + } else if !ok { + cmd = legacy + } + if cmd == "" { + cmd = preferredStableEngramCommand() + } + cmd = stableEngramCommandForExisting(cmd, model.AgentAntigravity) + servers["engram"], _ = json.Marshal(map[string]any{"command": cmd, "args": []string{"mcp", "--tools=agent"}}) + root["mcpServers"], _ = json.Marshal(servers) + pluginDesired, _ := json.MarshalIndent(root, "", " ") + return global, append(pluginDesired, '\n'), string(globalDesired), migrate, nil +} - hooksPath := filepath.Join(pluginDir, "hooks.json") - hooksWrite, err := filemerge.WriteFileAtomic(hooksPath, antigravityEngramHooksJSON(), 0o644) +func installAntigravityEngramPlugin(homeDir string, adapter agents.Adapter) (bool, []string, error) { + globalPath := adapter.MCPConfigPath(homeDir, "engram") + pluginDir := filepath.Join(filepath.Dir(globalPath), "plugins", "gentle-ai-engram") + manifestPath, mcpPath := filepath.Join(pluginDir, "plugin.json"), filepath.Join(pluginDir, "mcp_config.json") + manifestBefore, err := readImage(manifestPath) if err != nil { - return false, nil, fmt.Errorf("write Antigravity Engram hooks: %w", err) + return false, nil, fmt.Errorf("read Antigravity manifest %q: %w", manifestPath, err) + } + manifestBeforeActive := isActiveAntigravityManifest(manifestBefore) + globalBefore, pluginMCP, globalDesired, migrate, err := antigravityConfigs(globalPath, mcpPath, manifestBeforeActive) + if err != nil { + return false, nil, err + } + files := []string{mcpPath, filepath.Join(pluginDir, "hooks.json"), adapter.SettingsPath(homeDir), manifestPath} + changed := false + for i, content := range [][]byte{pluginMCP, antigravityEngramHooksJSON(), []byte("{}\n")} { + before, readErr := readImage(files[i]) + if readErr != nil { + return false, nil, fmt.Errorf("read staging target %q: %w", files[i], readErr) + } + if i == 2 && before.exists { + continue + } + wrote, _, writeErr := writeReconciled(files[i], before, content) + changed = changed || wrote + if writeErr != nil { + observed := fmt.Errorf("observed global active=%v at %q, manifest active=%v at %q", migrate, globalPath, manifestBeforeActive, manifestPath) + if !migrate && !manifestBeforeActive { + observed = errors.Join(observed, fmt.Errorf("no active registration; manual recovery required")) + } + return false, nil, errors.Join(writeErr, observed) + } + } + + var primary error + if migrate { + wrote, state, writeErr := writeReconciled(globalPath, globalBefore, []byte(globalDesired)) + changed = changed || wrote + if writeErr != nil { + if state == "pre-replacement" { + return false, nil, errors.Join(writeErr, fmt.Errorf("manifest %q active=%v", manifestPath, manifestBeforeActive)) + } + primary = writeErr + } + files = append(files, globalPath) } - changed = changed || hooksWrite.Changed - files = append(files, hooksPath) - return changed, files, nil + manifestDesired := []byte(antigravityEngramPluginJSON) + wrote, _, activateErr := writeReconciled(manifestPath, manifestBefore, manifestDesired) + changed = changed || wrote + if activateErr == nil { + return changed, files, primary + } + primary = errors.Join(primary, activateErr) + manifestAfter, observeErr := readImage(manifestPath) + if observeErr != nil { + primary = errors.Join(primary, fmt.Errorf("observe manifest %q after activation failure: %w", manifestPath, observeErr)) + } else if isActiveAntigravityManifest(manifestAfter) { + return false, nil, errors.Join(primary, fmt.Errorf("plugin-only registration observed at %q", manifestPath)) + } + if !migrate { + return false, nil, errors.Join(primary, fmt.Errorf("manifest inactive and no prior global registration; manual recovery required")) + } + currentGlobal, readErr := readImage(globalPath) + if readErr != nil { + return false, nil, errors.Join(primary, fmt.Errorf("observe global %q before rollback: %w; manual recovery required", globalPath, readErr)) + } + _, rollbackState, rollbackErr := writeReconciled(globalPath, currentGlobal, globalBefore.data) + if rollbackErr == nil || rollbackState == "post-replacement" { + return false, nil, errors.Join(primary, rollbackErr, fmt.Errorf("global registration restored exactly at %q", globalPath)) + } + if rollbackState == "pre-replacement" { + currentManifest, readErr := readImage(manifestPath) + if readErr != nil { + return false, nil, errors.Join(primary, rollbackErr, fmt.Errorf("observe manifest %q before roll-forward: %w; manual recovery required", manifestPath, readErr)) + } + _, rollState, rollErr := writeReconciled(manifestPath, currentManifest, manifestDesired) + if rollErr == nil || rollState == "post-replacement" { + return false, nil, errors.Join(primary, rollbackErr, rollErr, fmt.Errorf("rollback failed; converged plugin-only at %q", manifestPath)) + } + rollbackErr = errors.Join(rollbackErr, rollErr) + } + return false, nil, errors.Join(primary, rollbackErr, fmt.Errorf("unknown state at global %q and manifest %q; manual recovery required", globalPath, manifestPath)) } func injectWithOptions(configHomeDir, promptDir string, adapter agents.Adapter, opts InjectOptions) (InjectionResult, error) { @@ -349,6 +507,15 @@ func injectWithOptions(configHomeDir, promptDir string, adapter agents.Adapter, if mcpPath == "" { break } + if adapter.Agent() == model.AgentAntigravity { + pluginChanged, pluginFiles, pluginErr := installAntigravityEngramPlugin(configHomeDir, adapter) + if pluginErr != nil { + return InjectionResult{}, pluginErr + } + changed = changed || pluginChanged + files = append(files, pluginFiles...) + break + } engramCommand := stableEngramCommandForMergedConfig(mcpPath, adapter.Agent()) var overlay []byte if adapter.Agent() == model.AgentVSCodeCopilot { @@ -364,22 +531,6 @@ func injectWithOptions(configHomeDir, promptDir string, adapter agents.Adapter, changed = changed || mcpWrite.Changed files = append(files, mcpPath) - if adapter.Agent() == model.AgentAntigravity { - settingsWrite, settingsErr := ensureJSONFileIfMissing(adapter.SettingsPath(configHomeDir)) - if settingsErr != nil { - return InjectionResult{}, fmt.Errorf("ensure Antigravity settings: %w", settingsErr) - } - changed = changed || settingsWrite.Changed - files = append(files, adapter.SettingsPath(configHomeDir)) - - pluginChanged, pluginFiles, pluginErr := installAntigravityEngramPlugin(configHomeDir, engramCommand) - if pluginErr != nil { - return InjectionResult{}, pluginErr - } - changed = changed || pluginChanged - files = append(files, pluginFiles...) - } - case model.StrategyMergeIntoYAML: // Hermes: upsert the engram MCP server block under mcp_servers: in // ~/.hermes/config.yaml via the comment-preserving YAML string helpers. @@ -854,7 +1005,7 @@ func isVersionedHomebrewCellarPath(path string) bool { func isStableHomebrewEngramPath(path string) bool { clean := filepath.ToSlash(filepath.Clean(path)) - return (clean == "/opt/homebrew/bin/engram" || clean == "/usr/local/bin/engram") && isEngramCommand(clean) + return (clean == "/opt/homebrew/bin/engram" || clean == "/usr/local/bin/engram" || clean == "/home/linuxbrew/.linuxbrew/bin/engram") && isEngramCommand(clean) } // resolveProfileAssignments builds the []codex.ProfileAssignment slice used diff --git a/internal/components/engram/inject_test.go b/internal/components/engram/inject_test.go index e33e4885d..62bf06637 100644 --- a/internal/components/engram/inject_test.go +++ b/internal/components/engram/inject_test.go @@ -1,6 +1,7 @@ package engram import ( + "bytes" "encoding/json" "fmt" "os" @@ -19,6 +20,7 @@ import ( "github.com/gentleman-programming/gentle-ai/internal/agents/pi" "github.com/gentleman-programming/gentle-ai/internal/agents/qwen" "github.com/gentleman-programming/gentle-ai/internal/agents/vscode" + "github.com/gentleman-programming/gentle-ai/internal/components/filemerge" "github.com/gentleman-programming/gentle-ai/internal/model" ) @@ -573,7 +575,7 @@ func TestInjectAntigravityWritesMCPToCLIConfig(t *testing.T) { t.Fatalf("Inject(antigravity) changed = false") } - cliMCPPath := filepath.Join(home, ".gemini", "antigravity-cli", "mcp_config.json") + cliMCPPath := filepath.Join(home, ".gemini", "antigravity-cli", "plugins", "gentle-ai-engram", "mcp_config.json") content, err := os.ReadFile(cliMCPPath) if err != nil { t.Fatalf("ReadFile(%q) error = %v", cliMCPPath, err) @@ -582,8 +584,8 @@ func TestInjectAntigravityWritesMCPToCLIConfig(t *testing.T) { if !strings.Contains(text, `"args": [`) || !strings.Contains(text, `"mcp"`) { t.Fatalf("Antigravity MCP config must launch Engram MCP; got:\n%s", text) } - if strings.Contains(text, `--tools=`) { - t.Fatalf("Antigravity should use Engram's default MCP invocation without tool-profile flags; got:\n%s", text) + if !strings.Contains(text, `--tools=agent`) { + t.Fatalf("Antigravity should expose agent tools; got:\n%s", text) } pluginPath := filepath.Join(home, ".gemini", "antigravity-cli", "plugins", "gentle-ai-engram", "plugin.json") @@ -597,8 +599,8 @@ func TestInjectAntigravityWritesMCPToCLIConfig(t *testing.T) { t.Fatalf("ReadFile(%q) error = %v", pluginMCPPath, err) } pluginMCPText := string(pluginMCPContent) - if !strings.Contains(pluginMCPText, `"mcp"`) || strings.Contains(pluginMCPText, `--tools=`) { - t.Fatalf("Antigravity Engram plugin MCP config should expose default Engram MCP tools; got:\n%s", pluginMCPText) + if !strings.Contains(pluginMCPText, `"mcp"`) || !strings.Contains(pluginMCPText, `--tools=agent`) { + t.Fatalf("Antigravity Engram plugin MCP config should expose agent tools; got:\n%s", pluginMCPText) } hooksPath := filepath.Join(home, ".gemini", "antigravity-cli", "plugins", "gentle-ai-engram", "hooks.json") @@ -660,6 +662,269 @@ func TestInjectAntigravityInitializesEmptySettingsWhenGeminiMissing(t *testing.T } } +func TestInjectAntigravityConvergesToSelectedPlugin(t *testing.T) { + for _, variant := range []string{"antigravity-cli", "antigravity-desktop"} { + t.Run(variant, func(t *testing.T) { + home := t.TempDir() + mockEngramLookPath(t, "/home/linuxbrew/.linuxbrew/bin/engram", "") + dir := filepath.Join(home, ".gemini", variant) + if variant == "antigravity-desktop" { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + global := filepath.Join(dir, "mcp_config.json") + original := `{"theme":"dark","mcpServers":{"other":{"command":"other"},"engram":{"command":"/home/linuxbrew/.linuxbrew/Cellar/engram/1.2.3/bin/engram"}}}` + writeFile(t, global, original) + plugin := filepath.Join(dir, "plugins", "gentle-ai-engram", "mcp_config.json") + writeFile(t, plugin, `{"custom":true,"mcpServers":{"sibling":{"command":"sibling"},"engram":{"command":"/home/linuxbrew/.linuxbrew/Cellar/engram/1.0/bin/engram","env":{"OLD":"1"}}}}`) + settings := filepath.Join(dir, "settings.json") + writeFile(t, settings, `{"keep":true}`) + if _, err := Inject(home, antigravityAdapter()); err != nil { + t.Fatal(err) + } + g, p := readJSONFile(t, global), readJSONFile(t, plugin) + if g["theme"] != "dark" { + t.Fatalf("global root fields lost: %v", g) + } + gs := g["mcpServers"].(map[string]any) + if _, ok := gs["engram"]; ok || gs["other"] == nil { + t.Fatalf("global servers not preserved/migrated: %v", gs) + } + if p["custom"] != true || p["mcpServers"].(map[string]any)["sibling"] == nil { + t.Fatalf("plugin fields lost: %v", p) + } + assertNestedString(t, p, "/home/linuxbrew/.linuxbrew/bin/engram", "mcpServers", "engram", "command") + assertNestedStrings(t, p, []string{"mcp", "--tools=agent"}, "mcpServers", "engram", "args") + if _, ok := p["mcpServers"].(map[string]any)["engram"].(map[string]any)["env"]; ok { + t.Fatal("stale Engram fields preserved") + } + if got, _ := os.ReadFile(settings); string(got) != `{"keep":true}` { + t.Fatalf("settings changed: %s", got) + } + if second, err := Inject(home, antigravityAdapter()); err != nil || second.Changed { + t.Fatalf("second Inject = %+v, %v", second, err) + } + }) + } +} + +func TestInjectAntigravityInactivePluginPrefersLegacyGlobalCommand(t *testing.T) { + home := t.TempDir() + mockEngramLookPath(t, "", "not found") + dir := filepath.Join(home, ".gemini", "antigravity-cli") + global := filepath.Join(dir, "mcp_config.json") + pluginDir := filepath.Join(dir, "plugins", "gentle-ai-engram") + pluginMCP := filepath.Join(pluginDir, "mcp_config.json") + writeFile(t, global, `{"mcpServers":{"engram":{"command":"/working/engram"}}}`) + writeFile(t, pluginMCP, `{"mcpServers":{"engram":{"command":"/stale/engram"}}}`) + + if _, err := Inject(home, antigravityAdapter()); err != nil { + t.Fatal(err) + } + + pluginConfig := readJSONFile(t, pluginMCP) + assertNestedString(t, pluginConfig, "/working/engram", "mcpServers", "engram", "command") + assertNestedStrings(t, pluginConfig, []string{"mcp", "--tools=agent"}, "mcpServers", "engram", "args") + manifest, err := readImage(filepath.Join(pluginDir, "plugin.json")) + if err != nil || !isActiveAntigravityManifest(manifest) { + t.Fatalf("manifest active = %v, error = %v", isActiveAntigravityManifest(manifest), err) + } +} + +func TestInjectAntigravityRecoversWriteFailures(t *testing.T) { + tests := []struct { + name, global, manifest string + rollback bool + pluginOnly bool + }{ + {"global pre-replacement", "pre", "", false, false}, + {"global post-rename sync", "post", "", false, true}, + {"global post-replacement reread failure", "unknown", "", false, true}, + {"manifest pre-replacement", "", "pre", false, false}, + {"manifest post-rename sync", "", "post", false, true}, + {"rollback failure rolls forward", "", "pre", true, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, ".gemini", "antigravity-cli") + global, manifest := filepath.Join(dir, "mcp_config.json"), filepath.Join(dir, "plugins", "gentle-ai-engram", "plugin.json") + original := []byte("{\"root\":1,\"mcpServers\":{\"engram\":{\"command\":\"engram\"},\"other\":{}}}\n") + writeFile(t, global, string(original)) + actualWrite, actualRead, manifestCalls, globalReplaced := antigravityWriteFile, antigravityReadFile, 0, false + antigravityWriteFile = func(path string, content []byte, mode os.FileMode) (filemerge.WriteResult, error) { + if path == global && bytes.Equal(content, original) && tt.rollback { + return filemerge.WriteResult{}, fmt.Errorf("rollback fail") + } + if path == global && !bytes.Equal(content, original) && tt.global != "" { + if tt.global == "pre" { + return filemerge.WriteResult{}, fmt.Errorf("global fail") + } + r, _ := actualWrite(path, content, mode) + globalReplaced = true + return r, fmt.Errorf("global sync fail") + } + if path == manifest && tt.manifest != "" { + manifestCalls++ + if manifestCalls == 1 { + if tt.manifest == "pre" { + return filemerge.WriteResult{}, fmt.Errorf("manifest fail") + } + r, _ := actualWrite(path, content, mode) + return r, fmt.Errorf("manifest sync fail") + } + } + return actualWrite(path, content, mode) + } + antigravityReadFile = func(path string) ([]byte, error) { + if path == global && globalReplaced && tt.global == "unknown" { + return nil, fmt.Errorf("global reread fail") + } + return actualRead(path) + } + t.Cleanup(func() { + antigravityWriteFile = actualWrite + antigravityReadFile = actualRead + }) + _, err := Inject(home, antigravityAdapter()) + if err == nil { + t.Fatal("Inject error = nil") + } + gotGlobal, _ := os.ReadFile(global) + _, manifestErr := os.Stat(manifest) + if tt.pluginOnly { + if bytes.Contains(gotGlobal, []byte(`"engram"`)) || manifestErr != nil { + t.Fatalf("not plugin-only: global=%s manifest=%v err=%v", gotGlobal, manifestErr, err) + } + manifestImage, readErr := readImage(manifest) + if readErr != nil || !isActiveAntigravityManifest(manifestImage) { + t.Fatalf("manifest active = %v, error = %v", isActiveAntigravityManifest(manifestImage), readErr) + } + pluginConfig := readJSONFile(t, filepath.Join(filepath.Dir(manifest), "mcp_config.json")) + assertNestedString(t, pluginConfig, "engram", "mcpServers", "engram", "command") + assertNestedStrings(t, pluginConfig, []string{"mcp", "--tools=agent"}, "mcpServers", "engram", "args") + } else if !bytes.Equal(gotGlobal, original) || !os.IsNotExist(manifestErr) { + t.Fatalf("original not restored: global=%s manifest=%v err=%v", gotGlobal, manifestErr, err) + } + if tt.rollback && (!strings.Contains(err.Error(), "rollback fail") || !strings.Contains(err.Error(), "converged plugin-only")) { + t.Fatalf("recovery error lacks joined state: %v", err) + } + }) + } +} + +func TestInjectAntigravityManifestWriteFailureUsesSemanticActivity(t *testing.T) { + tests := []struct { + name, variant, manifest string + wantPluginOnly bool + }{ + {"CLI invalid manifest restores global", "antigravity-cli", `{"name":`, false}, + {"desktop invalid manifest restores global", "antigravity-desktop", `{"name":`, false}, + {"CLI older valid manifest preserves plugin-only", "antigravity-cli", `{"name":"gentle-ai-engram","version":"0.0.1","custom":true}`, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + home := t.TempDir() + mockEngramLookPath(t, "", "not found") + dir := filepath.Join(home, ".gemini", tt.variant) + if tt.variant == "antigravity-desktop" { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + global := filepath.Join(dir, "mcp_config.json") + manifest := filepath.Join(dir, "plugins", "gentle-ai-engram", "plugin.json") + pluginMCP := filepath.Join(filepath.Dir(manifest), "mcp_config.json") + writeFile(t, global, `{"root":1,"mcpServers":{"engram":{"command":"engram"},"other":{"command":"other"}}}`) + writeFile(t, manifest, tt.manifest) + + actual := antigravityWriteFile + antigravityWriteFile = func(path string, content []byte, mode os.FileMode) (filemerge.WriteResult, error) { + if path == manifest { + return filemerge.WriteResult{}, fmt.Errorf("manifest fail") + } + return actual(path, content, mode) + } + t.Cleanup(func() { antigravityWriteFile = actual }) + + _, err := Inject(home, antigravityAdapter()) + if err == nil { + t.Fatal("Inject error = nil") + } + + globalConfig := readJSONFile(t, global) + servers, ok := globalConfig["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("global mcpServers has unexpected type: %T", globalConfig["mcpServers"]) + } + _, globalActive := servers["engram"] + if globalActive != !tt.wantPluginOnly || servers["other"] == nil || globalConfig["root"] != float64(1) { + t.Fatalf("global registration state = %v, config = %v", globalActive, globalConfig) + } + + pluginConfig := readJSONFile(t, pluginMCP) + assertNestedString(t, pluginConfig, "engram", "mcpServers", "engram", "command") + assertNestedStrings(t, pluginConfig, []string{"mcp", "--tools=agent"}, "mcpServers", "engram", "args") + + manifestImage, readErr := readImage(manifest) + if readErr != nil { + t.Fatal(readErr) + } + if active := isActiveAntigravityManifest(manifestImage); active != tt.wantPluginOnly { + t.Fatalf("manifest active = %v, want %v; manifest = %s", active, tt.wantPluginOnly, manifestImage.data) + } + var manifestConfig map[string]any + parseErr := json.Unmarshal(manifestImage.data, &manifestConfig) + if tt.wantPluginOnly { + if parseErr != nil || manifestConfig["name"] != "gentle-ai-engram" || manifestConfig["version"] != "0.0.1" || manifestConfig["custom"] != true { + t.Fatalf("older manifest not preserved: config=%v err=%v", manifestConfig, parseErr) + } + if !strings.Contains(err.Error(), "plugin-only registration observed") { + t.Fatalf("recovery error does not report plugin-only state: %v", err) + } + } else { + if parseErr == nil { + t.Fatalf("invalid manifest unexpectedly parsed: %v", manifestConfig) + } + if strings.Contains(err.Error(), "plugin-only registration observed") { + t.Fatalf("recovery error incorrectly reports plugin-only state: %v", err) + } + } + }) + } +} + +func TestInjectAntigravityRejectsBadGlobalWithoutMutation(t *testing.T) { + for _, readFailure := range []bool{false, true} { + t.Run(fmt.Sprint(readFailure), func(t *testing.T) { + home := t.TempDir() + global := filepath.Join(home, ".gemini", "antigravity-cli", "mcp_config.json") + original := []byte(`{"mcpServers":`) + writeFile(t, global, string(original)) + actual := antigravityReadFile + if readFailure { + antigravityReadFile = func(path string) ([]byte, error) { + if path == global { + return nil, fmt.Errorf("read fail") + } + return actual(path) + } + } + t.Cleanup(func() { antigravityReadFile = actual }) + if _, err := Inject(home, antigravityAdapter()); err == nil { + t.Fatal("Inject error = nil") + } + got, _ := os.ReadFile(global) + manifest := filepath.Join(filepath.Dir(global), "plugins", "gentle-ai-engram", "plugin.json") + _, statErr := os.Stat(manifest) + if !bytes.Equal(got, original) || !os.IsNotExist(statErr) { + t.Fatalf("mutation occurred: global=%s manifest=%v", got, statErr) + } + }) + } +} + // ─── Codex tests ────────────────────────────────────────────────────────────── func TestInjectCodexWritesTOMLMCP(t *testing.T) { diff --git a/internal/components/golden_test.go b/internal/components/golden_test.go index 7617f4df3..155644935 100644 --- a/internal/components/golden_test.go +++ b/internal/components/golden_test.go @@ -944,8 +944,8 @@ func TestGoldenEngram_Antigravity(t *testing.T) { t.Fatalf("engram.Inject(antigravity) changed = false") } - // MCP config written to ~/.gemini/antigravity-cli/mcp_config.json. - mcpJSON := readTestFile(t, filepath.Join(home, ".gemini", "antigravity-cli", "mcp_config.json")) + // The plugin is the sole owner of Antigravity's Engram registration. + mcpJSON := readTestFile(t, filepath.Join(home, ".gemini", "antigravity-cli", "plugins", "gentle-ai-engram", "mcp_config.json")) assertGolden(t, "engram-antigravity-mcp.golden", mcpJSON) // GEMINI.md must contain the engram-protocol section. diff --git a/testdata/golden/engram-antigravity-mcp.golden b/testdata/golden/engram-antigravity-mcp.golden index 306b24ca2..4f1c5bdc2 100644 --- a/testdata/golden/engram-antigravity-mcp.golden +++ b/testdata/golden/engram-antigravity-mcp.golden @@ -2,7 +2,8 @@ "mcpServers": { "engram": { "args": [ - "mcp" + "mcp", + "--tools=agent" ], "command": "/opt/homebrew/bin/engram" }