Files
multica/server/internal/cli/update.go
Bohan Jiang fcd13aece9 feat(daemon): auto-update CLI when idle (MUL-2100) (#2679)
* feat(daemon): auto-update CLI when idle (MUL-2100)

Add a periodic poller that checks GitHub for a newer multica release
every hour and self-updates when the daemon is idle, reusing the same
brew-or-download upgrade path the Runtimes-page "Update" button already
runs.

- Refactor handleUpdate to call a shared runUpdate(target) helper so
  both server-triggered and auto-triggered upgrades go through the same
  brew detection + atomic replace + restart.
- New autoUpdateLoop gates each tick on: opt-out flag, Desktop launch
  source, dev-build version, an in-flight update, and active tasks. The
  idle gate guarantees we never interrupt a running agent — busy ticks
  silently retry at the next interval.
- Config: MULTICA_DAEMON_AUTO_UPDATE=false to disable (also via
  --no-auto-update), MULTICA_DAEMON_AUTO_UPDATE_INTERVAL to retune the
  poll period.
- IsNewerVersion / IsReleaseVersion helpers in the cli package, with
  tests covering patch/minor/major bumps, dev-describe strings, and
  malformed input.
- Daemon-side tests cover every skip path (updating, active tasks,
  fetch failure, no-newer) plus the success path that fires
  triggerRestart while keeping the updating flag held to the end.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): close idle race + verify checksum in auto-update (MUL-2100)

Two issues raised in PR #2679 review:

1. The first idle check in tryAutoUpdate only ran before the release-metadata
   fetch, so a poller that won the claim race during the fetch could end up
   handing handleTask a task that triggerRestart was about to cancel via root-
   ctx cancellation. Add a strict claim barrier: runRuntimePoller now
   tryEnterClaim()s before ClaimTask, and tryAutoUpdate flips pauseClaims
   under claimMu only after observing claimsInFlight + activeTasks == 0.
   Pollers that were already mid-claim hold claimsInFlight > 0, so the barrier
   refuses to engage and the update defers to the next tick.

2. The direct-download path replaced the running binary with whatever bytes
   GitHub returned, without checking checksums.txt. Pull the manifest first,
   buffer the archive, and reject on SHA-256 mismatch before extraction. The
   GoReleaser config already publishes checksums.txt; we just consume it.

Also tighten parseReleaseVersion so it stops accepting dev-describe shapes
like "v0.1.13-5-gabcdef0" through the patch trim, matching its docstring.
The auto-update loop already guards on IsReleaseVersion, but the lenient
parser was a footgun and the existing test name even said "not newer" while
asserting the opposite.

Tests:
- TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier (new race coverage)
- TestTryAutoUpdate_HoldsBarrierAcrossRestart / ReleasesBarrierOnUpgradeFailure
- TestTryEnterClaim_RespectsBarrier
- TestFindChecksumManifestAsset / TestParseChecksumManifest / TestVerifyAssetSHA256
- TestIsNewerVersion: dev-describe cases now expect false (matches docstring)

Co-authored-by: multica-agent <github@multica.ai>

* chore(daemon): default auto-update poll interval to 6h (MUL-2100)

1h was overly chatty for a release that lands at most a few times a week.
Operators who want a different cadence can still set
MULTICA_DAEMON_AUTO_UPDATE_INTERVAL or --auto-update-interval.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 18:10:22 +08:00

