diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9adcef7..d2cfdb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,17 +50,10 @@ jobs: - name: install tmux run: sudo apt-get update && sudo apt-get install -y tmux - name: build linux binary - run: go build -o sleeperagent-linux ./cmd/sleeperagent + # The version stamp matters: integration_update.sh exercises the + # self-update flow, which a "dev" build refuses by design. + run: go build -ldflags "-X main.version=0.0.0-test" -o sleeperagent-linux ./cmd/sleeperagent - name: run integration scripts - run: | - set -e - for s in test/integration.sh \ - test/integration_m2.sh \ - test/integration_m2_autodetach.sh \ - test/integration_attach.sh \ - test/integration_codex.sh \ - test/integration_reprompt.sh \ - test/integration_pty.sh; do - echo "=== $s ===" - bash "$s" - done + # run_all.sh is the single source of truth for the suite, so new + # scripts can't silently drift out of CI. + run: bash test/run_all.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a4f6c..6c4039c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.5.0] - 2026-07-10 + +### Added +- **Self-update** — new `sleeperagent update` command downloads the latest + GitHub release for your OS/arch, verifies it against the release's + `checksums.txt`, and atomically replaces the executable (on Windows the + running exe is parked as `.old` and swept up on the next start). `--check` + only reports. Release builds also offer this at startup, Codex-style: + `run`/`attach-existing` from a real terminal checks for a newer release at + most once a day and asks `Update now? [Y/n]` before the session launches. + The check is skipped for source (`dev`) builds and non-TTY runs, never + blocks startup on a slow or offline network (3s timeout, result cached), + and can be disabled with `check = false` under `[update]` in config.toml or + the `SLEEPERAGENT_NO_UPDATE_CHECK` env var. A failed or declined update + never touches the existing binary. + +### Fixed +- CI now runs the integration suite through `test/run_all.sh`, so new + integration scripts can't silently drift out of CI (dead-session and the + interactive-attach tests were missing from the workflow's hand-kept list). + ## [0.4.0] - 2026-07-10 ### Changed @@ -113,6 +134,7 @@ upgrading. times are cleared once the agent resumes, so `status` reports `RUNNING` with no leftover countdown. -[Unreleased]: https://github.com/amanjaiman/sleeperagent/compare/v0.4.0...main +[Unreleased]: https://github.com/amanjaiman/sleeperagent/compare/v0.5.0...main +[0.5.0]: https://github.com/amanjaiman/sleeperagent/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/amanjaiman/sleeperagent/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/amanjaiman/sleeperagent/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index aef265f..d39e604 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ Put it on your `PATH`: If the install directory is not already on `PATH`, the command prints the exact `setx PATH` or `export PATH` line to run, plus a reminder to open a new shell. On macOS/Linux, `install` also tries to add that PATH update to your shell profile automatically (zsh, bash, and sh; other shells get the printed line only). Pass `--no-profile` to skip that and just print the line yourself. +**Staying up to date** — release builds check GitHub for a newer version when you start a run from a real terminal (at most once a day) and offer to install it; answer `Y` and the binary replaces itself, taking effect on the next start. You can also update explicitly any time: + +```bash +sleeperagent update # or --check to only report +``` + +Disable the startup check with `check = false` under `[update]` in config.toml, or `SLEEPERAGENT_NO_UPDATE_CHECK=1`. + **With the Go toolchain:** ```bash @@ -121,6 +129,7 @@ On every platform, `run` from a real terminal drops you **straight into the live | `agents [--config P]` | List configured adapters and validate that their patterns compile. | | `parse --agent A "text…"` | Test a captured limit string against an agent's patterns and show the resolved reset. | | `install [--dir DIR] [--force] [--no-profile]` | Copy this binary to a PATH directory. | +| `update [--check]` | Update to the latest GitHub release (checksum-verified); `--check` only reports. | | `version` | Print the build version. | ### `run` flags diff --git a/cmd/sleeperagent/main.go b/cmd/sleeperagent/main.go index 7ce3909..2634ab9 100644 --- a/cmd/sleeperagent/main.go +++ b/cmd/sleeperagent/main.go @@ -5,6 +5,7 @@ package main import ( + "bufio" "context" "flag" "fmt" @@ -33,6 +34,7 @@ import ( "github.com/amanjaiman/sleeperagent/internal/statefile" "github.com/amanjaiman/sleeperagent/internal/supervisor" "github.com/amanjaiman/sleeperagent/internal/tmux" + "github.com/amanjaiman/sleeperagent/internal/update" ) // version is overridden at build time via -ldflags "-X main.version=...". @@ -40,6 +42,10 @@ var version = "dev" func main() { log.SetFlags(log.Ltime) + // A Windows self-update leaves the previous exe parked as ".old" + // (a running exe can't be overwritten); sweep it up now that we're the + // new binary. + update.CleanupOld() if len(os.Args) < 2 { usage() os.Exit(2) @@ -50,6 +56,8 @@ func main() { err = runCmd(os.Args[2:]) case "attach-existing": err = attachExistingCmd(os.Args[2:]) + case "update": + err = updateCmd(os.Args[2:]) case "agents": err = agentsCmd(os.Args[2:]) case "parse": @@ -94,6 +102,7 @@ Usage: sleeperagent detach --name NAME stop watching, keep session sleeperagent stop --name NAME [--kill] stop watching (optionally kill) sleeperagent rm --name NAME [--force] | --all remove a stale/ended instance record + sleeperagent update [--check] update to the latest release (--check: only report) sleeperagent version print version Run flags: @@ -151,6 +160,7 @@ func runCmd(args []string) error { if err != nil { return err } + maybeOfferUpdate(cfg) ad, err := cfg.Adapter(*agent) if err != nil { return err @@ -570,6 +580,89 @@ func watchSession(p watchParams) error { return nil } +// updateCmd checks the latest GitHub release and (unless --check) replaces the +// current executable with it, after verifying the download against the +// release's checksums.txt. +func updateCmd(args []string) error { + fs := flag.NewFlagSet("update", flag.ContinueOnError) + checkOnly := fs.Bool("check", false, "only report whether an update is available") + if err := fs.Parse(args); err != nil { + return err + } + cl := update.New() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + latest, err := cl.LatestVersion(ctx) + cancel() + if err != nil { + return fmt.Errorf("could not determine the latest release: %w", err) + } + if !update.Parseable(version) { + return fmt.Errorf("this build reports version %q (built from source?) — update with "+ + "`go install github.com/amanjaiman/sleeperagent/cmd/sleeperagent@%s` or download a release binary", + version, latest) + } + update.RecordCheck(latest) + if !update.Newer(version, latest) { + fmt.Printf("sleeperagent %s is up to date (latest release: %s)\n", version, latest) + return nil + } + if *checkOnly { + fmt.Printf("update available: %s (you have %s). Run `sleeperagent update` to install it.\n", latest, version) + return nil + } + fmt.Printf("updating %s → %s ...\n", version, latest) + ctx, cancel = context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if err := cl.Apply(ctx, latest); err != nil { + return err + } + fmt.Printf("updated to %s. Running instances keep their current version until restarted.\n", latest) + return nil +} + +// maybeOfferUpdate is the Codex-style startup check: at most once a day, only +// in a real terminal, opt-out via config or env, and never blocking startup on +// a slow or offline network. Runs before the session launches so the prompt +// doesn't fight the agent for the terminal. +func maybeOfferUpdate(cfg config.Config) { + if !update.Parseable(version) || !cfg.UpdateCheckEnabled() || os.Getenv("SLEEPERAGENT_NO_UPDATE_CHECK") != "" { + return + } + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { + return + } + if !update.ShouldCheck(24 * time.Hour) { + return + } + cl := update.New() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + latest, err := cl.LatestVersion(ctx) + cancel() + // Record the attempt even on failure so an offline machine isn't re-stalled + // (up to the timeout) on every startup. + update.RecordCheck(latest) + if err != nil || !update.Newer(version, latest) { + return + } + fmt.Printf("sleeperagent %s is available (you have %s). Update now? [Y/n] ", latest, version) + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "", "y", "yes": + default: + return + } + ctx, cancel = context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if err := cl.Apply(ctx, latest); err != nil { + log.Printf("update failed (continuing with %s): %v", version, err) + return + } + log.Printf("updated to %s — takes effect on the next start; this run continues on %s", latest, version) +} + // buildBuilder constructs the resume-prompt builder (static or local-LLM). func buildBuilder(cfg config.Config, ad *adapter.Adapter, repromptSpec, resumeText string) (prompt.Builder, string, error) { if repromptSpec == "" { @@ -659,6 +752,7 @@ func attachExistingCmd(args []string) error { if err != nil { return err } + maybeOfferUpdate(cfg) ad, err := cfg.Adapter(*agent) if err != nil { return err diff --git a/config.example.toml b/config.example.toml index 7ec2c56..9108ef6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -45,6 +45,13 @@ limit_patterns = [ inject_style = "text-enter" # text, then Enter yolo_flag = "--dangerously-bypass-approvals-and-sandbox" +# Startup update check: on `run`/`attach-existing` from a real terminal, +# SleeperAgent asks GitHub (at most once a day) whether a newer release exists +# and offers to install it. Set check = false to disable, or set the +# SLEEPERAGENT_NO_UPDATE_CHECK env var. `sleeperagent update` always works. +[update] +check = true + # Local-LLM reprompt. Active only with `--reprompt ollama:`; otherwise the # static prompt is used. On any failure (server down, empty/over-long/denylisted # output) SleeperAgent falls back to the static prompt. diff --git a/internal/config/config.go b/internal/config/config.go index 89e6f10..20a69de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,6 +58,12 @@ type RepromptConfig struct { Denylist []string `toml:"denylist"` } +// UpdateConfig configures the startup update check. Check is a pointer so an +// absent key keeps the default (enabled) while `check = false` disables it. +type UpdateConfig struct { + Check *bool `toml:"check"` +} + // Config is the whole SleeperAgent configuration. type Config struct { PollInterval Duration `toml:"poll_interval"` @@ -65,6 +71,13 @@ type Config struct { MaxWait Duration `toml:"max_wait"` Agents map[string]AgentConfig `toml:"agents"` Reprompt RepromptConfig `toml:"reprompt"` + Update UpdateConfig `toml:"update"` +} + +// UpdateCheckEnabled reports whether the startup update check is on (the +// default when the config doesn't say otherwise). +func (c Config) UpdateCheckEnabled() bool { + return c.Update.Check == nil || *c.Update.Check } // Default returns the built-in configuration, including the Claude Code adapter. @@ -201,6 +214,9 @@ func overlay(base *Config, user Config) { if len(user.Reprompt.Denylist) != 0 { base.Reprompt.Denylist = user.Reprompt.Denylist } + if user.Update.Check != nil { + base.Update.Check = user.Update.Check + } } // Adapter compiles the named agent's config into a ready-to-use adapter. diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..c0ac3c2 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,337 @@ +// Package update implements the self-update flow: discover the newest GitHub +// release, compare it to the running version, and swap the executable for the +// freshly downloaded (checksum-verified) release binary. Checks are throttled +// through a small cache file so startup never spams the network. +package update + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/amanjaiman/sleeperagent/internal/statefile" +) + +// repoPath is the GitHub owner/name whose releases we track. +const repoPath = "amanjaiman/sleeperagent" + +// binaryName is the executable inside a release archive. +func binaryName() string { + if runtime.GOOS == "windows" { + return "sleeperagent.exe" + } + return "sleeperagent" +} + +// Client talks to the release host. BaseURL is normally https://github.com; +// SLEEPERAGENT_UPDATE_BASE_URL overrides it (tests, mirrors). +type Client struct { + BaseURL string + HTTP *http.Client +} + +// New returns a Client with sane timeouts. +func New() *Client { + base := "https://github.com" + if u := os.Getenv("SLEEPERAGENT_UPDATE_BASE_URL"); u != "" { + base = strings.TrimRight(u, "/") + } + return &Client{BaseURL: base, HTTP: &http.Client{Timeout: 60 * time.Second}} +} + +// LatestVersion returns the newest release tag (e.g. "v0.5.0"). It reads the +// redirect target of /releases/latest, which needs no API token and is not +// rate-limited like the REST API. +func (c *Client) LatestVersion(ctx context.Context) (string, error) { + url := c.BaseURL + "/" + repoPath + "/releases/latest" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + // Don't follow the redirect — its Location IS the answer. + client := *c.HTTP + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + loc := resp.Header.Get("Location") + if resp.StatusCode < 300 || resp.StatusCode > 399 || loc == "" { + return "", fmt.Errorf("expected a redirect from %s, got %s", url, resp.Status) + } + tag := path.Base(loc) + if _, _, _, ok := parseVersion(tag); !ok { + return "", fmt.Errorf("release redirect points at %q, which does not look like a version tag", loc) + } + return tag, nil +} + +// Parseable reports whether v looks like a release version ("0.5.0", +// "v0.5.0"). Source builds report "dev" (or a pseudo-version) and can't be +// meaningfully compared to releases. +func Parseable(v string) bool { + _, _, _, ok := parseVersion(v) + return ok +} + +// Newer reports whether latest is strictly newer than current. Unparseable +// versions (e.g. a "dev" source build) are never considered outdated. +func Newer(current, latest string) bool { + cmaj, cmin, cpat, ok := parseVersion(current) + if !ok { + return false + } + lmaj, lmin, lpat, ok := parseVersion(latest) + if !ok { + return false + } + if lmaj != cmaj { + return lmaj > cmaj + } + if lmin != cmin { + return lmin > cmin + } + return lpat > cpat +} + +// parseVersion accepts "v1.2.3", "1.2.3", or "1.2" (patch 0); anything after a +// "-" or "+" (pre-release/build metadata) is ignored. +func parseVersion(v string) (major, minor, patch int, ok bool) { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + parts := strings.Split(v, ".") + if len(parts) < 2 || len(parts) > 3 { + return 0, 0, 0, false + } + nums := make([]int, 3) + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return 0, 0, 0, false + } + nums[i] = n + } + return nums[0], nums[1], nums[2], true +} + +// AssetName is the release archive for this OS/arch, e.g. +// sleeperagent_0.5.0_darwin_arm64.tar.gz (zip on Windows). +func AssetName(version string) string { + ext := "tar.gz" + if runtime.GOOS == "windows" { + ext = "zip" + } + return fmt.Sprintf("sleeperagent_%s_%s_%s.%s", + strings.TrimPrefix(version, "v"), runtime.GOOS, runtime.GOARCH, ext) +} + +// Apply downloads the given release, verifies it against checksums.txt, and +// replaces the running executable. The running process keeps executing the old +// code; the new version takes effect on the next start. +func (c *Client) Apply(ctx context.Context, version string) error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("locate current executable: %w", err) + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return c.ApplyTo(ctx, version, exe) +} + +// ApplyTo is Apply targeting an explicit path (separated out for tests). +func (c *Client) ApplyTo(ctx context.Context, version, target string) error { + asset := AssetName(version) + base := c.BaseURL + "/" + repoPath + "/releases/download/" + version + "/" + + archive, err := c.download(ctx, base+asset) + if err != nil { + return fmt.Errorf("download %s: %w", asset, err) + } + sums, err := c.download(ctx, base+"checksums.txt") + if err != nil { + return fmt.Errorf("download checksums.txt: %w", err) + } + if err := verifyChecksum(sums, asset, archive); err != nil { + return err + } + bin, err := extractBinary(asset, archive) + if err != nil { + return err + } + return replaceExecutable(target, bin) +} + +func (c *Client) download(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", url, resp.Status) + } + return io.ReadAll(resp.Body) +} + +// verifyChecksum checks data against the asset's line in a goreleaser +// checksums.txt (" " per line). +func verifyChecksum(sums []byte, asset string, data []byte) error { + for _, line := range strings.Split(string(sums), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 || fields[1] != asset { + continue + } + got := sha256.Sum256(data) + if hex.EncodeToString(got[:]) != strings.ToLower(fields[0]) { + return fmt.Errorf("checksum mismatch for %s — refusing to install", asset) + } + return nil + } + return fmt.Errorf("no checksum entry for %s in checksums.txt", asset) +} + +// extractBinary pulls the sleeperagent executable out of a release archive. +func extractBinary(asset string, archive []byte) ([]byte, error) { + want := binaryName() + if strings.HasSuffix(asset, ".zip") { + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("open zip: %w", err) + } + for _, f := range zr.File { + if path.Base(f.Name) != want { + continue + } + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) + } + return nil, fmt.Errorf("%s not found in %s", want, asset) + } + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("open tar.gz: %w", err) + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil, fmt.Errorf("%s not found in %s", want, asset) + } + if err != nil { + return nil, err + } + if hdr.Typeflag == tar.TypeReg && path.Base(hdr.Name) == want { + return io.ReadAll(tr) + } + } +} + +// replaceExecutable swaps target for the new binary. On Unix a rename over the +// old path is atomic and safe while the old binary is running. Windows can't +// overwrite a running exe, so the running one is renamed aside to +// ".old" first (cleaned up on the next start; see CleanupOld). +func replaceExecutable(target string, bin []byte) error { + dir := filepath.Dir(target) + tmp, err := os.CreateTemp(dir, ".sleeperagent-update-*") + if err != nil { + return fmt.Errorf("stage new binary (is %s writable?): %w", dir, err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op after a successful rename + if _, err := tmp.Write(bin); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpName, 0o755); err != nil { + return err + } + if runtime.GOOS == "windows" { + old := target + ".old" + _ = os.Remove(old) + if err := os.Rename(target, old); err != nil { + return fmt.Errorf("move running executable aside: %w", err) + } + if err := os.Rename(tmpName, target); err != nil { + // Try to put the old binary back so the install isn't left broken. + _ = os.Rename(old, target) + return fmt.Errorf("install new executable: %w", err) + } + return nil + } + if err := os.Rename(tmpName, target); err != nil { + return fmt.Errorf("install new executable: %w", err) + } + return nil +} + +// CleanupOld removes the ".old" left behind by a Windows self-update. +// Best-effort: it fails silently while an old instance still runs. +func CleanupOld() { + if exe, err := os.Executable(); err == nil { + _ = os.Remove(exe + ".old") + } +} + +// checkCache throttles the startup update check. +type checkCache struct { + CheckedAt time.Time `json:"checked_at"` + Latest string `json:"latest"` +} + +func cachePath() string { return filepath.Join(statefile.Dir(), "update-check.json") } + +// ShouldCheck reports whether the last check is older than maxAge. A missing +// or unreadable cache means "check now". +func ShouldCheck(maxAge time.Duration) bool { + b, err := os.ReadFile(cachePath()) + if err != nil { + return true + } + var c checkCache + if json.Unmarshal(b, &c) != nil { + return true + } + return time.Since(c.CheckedAt) >= maxAge +} + +// RecordCheck persists the result (or attempt) of an update check so the next +// startup within maxAge skips the network entirely. +func RecordCheck(latest string) { + b, err := json.Marshal(checkCache{CheckedAt: time.Now(), Latest: latest}) + if err != nil { + return + } + _ = os.MkdirAll(filepath.Dir(cachePath()), 0o755) + _ = os.WriteFile(cachePath(), b, 0o644) +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..dc89107 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,251 @@ +package update + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestNewer(t *testing.T) { + cases := []struct { + current, latest string + want bool + }{ + {"0.4.0", "v0.5.0", true}, + {"v0.4.0", "v0.4.1", true}, + {"0.4.0", "v1.0.0", true}, + {"0.4.0", "v0.4.0", false}, + {"0.5.0", "v0.4.9", false}, + {"1.0.0", "v0.9.9", false}, + {"dev", "v0.5.0", false}, // source build: never nag + {"0.4.0", "some-tag", false}, // garbage latest + {"0.4.0", "v0.4", false}, // 0.4 == 0.4.0 + {"0.4.0", "v0.5.0-rc1", true}, // pre-release suffix ignored + {"0.4.0-12-gdeadbee", "v0.4.1", true}, // git-describe style current + } + for _, c := range cases { + if got := Newer(c.current, c.latest); got != c.want { + t.Errorf("Newer(%q, %q) = %v, want %v", c.current, c.latest, got, c.want) + } + } +} + +func TestParseable(t *testing.T) { + for v, want := range map[string]bool{ + "0.4.0": true, "v0.5.0": true, "0.4": true, + "dev": false, "": false, "abc.def": false, + } { + if got := Parseable(v); got != want { + t.Errorf("Parseable(%q) = %v, want %v", v, got, want) + } + } +} + +func TestAssetName(t *testing.T) { + got := AssetName("v0.5.0") + wantPrefix := fmt.Sprintf("sleeperagent_0.5.0_%s_%s.", runtime.GOOS, runtime.GOARCH) + if !strings.HasPrefix(got, wantPrefix) { + t.Fatalf("AssetName = %q, want prefix %q", got, wantPrefix) + } + if runtime.GOOS == "windows" && !strings.HasSuffix(got, ".zip") { + t.Fatalf("windows asset should be a zip, got %q", got) + } + if runtime.GOOS != "windows" && !strings.HasSuffix(got, ".tar.gz") { + t.Fatalf("non-windows asset should be a tar.gz, got %q", got) + } +} + +func TestLatestVersionFollowsRedirect(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/"+repoPath+"/releases/latest" { + http.Redirect(w, r, srv_url(r)+"/"+repoPath+"/releases/tag/v9.9.9", http.StatusFound) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + cl := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + got, err := cl.LatestVersion(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != "v9.9.9" { + t.Fatalf("LatestVersion = %q, want v9.9.9", got) + } +} + +// srv_url reconstructs the test server's base URL from the request, so the +// redirect Location is absolute like GitHub's. +func srv_url(r *http.Request) string { return "http://" + r.Host } + +func TestLatestVersionRejectsNonVersionRedirect(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://"+r.Host+"/login", http.StatusFound) + })) + defer srv.Close() + cl := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + if _, err := cl.LatestVersion(context.Background()); err == nil { + t.Fatal("expected an error for a redirect that is not a version tag") + } +} + +// makeArchive builds a release archive for the current platform containing the +// platform's binary name with the given contents. +func makeArchive(t *testing.T, contents []byte) []byte { + t.Helper() + var buf bytes.Buffer + if runtime.GOOS == "windows" { + zw := zip.NewWriter(&buf) + f, err := zw.Create(binaryName()) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(contents); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() + } + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: binaryName(), Mode: 0o755, Size: int64(len(contents))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(contents); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestApplyToReplacesBinary(t *testing.T) { + newBin := []byte("new binary contents") + archive := makeArchive(t, newBin) + asset := AssetName("v9.9.9") + sum := sha256.Sum256(archive) + checksums := hex.EncodeToString(sum[:]) + " " + asset + "\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/"+asset): + w.Write(archive) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + w.Write([]byte(checksums)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + target := filepath.Join(t.TempDir(), binaryName()) + if err := os.WriteFile(target, []byte("old binary"), 0o755); err != nil { + t.Fatal(err) + } + cl := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + if err := cl.ApplyTo(context.Background(), "v9.9.9", target); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, newBin) { + t.Fatalf("target = %q, want the new binary contents", got) + } + if runtime.GOOS == "windows" { + // The old exe is parked as .old (deleted on next start by CleanupOld). + if _, err := os.Stat(target + ".old"); err != nil { + t.Fatalf("windows update should leave %s.old behind: %v", target, err) + } + } +} + +func TestApplyToRejectsBadChecksum(t *testing.T) { + archive := makeArchive(t, []byte("evil")) + asset := AssetName("v9.9.9") + checksums := strings.Repeat("0", 64) + " " + asset + "\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/"+asset): + w.Write(archive) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + w.Write([]byte(checksums)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + target := filepath.Join(t.TempDir(), binaryName()) + if err := os.WriteFile(target, []byte("old binary"), 0o755); err != nil { + t.Fatal(err) + } + cl := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + if err := cl.ApplyTo(context.Background(), "v9.9.9", target); err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("want checksum mismatch error, got %v", err) + } + got, _ := os.ReadFile(target) + if string(got) != "old binary" { + t.Fatal("a failed update must not touch the existing binary") + } +} + +func TestApplyToRejectsMissingChecksumEntry(t *testing.T) { + archive := makeArchive(t, []byte("x")) + asset := AssetName("v9.9.9") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/"+asset): + w.Write(archive) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + w.Write([]byte("deadbeef something_else.tar.gz\n")) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + target := filepath.Join(t.TempDir(), binaryName()) + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + cl := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + if err := cl.ApplyTo(context.Background(), "v9.9.9", target); err == nil || !strings.Contains(err.Error(), "no checksum entry") { + t.Fatalf("want missing-entry error, got %v", err) + } +} + +func TestShouldCheckThrottles(t *testing.T) { + t.Setenv("SLEEPERAGENT_STATE_DIR", t.TempDir()) + if !ShouldCheck(24 * time.Hour) { + t.Fatal("no cache yet: should check") + } + RecordCheck("v0.5.0") + if ShouldCheck(24 * time.Hour) { + t.Fatal("fresh cache: should not check again") + } + if !ShouldCheck(0) { + t.Fatal("zero max age: should always check") + } +} diff --git a/test/integration_interactive_attach.sh b/test/integration_interactive_attach.sh index 022ad56..22dd218 100644 --- a/test/integration_interactive_attach.sh +++ b/test/integration_interactive_attach.sh @@ -9,6 +9,9 @@ set -uo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" BIN="$ROOT/sleeperagent-linux" export SLEEPERAGENT_STATE_DIR="$(mktemp -d)" +# This test gives the binary a real TTY, which would arm the startup update +# prompt on a version-stamped build; keep the test hermetic. +export SLEEPERAGENT_NO_UPDATE_CHECK=1 N="ak-ia-$$" fail=0 diff --git a/test/integration_interactive_second_client.sh b/test/integration_interactive_second_client.sh index e54a58d..7518556 100644 --- a/test/integration_interactive_second_client.sh +++ b/test/integration_interactive_second_client.sh @@ -8,6 +8,9 @@ set -uo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" BIN="$ROOT/sleeperagent-linux" export SLEEPERAGENT_STATE_DIR="$(mktemp -d)" +# This test gives the binary a real TTY, which would arm the startup update +# prompt on a version-stamped build; keep the test hermetic. +export SLEEPERAGENT_NO_UPDATE_CHECK=1 N="ak-i2c-$$" fail=0 diff --git a/test/integration_pty.sh b/test/integration_pty.sh index 0cfa2d3..bbe0ba7 100644 --- a/test/integration_pty.sh +++ b/test/integration_pty.sh @@ -12,6 +12,9 @@ HOOKLOG="$(mktemp)" CFG="$(mktemp --suffix=.toml)" AGENT="$(mktemp --suffix=.sh)" export SLEEPERAGENT_STATE_DIR="$(mktemp -d)" +# The supervisor runs under a pty here, which would arm the startup update +# prompt on a version-stamped build; keep the test hermetic. +export SLEEPERAGENT_NO_UPDATE_CHECK=1 cleanup() { [ -n "${HOOK_PID:-}" ] && kill "$HOOK_PID" 2>/dev/null diff --git a/test/integration_update.sh b/test/integration_update.sh new file mode 100644 index 0000000..32eb3e6 --- /dev/null +++ b/test/integration_update.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Verifies the self-update flow end to end against a fake release server: +# `update --check` reports the newer version, `update` downloads the asset, +# verifies it against checksums.txt, and atomically replaces the executable. +# Requires sleeperagent-linux to be built with a version stamp, e.g. +# go build -ldflags "-X main.version=0.0.0-test" -o sleeperagent-linux ./cmd/sleeperagent +# (a "dev" build refuses to self-update by design). +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$ROOT/sleeperagent-linux" +TMP="$(mktemp -d)" +export SLEEPERAGENT_STATE_DIR="$TMP/state" +fail=0 +SRV_PID="" + +cleanup() { [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null; rm -rf "$TMP"; } +trap cleanup EXIT +check() { if eval "$2"; then echo " ok: $1"; else echo " FAIL: $1"; fail=1; fi; } + +if ! "$BIN" version | grep -qE '[0-9]+\.[0-9]+'; then + echo "SKIP-FAIL: $BIN reports a non-semver version; build it with -ldflags '-X main.version=0.0.0-test'" + echo "RESULT: FAIL" + exit 1 +fi + +case "$(uname -m)" in + aarch64|arm64) ARCH=arm64 ;; + x86_64) ARCH=amd64 ;; + *) echo "unsupported test arch $(uname -m)"; echo "RESULT: FAIL"; exit 1 ;; +esac +ASSET="sleeperagent_9.9.9_linux_${ARCH}.tar.gz" + +echo "== build a fake v9.9.9 release ==" +mkdir -p "$TMP/payload" +printf '#!/bin/sh\necho fake-v9.9.9\n' > "$TMP/payload/sleeperagent" +chmod +x "$TMP/payload/sleeperagent" +DL="$TMP/srv/amanjaiman/sleeperagent/releases/download/v9.9.9" +mkdir -p "$DL" +tar -C "$TMP/payload" -czf "$DL/$ASSET" sleeperagent +(cd "$DL" && sha256sum "$ASSET" > checksums.txt) + +PORT=$((21000 + RANDOM % 20000)) +python3 - "$TMP/srv" "$PORT" <<'EOF' & +import http.server, socketserver, sys +root, port = sys.argv[1], int(sys.argv[2]) +class H(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=root, **kw) + def do_GET(self): + if self.path.rstrip('/').endswith('/releases/latest'): + self.send_response(302) + self.send_header('Location', + 'http://127.0.0.1:%d/amanjaiman/sleeperagent/releases/tag/v9.9.9' % port) + self.end_headers() + return + super().do_GET() + def log_message(self, *a): pass +socketserver.TCPServer.allow_reuse_address = True +with socketserver.TCPServer(('127.0.0.1', port), H) as s: + s.serve_forever() +EOF +SRV_PID=$! +up=0 +for _ in $(seq 1 40); do + if bash -c "exec 3<>/dev/tcp/127.0.0.1/$PORT" 2>/dev/null; then up=1; break; fi + sleep 0.25 +done +check "fake release server up" '[ "$up" -eq 1 ]' +export SLEEPERAGENT_UPDATE_BASE_URL="http://127.0.0.1:$PORT" + +echo "== update --check reports without installing ==" +cp "$BIN" "$TMP/sleeperagent" +OUT="$("$TMP/sleeperagent" update --check 2>&1)" +echo "$OUT" +check "check reports v9.9.9" 'echo "$OUT" | grep -q "update available: v9.9.9"' +check "check does not install" '! "$TMP/sleeperagent" 2>/dev/null | grep -q fake-v9.9.9' + +echo "== update installs and swaps the executable ==" +OUT="$("$TMP/sleeperagent" update 2>&1)" +echo "$OUT" +check "update reports success" 'echo "$OUT" | grep -q "updated to v9.9.9"' +check "executable replaced" '[ "$("$TMP/sleeperagent")" = "fake-v9.9.9" ]' + +echo "== a corrupted asset is refused ==" +cp "$BIN" "$TMP/sleeperagent2" +printf 'tampered' >> "$DL/$ASSET" # checksum no longer matches +OUT="$("$TMP/sleeperagent2" update 2>&1)" +echo "$OUT" +check "tampered download rejected" 'echo "$OUT" | grep -qi "checksum mismatch"' +check "binary untouched on failure" '"$TMP/sleeperagent2" version | grep -qE "[0-9]+\.[0-9]+"' + +if [ "$fail" -eq 0 ]; then echo "RESULT: PASS"; else echo "RESULT: FAIL"; fi +exit "$fail" diff --git a/test/run_all.sh b/test/run_all.sh index 6158691..9eb1a21 100644 --- a/test/run_all.sh +++ b/test/run_all.sh @@ -1,17 +1,21 @@ #!/usr/bin/env bash # Run every integration script and summarize. Needs tmux + python3. cd "$(dirname "$0")/.." +# Never let the startup update prompt reach out to real GitHub from tests +# (integration_update.sh overrides the base URL to its own fake server). +export SLEEPERAGENT_NO_UPDATE_CHECK=1 fail=0 for s in integration integration_m2 integration_m2_autodetach \ integration_attach integration_codex integration_reprompt integration_pty \ integration_dead_session integration_interactive_attach \ - integration_interactive_second_client; do + integration_interactive_second_client integration_update; do sed -i 's/\r$//' "test/$s.sh" 2>/dev/null printf '%-30s ' "$s" if bash "test/$s.sh" >"/tmp/$s.out" 2>&1 && grep -q 'RESULT: PASS' "/tmp/$s.out"; then echo PASS else - echo "FAIL (see /tmp/$s.out)" + echo "FAIL — output:" + sed -n '1,200p' "/tmp/$s.out" fail=1 fi done