From ada1eb516354a0428c48202b1a8966550056df8e Mon Sep 17 00:00:00 2001 From: Bruce MacDonald Date: Mon, 29 Jun 2026 11:50:11 -0700 Subject: [PATCH] launch: check for min version for hermes desktop (#16912) --- cmd/launch/hermes.go | 42 +++++++++++ cmd/launch/hermes_test.go | 143 +++++++++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/cmd/launch/hermes.go b/cmd/launch/hermes.go index 1ad94b72..22e93d99 100644 --- a/cmd/launch/hermes.go +++ b/cmd/launch/hermes.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "golang.org/x/mod/semver" "gopkg.in/yaml.v3" "github.com/ollama/ollama/api" @@ -23,6 +24,8 @@ import ( ) const ( + // https://github.com/NousResearch/hermes-agent/releases/tag/v2026.6.5 + hermesDesktopMinVersion = "v0.16.0" hermesInstallScript = "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-setup" hermesWindowsInstallURL = "https://hermes-agent.nousresearch.com/install.ps1" hermesWindowsInstallCmd = "& ([scriptblock]::Create((irm " + hermesWindowsInstallURL + "))) -SkipSetup" @@ -94,9 +97,48 @@ func (h *HermesDesktop) Run(_ string, _ []LaunchModel, args []string) error { if err != nil { return err } + if err := h.ensureHermesDesktopMinVersion(bin); err != nil { + return err + } return hermesAttachedCommand(bin, h.launchArgs(args)...).Run() } +func (h *HermesDesktop) ensureHermesDesktopMinVersion(bin string) error { + if hermesGOOS == "windows" { + return nil + } + version := hermesVersionOf(bin) + if version == "" { + return nil + } + if semver.Compare(version, hermesDesktopMinVersion) >= 0 { + return nil + } + fmt.Fprintf(os.Stderr, "%sHermes %s is older than the minimum version (%s) for `hermes desktop`; updating...%s\n", ansiGray, version, hermesDesktopMinVersion, ansiReset) + if err := hermesAttachedCommand(bin, "update").Run(); err != nil { + return fmt.Errorf("failed to update hermes to %s or newer: %w", hermesDesktopMinVersion, err) + } + return nil +} + +func hermesVersionOf(bin string) string { + out, err := hermesCommand(bin, "--version").Output() + if err != nil { + return "" + } + firstLine := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] + return parseHermesVersion(firstLine) +} + +func parseHermesVersion(firstLine string) string { + for _, field := range strings.Fields(firstLine) { + if semver.IsValid(field) { + return field + } + } + return "" +} + func (h *HermesDesktop) Onboard() error { return config.MarkIntegrationOnboarded("hermes-desktop") } diff --git a/cmd/launch/hermes_test.go b/cmd/launch/hermes_test.go index 8dfd733a..690ab8cf 100644 --- a/cmd/launch/hermes_test.go +++ b/cmd/launch/hermes_test.go @@ -630,7 +630,7 @@ func hermesDesktopTestExecutableRelativePath(goos string) string { func writeHermesDesktopTestBinary(t *testing.T, dir string) { t.Helper() bin := filepath.Join(dir, "hermes") - if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n"), 0o755); err != nil { + if err := os.WriteFile(bin, []byte("#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n printf 'Hermes Agent v0.16.0 (2026.6.5)\\n'\n exit 0\nfi\nprintf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n"), 0o755); err != nil { t.Fatal(err) } } @@ -732,6 +732,147 @@ func TestHermesDesktopRun(t *testing.T) { }) } } +func writeHermesVersionedTestBinary(t *testing.T, dir, version string) { + t.Helper() + script := "#!/bin/sh\n" + + "case \"$1\" in\n" + + " --version)\n" + + " printf 'Hermes Agent " + version + " (test)\\n'\n" + + " ;;\n" + + " update)\n" + + " printf 'update\\n' >> \"$HOME/hermes-update.log\"\n" + + " ;;\n" + + " *)\n" + + " printf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n" + + " ;;\n" + + "esac\n" + bin := filepath.Join(dir, "hermes") + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } +} + +func TestParseHermesVersion(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"standard release", "Hermes Agent v0.16.0 (2026.6.5)", "v0.16.0"}, + {"newer release", "Hermes Agent v0.17.0 (2026.6.19)", "v0.17.0"}, + {"older release", "Hermes Agent v0.15.1 (2026.5.29)", "v0.15.1"}, + {"prerelease", "Hermes Agent v0.16.0-rc1 (2026.6.5)", "v0.16.0-rc1"}, + {"no version token", "Hermes Agent", ""}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseHermesVersion(tt.input); got != tt.want { + t.Fatalf("parseHermesVersion(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestHermesDesktopRun_UpdatesCliOlderThanMinVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, runtime.GOOS) + clearHermesMessagingEnvVars(t) + clearHermesDesktopPackageEnvVars(t) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + writeHermesVersionedTestBinary(t, tmpDir, "v0.15.1") + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect messaging prompt during desktop launch: %s", prompt) + return false, nil + } + + if err := (&HermesDesktop{}).Run("", nil, []string{"--foreground"}); err != nil { + t.Fatalf("Run returned error: %v", err) + } + + updateLog, err := os.ReadFile(filepath.Join(tmpDir, "hermes-update.log")) + if err != nil { + t.Fatalf("expected hermes update to run for an older CLI: %v", err) + } + if strings.TrimSpace(string(updateLog)) != "update" { + t.Fatalf("expected update log 'update', got %q", updateLog) + } + if got := readHermesDesktopInvocations(t, tmpDir); got != "[desktop --foreground]" { + t.Fatalf("expected desktop launch after update, got %q", got) + } +} + +func TestHermesDesktopRun_SkipsMinVersionCheckOnWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, "windows") + clearHermesMessagingEnvVars(t) + clearHermesDesktopPackageEnvVars(t) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + writeHermesVersionedTestBinary(t, tmpDir, "v0.15.1") + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect messaging prompt during desktop launch: %s", prompt) + return false, nil + } + + if err := (&HermesDesktop{}).Run("", nil, []string{"--foreground"}); err != nil { + t.Fatalf("Run returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmpDir, "hermes-update.log")); err == nil { + t.Fatal("expected hermes update NOT to run on Windows, but hermes-update.log exists") + } + if got := readHermesDesktopInvocations(t, tmpDir); got != "[desktop --foreground]" { + t.Fatalf("expected desktop launch without update, got %q", got) + } +} + +func TestHermesDesktopRun_DoesNotUpdateCliAtOrAboveMinVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withLauncherHooks(t) + withInteractiveSession(t, true) + withHermesPlatform(t, runtime.GOOS) + clearHermesMessagingEnvVars(t) + clearHermesDesktopPackageEnvVars(t) + t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH")) + writeHermesVersionedTestBinary(t, tmpDir, "v0.17.0") + + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect messaging prompt during desktop launch: %s", prompt) + return false, nil + } + + if err := (&HermesDesktop{}).Run("", nil, []string{"--foreground"}); err != nil { + t.Fatalf("Run returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmpDir, "hermes-update.log")); err == nil { + t.Fatal("expected hermes update NOT to run for a current CLI, but hermes-update.log exists") + } + if got := readHermesDesktopInvocations(t, tmpDir); got != "[desktop --foreground]" { + t.Fatalf("expected desktop launch without update, got %q", got) + } +} func TestHermesDesktopRunUsesWindowsLocalAppDataPackage(t *testing.T) { tmpDir := t.TempDir()