532 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cli
import (
"archive/tar"
"archive/zip"
"bufio"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// ChecksumManifestName is the asset name GoReleaser publishes for the
// checksum manifest (`checksum.name_template: "checksums.txt"` in
// .goreleaser.yml). Kept as a constant rather than inlined so a future rename
// changes one place.
const ChecksumManifestName = "checksums.txt"
const DefaultUpdateDownloadTimeout = 120 * time.Second
// GitHubRelease is the subset of the GitHub releases API response we need.
type GitHubRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Assets []GitHubReleaseAsset `json:"assets"`
}
// IsReleaseVersion reports whether v looks like a tagged release version
// (e.g. "0.1.13", "v0.1.13") rather than a dev build (e.g. an empty version
// or a `git describe`style "v0.2.15-235-gdaf0e935"). The auto-update poller
// uses this to skip self-update for source builds, where downgrading to a
// public release would clobber unreleased changes.
func IsReleaseVersion(v string) bool {
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(v), "v"))
if s == "" {
return false
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
for _, r := range p {
if r < '0' || r > '9' {
return false
}
}
}
return true
}
// IsNewerVersion reports whether latest is strictly newer than current. Both
// arguments may carry an optional "v" prefix; non-numeric tails are ignored
// (a 4th component, pre-release tag, etc.). Returns false if either side
// cannot be parsed — the caller treats that as "stay on current".
func IsNewerVersion(latest, current string) bool {
l, ok := parseReleaseVersion(latest)
if !ok {
return false
}
c, ok := parseReleaseVersion(current)
if !ok {
return false
}
for i := 0; i < 3; i++ {
if l[i] != c[i] {
return l[i] > c[i]
}
}
return false
}
// parseReleaseVersion extracts the three numeric components of v. Returns
// (parts, true) on success; (_, false) when v is missing, malformed, or
// carries any non-numeric tail (a dev-describe suffix, a 4th component, a
// pre-release tag, etc.). The strict shape is intentional: this is the only
// parser used by IsNewerVersion, and the autoUpdateLoop must never silently
// downgrade a developer build to a public release just because the
// dev-describe patch happened to look numeric after trimming.
func parseReleaseVersion(v string) ([3]int, bool) {
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(v), "v"))
if s == "" {
return [3]int{}, false
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return [3]int{}, false
}
var out [3]int
for i, p := range parts {
if p == "" {
return [3]int{}, false
}
for _, r := range p {
if r < '0' || r > '9' {
return [3]int{}, false
}
}
n, err := strconv.Atoi(p)
if err != nil {
return [3]int{}, false
}
out[i] = n
}
return out, true
}
type GitHubReleaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
func releaseArchiveExtension(goos string) string {
if goos == "windows" {
return "zip"
}
return "tar.gz"
}
func normalizeReleaseTag(targetVersion string) string {
tag := strings.TrimSpace(targetVersion)
if !strings.HasPrefix(tag, "v") {
tag = "v" + tag
}
return tag
}
func releaseAssetCandidates(targetVersion, goos, goarch string) []string {
tag := normalizeReleaseTag(targetVersion)
version := strings.TrimPrefix(tag, "v")
ext := releaseArchiveExtension(goos)
// Prefer the versioned name (current scheme); fall back to the legacy
// `multica_{os}_{arch}` name for releases that still ship it.
return []string{
fmt.Sprintf("multica-cli-%s-%s-%s.%s", version, goos, goarch, ext),
fmt.Sprintf("multica_%s_%s.%s", goos, goarch, ext),
}
}
func findReleaseAsset(assets []GitHubReleaseAsset, targetVersion, goos, goarch string) (*GitHubReleaseAsset, error) {
for _, candidate := range releaseAssetCandidates(targetVersion, goos, goarch) {
for i := range assets {
if assets[i].Name == candidate {
return &assets[i], nil
}
}
}
candidates := strings.Join(releaseAssetCandidates(targetVersion, goos, goarch), ", ")
return nil, fmt.Errorf("no matching release asset for %s/%s (tried: %s)", goos, goarch, candidates)
}
// findChecksumManifestAsset locates the GoReleaser-generated checksums.txt
// among a release's assets. Required for the direct-download path's SHA-256
// verification — if it is missing we refuse to replace the binary rather
// than fall back to unverified install, because the auto-update poller runs
// unattended and an unverified binary swap is a supply-chain risk.
func findChecksumManifestAsset(assets []GitHubReleaseAsset) (*GitHubReleaseAsset, error) {
for i := range assets {
if assets[i].Name == ChecksumManifestName {
return &assets[i], nil
}
}
return nil, fmt.Errorf("checksum manifest %q not present in release", ChecksumManifestName)
}
// parseChecksumManifest reads a GoReleaser-style "<sha256> <filename>"
// manifest and returns the lowercase hex SHA-256 for assetName. Returns an
// error if the asset is absent so a typo (or the wrong manifest from a
// different release) fails closed rather than silently disabling
// verification.
func parseChecksumManifest(manifest []byte, assetName string) (string, error) {
scanner := bufio.NewScanner(bytes.NewReader(manifest))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
// GoReleaser's default separator is two spaces; some tools use one
// or pad with tabs. strings.Fields handles all of those at once.
if len(fields) < 2 {
continue
}
if fields[1] == assetName {
return strings.ToLower(fields[0]), nil
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("read checksum manifest: %w", err)
}
return "", fmt.Errorf("checksum for %q not found in manifest", assetName)
}
// verifyAssetSHA256 returns nil when the SHA-256 of data matches the lowercase
// hex expected value, or an error otherwise. The error includes both digests
// so a corrupted asset is diagnosable from the log without re-downloading.
func verifyAssetSHA256(data []byte, expectedHex, assetName string) error {
if expectedHex == "" {
return fmt.Errorf("empty expected checksum for %q", assetName)
}
sum := sha256.Sum256(data)
actual := hex.EncodeToString(sum[:])
if !strings.EqualFold(actual, expectedHex) {
return fmt.Errorf("checksum mismatch for %q: expected %s, got %s", assetName, expectedHex, actual)
}
return nil
}
func fetchReleaseByTag(tag string) (*GitHubRelease, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/tags/"+tag, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
var release GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
// FetchLatestRelease fetches the latest release tag from the multica GitHub repo.
func FetchLatestRelease() (*GitHubRelease, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/latest", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
var release GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, err
}
return &release, nil
}
// knownBrewPrefixes lists the install roots Homebrew uses on each platform.
// Order is irrelevant — the prefixes do not nest.
var knownBrewPrefixes = []string{"/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"}
// MatchKnownBrewPrefix returns the Homebrew prefix whose Cellar contains path,
// or "" if path is not under a known Cellar. It is the offline equivalent of
// `brew --prefix`: callers reach for it when `brew --prefix` is unavailable
// (brew not on PATH) but the binary's path still betrays its install root.
func MatchKnownBrewPrefix(path string) string {
for _, prefix := range knownBrewPrefixes {
if strings.HasPrefix(path, prefix+"/Cellar/") {
return prefix
}
}
return ""
}
// IsBrewInstall checks whether the running multica binary was installed via Homebrew.
func IsBrewInstall() bool {
exePath, err := os.Executable()
if err != nil {
return false
}
resolved, err := filepath.EvalSymlinks(exePath)
if err != nil {
resolved = exePath
}
brewPrefix := GetBrewPrefix()
if brewPrefix != "" && strings.HasPrefix(resolved, brewPrefix) {
return true
}
return MatchKnownBrewPrefix(resolved) != ""
}
// GetBrewPrefix returns the Homebrew prefix by running `brew --prefix`, or empty string.
func GetBrewPrefix() string {
out, err := exec.Command("brew", "--prefix").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// UpdateViaBrew runs `brew upgrade multica-ai/tap/multica`.
// Returns the combined output and any error.
func UpdateViaBrew() (string, error) {
cmd := exec.Command("brew", "upgrade", "multica-ai/tap/multica")
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("brew upgrade failed: %w", err)
}
return string(out), nil
}
func updateDownloadTimeoutOrDefault(timeout time.Duration) time.Duration {
if timeout <= 0 {
return DefaultUpdateDownloadTimeout
}
return timeout
}
// fetchURLBytes does a GET with the given timeout and returns the response
// body in full. Used for the checksum manifest (tiny) and the release
// archive (single-digit MB). The checksum verification path requires buffered
// bytes so streaming would just push the buffer into the caller anyway.
func fetchURLBytes(url string, timeout time.Duration) ([]byte, error) {
client := &http.Client{Timeout: updateDownloadTimeoutOrDefault(timeout)}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
}
return io.ReadAll(resp.Body)
}
// UpdateViaDownload downloads the latest release binary from GitHub and replaces
// the current executable in-place. Returns the combined output message and any error.
func UpdateViaDownload(targetVersion string) (string, error) {
return UpdateViaDownloadWithTimeout(targetVersion, DefaultUpdateDownloadTimeout)
}
// UpdateViaDownloadWithTimeout downloads the latest release binary with a caller-selected timeout.
func UpdateViaDownloadWithTimeout(targetVersion string, downloadTimeout time.Duration) (string, error) {
// Determine current binary path.
exePath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("resolve executable path: %w", err)
}
exePath, err = filepath.EvalSymlinks(exePath)
if err != nil {
return "", fmt.Errorf("resolve symlink: %w", err)
}
tag := normalizeReleaseTag(targetVersion)
release, err := fetchReleaseByTag(tag)
if err != nil {
return "", fmt.Errorf("fetch release metadata: %w", err)
}
asset, err := findReleaseAsset(release.Assets, tag, runtime.GOOS, runtime.GOARCH)
if err != nil {
return "", err
}
manifestAsset, err := findChecksumManifestAsset(release.Assets)
if err != nil {
return "", err
}
downloadURL := asset.BrowserDownloadURL
assetName := asset.Name
// Pull the checksum manifest first so a release that is half-published
// (archives uploaded but checksums.txt not yet) fails before we eat the
// archive's bandwidth.
timeout := updateDownloadTimeoutOrDefault(downloadTimeout)
manifestData, err := fetchURLBytes(manifestAsset.BrowserDownloadURL, timeout)
if err != nil {
return "", fmt.Errorf("download checksum manifest: %w", err)
}
expectedSum, err := parseChecksumManifest(manifestData, assetName)
if err != nil {
return "", fmt.Errorf("parse checksum manifest: %w", err)
}
// Buffer the archive into memory so we can verify the full SHA-256
// before writing anything to disk. Release archives are ~1030 MB; the
// extraction code already buffers zip archives in full (random access
// requirement), so this is not a new memory cost on Windows. For tar.gz
// it adds a single in-RAM copy, which is preferable to running the
// untrusted bytes through gzip+tar extraction before the SHA-256 check.
archiveData, err := fetchURLBytes(downloadURL, timeout)
if err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
if err := verifyAssetSHA256(archiveData, expectedSum, assetName); err != nil {
// Do NOT extract or replace; the next poll tick will retry. A
// corrupted asset is rare enough that retrying through the same
// CDN is the right default; persistent failures will surface in
// the daemon log.
return "", fmt.Errorf("verify download: %w", err)
}
// Extract the binary from the archive.
binaryName := "multica"
if runtime.GOOS == "windows" {
binaryName = "multica.exe"
}
var binaryData []byte
if runtime.GOOS == "windows" {
binaryData, err = extractBinaryFromZip(bytes.NewReader(archiveData), binaryName)
} else {
binaryData, err = extractBinaryFromTarGz(bytes.NewReader(archiveData), binaryName)
}
if err != nil {
return "", fmt.Errorf("extract binary: %w", err)
}
// Atomic replace: write to temp file, then rename over the original.
dir := filepath.Dir(exePath)
tmpFile, err := os.CreateTemp(dir, "multica-update-*")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.Write(binaryData); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
return "", fmt.Errorf("write temp file: %w", err)
}
tmpFile.Close()
// Preserve original file permissions.
info, err := os.Stat(exePath)
if err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("stat original binary: %w", err)
}
if err := os.Chmod(tmpPath, info.Mode()); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("chmod temp file: %w", err)
}
// Replace the original binary. On Windows this moves the running executable
// aside first; on Unix a plain rename over the running inode is fine.
if err := replaceBinary(tmpPath, exePath); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("replace binary: %w", err)
}
return fmt.Sprintf("Downloaded %s and replaced %s", assetName, exePath), nil
}
// extractBinaryFromTarGz reads a .tar.gz stream and returns the contents of the
// named file entry.
func extractBinaryFromTarGz(r io.Reader, name string) ([]byte, error) {
gz, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("gzip reader: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil, fmt.Errorf("binary %q not found in archive", name)
}
if err != nil {
return nil, fmt.Errorf("read tar: %w", err)
}
// Match the binary name (may be prefixed with a directory).
if filepath.Base(hdr.Name) == name && hdr.Typeflag == tar.TypeReg {
data, err := io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("read binary: %w", err)
}
return data, nil
}
}
}
// extractBinaryFromZip reads a .zip stream and returns the contents of the
// named file entry. The zip format requires random access, so the full archive
// is buffered in memory.
func extractBinaryFromZip(r io.Reader, name string) ([]byte, error) {
buf, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("read zip data: %w", err)
}
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
if err != nil {
return nil, fmt.Errorf("zip reader: %w", err)
}
for _, f := range zr.File {
if filepath.Base(f.Name) == name && !f.FileInfo().IsDir() {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("open zip entry: %w", err)
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return nil, fmt.Errorf("read binary: %w", err)
}
return data, nil
}
}
return nil, fmt.Errorf("binary %q not found in archive", name)
}