// Package updater carries the agent's self-update logic. // // The flow is operator-driven: the server dispatches a command.update // WS envelope, the agent fetches a fresh binary from the server's // /agent/binary endpoint, atomic-renames it over the running binary // (Linux) or hands off to a detached helper script (Windows), and // exits cleanly so the service manager restarts under the new // binary. See docs/superpowers/specs/2026-05-06-p6-01-02-... // // Platform-specific code is build-tagged into updater_unix.go / // updater_windows.go. This file holds the shared HTTP fetch + path // helpers + the test seam. package updater import ( "context" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "time" ) // fetch downloads the new binary into .new, fsyncs, chmods. // Returns the path of the staged file (always binaryPath + ".new"). func fetch(ctx context.Context, serverURL, binaryPath string) (string, error) { url := fmt.Sprintf("%s/agent/binary?os=%s&arch=%s", serverURL, runtime.GOOS, runtime.GOARCH) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err } c := &http.Client{Timeout: 5 * time.Minute} res, err := c.Do(req) if err != nil { return "", err } defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { return "", fmt.Errorf("agent binary fetch: %s", res.Status) } stagePath := binaryPath + ".new" f, err := os.OpenFile(stagePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) if err != nil { return "", err } if _, copyErr := io.Copy(f, res.Body); copyErr != nil { _ = f.Close() _ = os.Remove(stagePath) return "", copyErr } if syncErr := f.Sync(); syncErr != nil { _ = f.Close() _ = os.Remove(stagePath) return "", syncErr } if closeErr := f.Close(); closeErr != nil { _ = os.Remove(stagePath) return "", closeErr } if err := os.Chmod(stagePath, 0o755); err != nil { _ = os.Remove(stagePath) return "", err } return stagePath, nil } // resolveOwnBinary returns the absolute path of the running binary. // Refuses /proc/self/exe — that's what os.Executable returns on some // systems but the path can't be renamed across. func resolveOwnBinary() (string, error) { p, err := os.Executable() if err != nil { return "", err } abs, err := filepath.Abs(p) if err != nil { return "", err } if abs == "/proc/self/exe" { return "", fmt.Errorf("cannot resolve own binary path (/proc/self/exe)") } return abs, nil } // UpdateForTest is the platform-neutral test seam. In production the // platform-specific Update fetches, swaps, then exits the process. // UpdateForTest stops short of the exit so unit tests can assert on // file state. func UpdateForTest(serverURL, binaryPath string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() stage, err := fetch(ctx, serverURL, binaryPath) if err != nil { return err } return swap(stage, binaryPath) }