diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d015d9ba..7a98cfe4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,7 +199,7 @@ jobs: goreleaser_ids: '--id agentfield-linux-amd64 --id agentfield-linux-arm64' - os: macos-14 label: darwin - goreleaser_ids: '--id agentfield-darwin-amd64 --id agentfield-darwin-arm64' + goreleaser_ids: '--id agentfield-darwin-amd64 --id agentfield-darwin-arm64 --id agentfield-tray-darwin-amd64 --id agentfield-tray-darwin-arm64' runs-on: ${{ matrix.os }} env: GORELEASER_CURRENT_TAG: ${{ needs.prepare.outputs.tag_name }} diff --git a/.gitignore b/.gitignore index 9ea842be..0e0c7bf1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ examples/go_agent_nodes/multi_version # Compiled binaries control-plane/agentfield-server control-plane/af +control-plane/af-tray scripts/lambda-rie-fake/lambda-rie-fake # Python diff --git a/.goreleaser.yml b/.goreleaser.yml index 2ec49165..a4141fff 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -86,6 +86,41 @@ builds: - embedded - sqlite_fts5 + # AgentField desktop tray (menu-bar companion). macOS only — it carries the + # systray/Cocoa (CGO) dependency so the control-plane binary never has to. + # A separate, small binary; the installer fetches it only on macOS. + - id: agentfield-tray-darwin-amd64 + dir: control-plane + main: ./cmd/af-tray + binary: agentfield-tray-darwin-amd64 + env: + - CGO_ENABLED=1 + goos: + - darwin + goarch: + - amd64 + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.commit={{ .ShortCommit }} + - -X main.date={{ .Date }} + + - id: agentfield-tray-darwin-arm64 + dir: control-plane + main: ./cmd/af-tray + binary: agentfield-tray-darwin-arm64 + env: + - CGO_ENABLED=1 + goos: + - darwin + goarch: + - arm64 + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.commit={{ .ShortCommit }} + - -X main.date={{ .Date }} + # Windows build disabled - focus on Linux and macOS for now # - id: agentfield-windows-amd64 # dir: control-plane @@ -116,8 +151,12 @@ archives: - agentfield-linux-arm64 - agentfield-darwin-amd64 - agentfield-darwin-arm64 + - agentfield-tray-darwin-amd64 + - agentfield-tray-darwin-arm64 format: binary - name_template: "agentfield-{{ .Os }}-{{ .Arch }}" + # Each build's `binary:` is already the exact asset name we want + # (agentfield-- and agentfield-tray-darwin-). + name_template: "{{ .Binary }}" checksum: name_template: "checksums.txt" diff --git a/control-plane/cmd/af-tray/assets/appicon.icns b/control-plane/cmd/af-tray/assets/appicon.icns new file mode 100644 index 00000000..60a2f50e Binary files /dev/null and b/control-plane/cmd/af-tray/assets/appicon.icns differ diff --git a/control-plane/cmd/af-tray/assets/icon_active.ico b/control-plane/cmd/af-tray/assets/icon_active.ico new file mode 100644 index 00000000..3ff4b678 Binary files /dev/null and b/control-plane/cmd/af-tray/assets/icon_active.ico differ diff --git a/control-plane/cmd/af-tray/assets/icon_active.png b/control-plane/cmd/af-tray/assets/icon_active.png new file mode 100644 index 00000000..8c27b331 Binary files /dev/null and b/control-plane/cmd/af-tray/assets/icon_active.png differ diff --git a/control-plane/cmd/af-tray/assets/icon_inactive.ico b/control-plane/cmd/af-tray/assets/icon_inactive.ico new file mode 100644 index 00000000..87fc9e40 Binary files /dev/null and b/control-plane/cmd/af-tray/assets/icon_inactive.ico differ diff --git a/control-plane/cmd/af-tray/assets/icon_inactive.png b/control-plane/cmd/af-tray/assets/icon_inactive.png new file mode 100644 index 00000000..d26c62fa Binary files /dev/null and b/control-plane/cmd/af-tray/assets/icon_inactive.png differ diff --git a/control-plane/cmd/af-tray/assets_darwin.go b/control-plane/cmd/af-tray/assets_darwin.go new file mode 100644 index 00000000..4e44d3c6 --- /dev/null +++ b/control-plane/cmd/af-tray/assets_darwin.go @@ -0,0 +1,19 @@ +package main + +import _ "embed" + +// Menu-bar icons. These are embedded on every platform (they are just bytes), +// but only referenced by the darwin tray implementation. Replace these with the +// final brand assets when available — icon_active is shown when the control +// plane is healthy, icon_inactive when it is stopped/unreachable. +// +//go:embed assets/icon_active.png +var iconActive []byte + +//go:embed assets/icon_inactive.png +var iconInactive []byte + +// appIconICNS is written into the generated .app bundle's Resources on macOS. +// +//go:embed assets/appicon.icns +var appIconICNS []byte diff --git a/control-plane/cmd/af-tray/launchd_darwin.go b/control-plane/cmd/af-tray/launchd_darwin.go new file mode 100644 index 00000000..74652bf7 --- /dev/null +++ b/control-plane/cmd/af-tray/launchd_darwin.go @@ -0,0 +1,131 @@ +//go:build darwin + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// ---- Install / uninstall --------------------------------------------------- + +// installDesktop is idempotent and convergent: every run rewrites the .app +// bundle and both launchd plists, then bootstraps-or-force-restarts each agent. +// This is what makes `curl … | install.sh` hands-off on both a fresh install +// and an update — a stale, already-running tray is killed and relaunched onto +// the freshly installed binary, and a freshly written agent is started now +// (not just at next login). +func installDesktop() error { + for _, d := range []string{logsDir(), launchAgentsDir(), + filepath.Join(appBundleDir(), "Contents", "MacOS"), + filepath.Join(appBundleDir(), "Contents", "Resources")} { + if err := os.MkdirAll(d, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", d, err) + } + } + + // Build the .app bundle around a copy of ourselves. Using rename-over means + // we can safely replace the binary even while an old tray is executing it. + self, err := os.Executable() + if err != nil { + return fmt.Errorf("locate self: %w", err) + } + selfData, err := os.ReadFile(self) + if err != nil { + return fmt.Errorf("read self: %w", err) + } + if err := writeFileAtomic(trayBundleBinaryPath(), selfData, 0o755); err != nil { + return fmt.Errorf("install tray binary: %w", err) + } + if err := writeFileAtomic(filepath.Join(appBundleDir(), "Contents", "Resources", "appicon.icns"), appIconICNS, 0o644); err != nil { + return fmt.Errorf("write app icon: %w", err) + } + if err := writeFileAtomic(filepath.Join(appBundleDir(), "Contents", "Info.plist"), []byte(infoPlist()), 0o644); err != nil { + return fmt.Errorf("write Info.plist: %w", err) + } + + // launchd agents. + if err := writeFileAtomic(serverPlistPath(), []byte(serverPlist()), 0o644); err != nil { + return fmt.Errorf("write server plist: %w", err) + } + if err := writeFileAtomic(trayPlistPath(), []byte(trayPlist()), 0o644); err != nil { + return fmt.Errorf("write tray plist: %w", err) + } + + // Converge launchd state. bootstrap is ignored if already loaded; the + // kickstart -k then force-restarts onto the new binary in every case. + _ = bootstrapAgent(serverPlistPath()) + _ = kickstartAgent(serverLabel, true) + _ = bootstrapAgent(trayPlistPath()) + _ = kickstartAgent(trayLabel, true) + + fmt.Println("AgentField desktop tray installed. Look for the icon in your menu bar.") + return nil +} + +func uninstallDesktop() error { + _ = bootoutAgent(trayLabel) + _ = bootoutAgent(serverLabel) + _ = os.Remove(trayPlistPath()) + _ = os.Remove(serverPlistPath()) + _ = os.RemoveAll(appBundleDir()) + fmt.Println("AgentField desktop tray removed.") + return nil +} + +// ---- Server lifecycle (driven from the tray menu) -------------------------- + +func startServer() error { + if !agentLoaded(serverLabel) { + _ = bootstrapAgent(serverPlistPath()) + } + return kickstartAgent(serverLabel, false) +} + +// stopServer sends SIGTERM for a graceful shutdown. Because the server plist +// uses KeepAlive={SuccessfulExit: false}, a clean exit is not relaunched — so +// "Stop" actually stops it, while a genuine crash still auto-restarts. +func stopServer() error { + return exec.Command("launchctl", "kill", "SIGTERM", svcTarget(serverLabel)).Run() +} + +func restartServer() error { + if !agentLoaded(serverLabel) { + _ = bootstrapAgent(serverPlistPath()) + } + return kickstartAgent(serverLabel, true) +} + +// serverAutostartEnabled reflects whether the server agent is loaded (and will +// therefore start at login). +func serverAutostartEnabled() bool { return agentLoaded(serverLabel) } + +func setServerAutostart(enable bool) error { + if enable { + if err := bootstrapAgent(serverPlistPath()); err != nil && !agentLoaded(serverLabel) { + return err + } + return kickstartAgent(serverLabel, false) + } + return bootoutAgent(serverLabel) +} + +// ---- launchctl exec wrappers ----------------------------------------------- + +func bootstrapAgent(plistPath string) error { + return exec.Command("launchctl", "bootstrap", guiDomain(), plistPath).Run() +} + +func bootoutAgent(label string) error { + return exec.Command("launchctl", "bootout", svcTarget(label)).Run() +} + +func kickstartAgent(label string, kill bool) error { + return exec.Command("launchctl", kickstartArgs(label, kill)...).Run() +} + +func agentLoaded(label string) bool { + return exec.Command("launchctl", "print", svcTarget(label)).Run() == nil +} diff --git a/control-plane/cmd/af-tray/main.go b/control-plane/cmd/af-tray/main.go new file mode 100644 index 00000000..160ad4e0 --- /dev/null +++ b/control-plane/cmd/af-tray/main.go @@ -0,0 +1,80 @@ +// Command af-tray is the AgentField menu-bar companion. +// +// It is a small, separate binary from the main `af`/agentfield control-plane +// binary on purpose: it carries the GUI/systray dependency so the server binary +// never has to. In a headless deployment (Railway, ECS, EC2, a container) the +// tray simply is never installed or run — and if it is run there anyway, it +// detects the absence of a GUI session and exits cleanly instead of crashing. +// +// Subcommands: +// +// af-tray run Run the menu-bar tray (default). +// af-tray install Install the desktop tray + control-plane autostart (macOS). +// af-tray uninstall Remove the desktop tray and autostart. +// af-tray version Print version. +// +// The platform-specific behaviour lives in tray_darwin.go / launchd_darwin.go +// (real implementation) and tray_other.go (no-op stubs for every other OS). +package main + +import ( + "fmt" + "os" +) + +// Build-time version information (set via ldflags during build). +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + os.Exit(run(os.Args[1:])) +} + +// run dispatches a subcommand and returns the process exit code. It is split +// out of main so it can be unit-tested without spawning a process. +func run(args []string) int { + cmd := "run" + if len(args) > 0 { + cmd = args[0] + } + + switch cmd { + case "run": + if err := runTray(); err != nil { + fmt.Fprintln(os.Stderr, "af-tray:", err) + return 1 + } + case "install": + if err := installDesktop(); err != nil { + fmt.Fprintln(os.Stderr, "af-tray install:", err) + return 1 + } + case "uninstall": + if err := uninstallDesktop(); err != nil { + fmt.Fprintln(os.Stderr, "af-tray uninstall:", err) + return 1 + } + case "version", "--version", "-v": + fmt.Printf("af-tray %s (%s) %s\n", version, commit, date) + case "help", "--help", "-h": + printUsage() + default: + printUsage() + return 2 + } + return 0 +} + +func printUsage() { + fmt.Print(`af-tray — AgentField menu-bar companion + +Usage: + af-tray run Run the menu-bar tray (default) + af-tray install Install the desktop tray + control-plane autostart (macOS) + af-tray uninstall Remove the desktop tray and control-plane autostart + af-tray version Print version information +`) +} diff --git a/control-plane/cmd/af-tray/main_test.go b/control-plane/cmd/af-tray/main_test.go new file mode 100644 index 00000000..885f903b --- /dev/null +++ b/control-plane/cmd/af-tray/main_test.go @@ -0,0 +1,18 @@ +package main + +import "testing" + +// Contract: the CLI dispatch returns 0 for version/help variants and 2 for an +// unknown subcommand. These arms are platform-independent (they don't touch the +// tray or launchd), so they run everywhere. +func TestRunDispatchExitCodes(t *testing.T) { + zero := []string{"version", "--version", "-v", "help", "--help", "-h"} + for _, cmd := range zero { + if code := run([]string{cmd}); code != 0 { + t.Errorf("run([%q]) = %d, want 0", cmd, code) + } + } + if code := run([]string{"totally-unknown"}); code != 2 { + t.Errorf("run([unknown]) = %d, want 2", code) + } +} diff --git a/control-plane/cmd/af-tray/shared.go b/control-plane/cmd/af-tray/shared.go new file mode 100644 index 00000000..2fdd2912 --- /dev/null +++ b/control-plane/cmd/af-tray/shared.go @@ -0,0 +1,228 @@ +package main + +// This file holds the platform-neutral logic behind the tray: health polling, +// path resolution, launchd plist / Info.plist generation, launchctl argument +// construction, and atomic file writes. It has NO GUI (systray/CGO) dependency +// and compiles on every platform, so it can be unit-tested directly in CI +// (which runs on Linux). The OS-specific glue — the systray event loop and the +// exec.Command("launchctl", …) calls — lives in the _darwin files and calls +// into these helpers. + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "time" +) + +const ( + trayLabel = "ai.agentfield.tray" + serverLabel = "ai.agentfield.server" +) + +// ---- Paths ----------------------------------------------------------------- + +func home() string { + h, _ := os.UserHomeDir() + return h +} + +func agentfieldDir() string { return filepath.Join(home(), ".agentfield") } +func binDir() string { return filepath.Join(agentfieldDir(), "bin") } +func logsDir() string { return filepath.Join(agentfieldDir(), "logs") } +func launchAgentsDir() string { return filepath.Join(home(), "Library", "LaunchAgents") } +func appBundleDir() string { return filepath.Join(home(), "Applications", "AgentField.app") } +func serverLogPath() string { return filepath.Join(logsDir(), "control-plane.log") } +func trayLogPath() string { return filepath.Join(logsDir(), "tray.log") } +func trayPlistPath() string { return filepath.Join(launchAgentsDir(), trayLabel+".plist") } +func serverPlistPath() string { return filepath.Join(launchAgentsDir(), serverLabel+".plist") } + +func trayBundleBinaryPath() string { + return filepath.Join(appBundleDir(), "Contents", "MacOS", "af-tray") +} + +// serverBinaryPath finds the control-plane binary the launchd agent should run. +// It prefers the installed copy, then falls back to whatever is on PATH. +func serverBinaryPath() string { + cand := filepath.Join(binDir(), "agentfield") + if isExecutable(cand) { + return cand + } + if p, err := exec.LookPath("af"); err == nil { + return p + } + if p, err := exec.LookPath("agentfield"); err == nil { + return p + } + return cand // best effort; may not exist yet. +} + +func isExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() && info.Mode()&0o111 != 0 +} + +// ---- Health / URLs --------------------------------------------------------- + +// serverPort returns the port the control plane is expected to listen on. +func serverPort() int { + if v := os.Getenv("AGENTFIELD_PORT"); v != "" { + if p, err := strconv.Atoi(v); err == nil { + return p + } + } + return 8080 +} + +func healthURL() string { return fmt.Sprintf("http://localhost:%d/health", serverPort()) } +func dashboardURL() string { return fmt.Sprintf("http://localhost:%d", serverPort()) } + +// checkHealth reports whether the given URL answers HTTP 200 within a short +// timeout. The control plane's /health endpoint returns 200 when healthy and +// 503 when not, so only a 200 counts as "running". +func checkHealth(url string) bool { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(url) + if err != nil { + return false + } + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode == http.StatusOK +} + +func serverHealthy() bool { return checkHealth(healthURL()) } + +// ---- launchctl argument construction --------------------------------------- + +func guiDomain() string { return fmt.Sprintf("gui/%d", os.Getuid()) } +func svcTarget(l string) string { return guiDomain() + "/" + l } + +// kickstartArgs builds the argv for `launchctl kickstart`. The -k flag forces a +// restart of an already-running service (kill then relaunch); without it, +// kickstart only starts a loaded-but-idle service. +func kickstartArgs(label string, kill bool) []string { + args := []string{"kickstart"} + if kill { + args = append(args, "-k") + } + return append(args, svcTarget(label)) +} + +// ---- Files ----------------------------------------------------------------- + +// writeFileAtomic writes data to a temp file in the destination directory and +// renames it into place, so a reader (or a running binary being replaced) never +// sees a half-written file. +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".af-tray-*") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return err + } + if err := os.Chmod(tmpName, perm); err != nil { + _ = os.Remove(tmpName) + return err + } + return os.Rename(tmpName, path) +} + +// ---- plist / Info.plist templates ------------------------------------------ + +func infoPlist() string { + return fmt.Sprintf(` + + + + CFBundleNameAgentField + CFBundleDisplayNameAgentField + CFBundleIdentifier%s + CFBundleVersion%s + CFBundleShortVersionString%s + CFBundlePackageTypeAPPL + CFBundleExecutableaf-tray + CFBundleIconFileappicon + LSUIElement + LSMinimumSystemVersion10.15 + + +`, trayLabel, version, version) +} + +// serverPlist is the control-plane launchd agent. +// - RunAtLoad starts it at login. +// - KeepAlive={SuccessfulExit: false} restarts it only on a crash, so a +// graceful "Stop" (SIGTERM → clean exit) actually stays stopped. +// - --open=false stops it opening a browser every time it starts under launchd. +func serverPlist() string { + log := serverLogPath() + return fmt.Sprintf(` + + + + Label%s + ProgramArguments + + %s + server + --open=false + + RunAtLoad + KeepAlive + SuccessfulExit + WorkingDirectory%s + StandardOutPath%s + StandardErrorPath%s + EnvironmentVariables + + PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + ProcessTypeBackground + + +`, serverLabel, serverBinaryPath(), agentfieldDir(), log, log) +} + +// trayPlist is the menu-bar tray launchd agent. KeepAlive={Crashed: true} means +// a genuine crash relaunches it, but a clean exit (the "Quit" menu item, or the +// no-GUI-session early exit) does not — so it never crash-loops. +func trayPlist() string { + log := trayLogPath() + return fmt.Sprintf(` + + + + Label%s + ProgramArguments + + %s + run + + RunAtLoad + KeepAlive + Crashed + StandardOutPath%s + StandardErrorPath%s + ProcessTypeInteractive + + +`, trayLabel, trayBundleBinaryPath(), log, log) +} diff --git a/control-plane/cmd/af-tray/shared_test.go b/control-plane/cmd/af-tray/shared_test.go new file mode 100644 index 00000000..c5dde686 --- /dev/null +++ b/control-plane/cmd/af-tray/shared_test.go @@ -0,0 +1,352 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +// These tests validate the behaviours the tray depends on, expressed as +// contracts rather than by mirroring the implementation. They live in a +// build-tag-free file so they compile and run on the Linux CI even though the +// tray UI itself is macOS-only. + +// Contract: the port comes from AGENTFIELD_PORT when set to a valid integer, +// and defaults to 8080 otherwise (including when the value is garbage). +func TestServerPort(t *testing.T) { + cases := []struct { + name string + env string + set bool + want int + }{ + {"default when unset", "", false, 8080}, + {"honors valid env", "9090", true, 9090}, + {"falls back on garbage", "not-a-number", true, 8080}, + {"falls back on empty", "", true, 8080}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.set { + t.Setenv("AGENTFIELD_PORT", tc.env) + } else { + _ = os.Unsetenv("AGENTFIELD_PORT") + } + if got := serverPort(); got != tc.want { + t.Fatalf("serverPort() = %d, want %d", got, tc.want) + } + }) + } +} + +// Contract: the health/dashboard URLs target the resolved port on localhost. +func TestURLsUsePort(t *testing.T) { + t.Setenv("AGENTFIELD_PORT", "1234") + if got, want := healthURL(), "http://localhost:1234/health"; got != want { + t.Errorf("healthURL() = %q, want %q", got, want) + } + if got, want := dashboardURL(), "http://localhost:1234"; got != want { + t.Errorf("dashboardURL() = %q, want %q", got, want) + } +} + +// Contract: a server is "running" only when /health answers HTTP 200; a 503 +// (unhealthy) or an unreachable server both read as not running. +func TestCheckHealth(t *testing.T) { + ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer ok.Close() + if !checkHealth(ok.URL) { + t.Error("checkHealth on a 200 server = false, want true") + } + + unhealthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer unhealthy.Close() + if checkHealth(unhealthy.URL) { + t.Error("checkHealth on a 503 server = true, want false") + } + + // Unreachable: start a server, capture its URL, then close it. + down := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + downURL := down.URL + down.Close() + if checkHealth(downURL) { + t.Error("checkHealth on a closed server = true, want false") + } +} + +// Contract: serverHealthy() wires serverPort()→healthURL()→checkHealth end to +// end against a live server. +func TestServerHealthyEndToEnd(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer ts.Close() + + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + t.Setenv("AGENTFIELD_PORT", u.Port()) // localhost: resolves to the test server. + if !serverHealthy() { + t.Error("serverHealthy() = false against a live /health, want true") + } +} + +// Contract: kickstart uses -k (force restart) only when a kill is requested, +// and always targets gui//