From 34bf538ba338bdf160cb15cf2e20bb12acc5a76f Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sun, 14 Jun 2026 12:14:58 +0200 Subject: [PATCH 1/2] fix(setup): avoid baking versioned Homebrew Cellar path into MCP configs resolveEngramCommand() used os.Executable() + filepath.EvalSymlinks(), which resolves the engram symlink down to a versioned Homebrew/Linuxbrew Cellar path (e.g. .../Cellar/engram/1.16.1/bin/engram). That path is removed on the next 'brew upgrade', so OpenCode/Codex fail to spawn the engram MCP server with ENOENT. On Linux os.Executable() reads /proc/self/exe which is already fully resolved, so dropping EvalSymlinks alone does not help. When the resolved executable points into a versioned Cellar directory, map it to the stable /bin/engram symlink that brew keeps current, falling back to bare "engram" when that symlink is missing. Non-Cellar absolute paths are preserved unchanged. Relates to Gentleman-Programming/gentle-ai#863 --- internal/setup/setup.go | 46 +++++++++++++++++++++++--- internal/setup/setup_test.go | 63 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/internal/setup/setup.go b/internal/setup/setup.go index b9a509bb..83d924d6 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -1077,11 +1077,18 @@ func injectGeminiMCP(configPath string) error { return nil } -// resolveEngramCommand returns the absolute path to the engram binary. -// It uses os.Executable() so that headless/systemd environments (where PATH -// is not reliably inherited by child processes) still find the binary. -// EvalSymlinks makes the path stable across package-manager upgrades. -// Falls back to bare "engram" only if os.Executable() itself fails. +// resolveEngramCommand returns the most stable command to spawn the engram +// binary. It uses os.Executable() so that headless/systemd environments (where +// PATH is not reliably inherited by child processes) still find the binary. +// +// Homebrew (and Linuxbrew) resolve the `engram` symlink to a versioned Cellar +// path such as /opt/homebrew/Cellar/engram/1.16.1/bin/engram. That path is +// removed on the next `brew upgrade`, so baking it into MCP client configs +// leaves a stale command that fails to spawn (ENOENT). When the resolved +// executable points into a versioned Cellar directory we prefer the stable +// /bin/engram symlink, which brew repoints at the current version, +// so registrations survive upgrades. Falls back to bare "engram" only when +// os.Executable() fails or the stable symlink is missing. func resolveEngramCommand() string { exe, err := osExecutable() if err != nil { @@ -1090,9 +1097,38 @@ func resolveEngramCommand() string { if resolved, err := filepath.EvalSymlinks(exe); err == nil { exe = resolved } + if stable, ok := stableHomebrewEngramCommand(exe); ok { + return stable + } return exe } +// stableHomebrewEngramCommand maps a versioned Homebrew Cellar path to the +// stable "/bin/engram" symlink that brew keeps pointing at the +// current version. It returns ("", false) when exe is not a versioned Cellar +// path, so non-Homebrew installs keep their resolved absolute path. When the +// derived stable symlink does not exist on disk it falls back to the bare +// "engram" name so the command still resolves via PATH. +func stableHomebrewEngramCommand(exe string) (string, bool) { + const marker = "/Cellar/engram/" + clean := filepath.ToSlash(filepath.Clean(exe)) + idx := strings.Index(clean, marker) + if idx < 0 { + return "", false + } + base := strings.ToLower(filepath.Base(clean)) + if base != "engram" && base != "engram.exe" { + return "", false + } + // Everything before "/Cellar/" is the brew prefix, e.g. /opt/homebrew or + // /home/linuxbrew/.linuxbrew. The bin symlink lives directly under it. + stable := clean[:idx] + "/bin/engram" + if _, err := statFn(stable); err == nil { + return filepath.FromSlash(stable), true + } + return "engram", true +} + func writeGeminiSystemPrompt() error { systemPath := geminiSystemPromptPath() if err := os.MkdirAll(filepath.Dir(systemPath), 0755); err != nil { diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 4e786afd..152898c3 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -1350,6 +1350,69 @@ func TestResolveEngramCommand(t *testing.T) { }) } +// TestResolveEngramCommandHomebrewCellar guards against baking a versioned +// Homebrew/Linuxbrew Cellar path into MCP client configs. Such paths (e.g. +// .../Cellar/engram/1.16.1/bin/engram) are removed on `brew upgrade`, leaving +// OpenCode/Codex with a stale command that fails to spawn (ENOENT). The command +// must resolve to the stable /bin/engram symlink, or bare "engram" +// when that symlink is missing. +func TestResolveEngramCommandHomebrewCellar(t *testing.T) { + cases := []struct { + name string + exe string + stableOnDisk string // stable symlink present on disk; "" means none + want string + }{ + { + name: "linuxbrew cellar maps to stable bin symlink", + exe: "/home/linuxbrew/.linuxbrew/Cellar/engram/1.16.1/bin/engram", + stableOnDisk: "/home/linuxbrew/.linuxbrew/bin/engram", + want: "/home/linuxbrew/.linuxbrew/bin/engram", + }, + { + name: "macos arm cellar maps to stable bin symlink", + exe: "/opt/homebrew/Cellar/engram/1.16.1/bin/engram", + stableOnDisk: "/opt/homebrew/bin/engram", + want: "/opt/homebrew/bin/engram", + }, + { + name: "macos intel cellar maps to stable bin symlink", + exe: "/usr/local/Cellar/engram/1.16.1/bin/engram", + stableOnDisk: "/usr/local/bin/engram", + want: "/usr/local/bin/engram", + }, + { + name: "cellar path with missing stable symlink falls back to bare name", + exe: "/opt/homebrew/Cellar/engram/1.16.1/bin/engram", + stableOnDisk: "", + want: "engram", + }, + { + name: "non-cellar absolute path is preserved", + exe: "/opt/engram/bin/engram", + stableOnDisk: "", + want: "/opt/engram/bin/engram", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetSetupSeams(t) + osExecutable = func() (string, error) { return tc.exe, nil } + statFn = func(name string) (os.FileInfo, error) { + if tc.stableOnDisk != "" && filepath.ToSlash(name) == tc.stableOnDisk { + return nil, nil // exists + } + return nil, os.ErrNotExist + } + + if got := resolveEngramCommand(); got != tc.want { + t.Fatalf("resolveEngramCommand() = %q, want %q", got, tc.want) + } + }) + } +} + func TestClaudeCodeMCPDirPaths(t *testing.T) { resetSetupSeams(t) userHomeDir = func() (string, error) { return "/home/tester", nil } From 2a7d3fac171ff23e305a129b45215f31872e258c Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sun, 14 Jun 2026 12:33:21 +0200 Subject: [PATCH 2/2] test(setup): normalize path separators for cross-platform Cellar test resolveEngramCommand returns OS-native separators via filepath.FromSlash, so compare with filepath.ToSlash to keep the table test green on Windows runners. Addresses PR review feedback. --- internal/setup/setup_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 152898c3..be2833b5 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -1406,7 +1406,10 @@ func TestResolveEngramCommandHomebrewCellar(t *testing.T) { return nil, os.ErrNotExist } - if got := resolveEngramCommand(); got != tc.want { + // Normalize separators so the comparison holds on Windows runners, + // where resolveEngramCommand returns OS-native separators via + // filepath.FromSlash while tc.want is written with forward slashes. + if got := filepath.ToSlash(resolveEngramCommand()); got != tc.want { t.Fatalf("resolveEngramCommand() = %q, want %q", got, tc.want) } })