diff --git a/cmd/root/root.go b/cmd/root/root.go
index 24e9b95c8..969c3b5ac 100644
--- a/cmd/root/root.go
+++ b/cmd/root/root.go
@@ -19,6 +19,7 @@ import (
 	"github.com/docker/docker-agent/pkg/feedback"
 	"github.com/docker/docker-agent/pkg/logging"
 	"github.com/docker/docker-agent/pkg/paths"
+	"github.com/docker/docker-agent/pkg/selfupdate"
 	"github.com/docker/docker-agent/pkg/telemetry"
 	"github.com/docker/docker-agent/pkg/version"
 )
@@ -168,6 +169,16 @@ We collect anonymous usage data to help improve docker agent. To disable:
 }
 
 func Execute(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args ...string) error {
+	selfupdate.Cleanup(ctx)
+
+	// Opt-in self-update: when enabled, replace this binary with the latest
+	// release and re-exec before doing any real work. Skipped for invocations
+	// where restarting would be wrong (plugin metadata handshake, shell
+	// completion) and always falls back to the current binary on any failure.
+	if selfupdate.Enabled() && !isManagementInvocation(args) {
+		selfupdate.Run(ctx, stdin, stderr)
+	}
+
 	rootCmd := NewRootCmd()
 	rootCmd.SetIn(stdin)
 	rootCmd.SetOut(stdout)
@@ -221,6 +232,32 @@ func visitAll(cmd *cobra.Command, fn func(*cobra.Command)) {
 	}
 }
 
+// isManagementInvocation reports whether args correspond to an invocation that
+// must not trigger a self-update + restart: the docker CLI plugin metadata
+// handshake, shell-completion script generation, and the version/help queries.
+// Updating mid-handshake would corrupt the plugin protocol, and restarting a
+// completion call would be surprising.
+//
+// Help and version are detected anywhere in args, not just at args[0], so that
+// per-subcommand help (e.g. "run --help") is also skipped.
+func isManagementInvocation(args []string) bool {
+	if len(args) == 0 {
+		return false
+	}
+	switch args[0] {
+	case metadata.MetadataSubcommandName, cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd, "completion", "version", "help", "--version":
+		return true
+	}
+	// A help request can appear after a subcommand ("run --help"); never update
+	// just to print help text.
+	for _, arg := range args {
+		if arg == "--help" || arg == "-h" {
+			return true
+		}
+	}
+	return false
+}
+
 // setupLogging configures slog logging behavior.
 // When --debug is enabled, logs are written to a rotating file <dataDir>/cagent.debug.log,
 // or to the file specified by --log-file. Log files are rotated when they exceed 10MB,
diff --git a/cmd/root/selfupdate_test.go b/cmd/root/selfupdate_test.go
new file mode 100644
index 000000000..a12b70c10
--- /dev/null
+++ b/cmd/root/selfupdate_test.go
@@ -0,0 +1,39 @@
+package root
+
+import (
+	"testing"
+
+	"github.com/docker/cli/cli-plugins/metadata"
+	"github.com/spf13/cobra"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestIsManagementInvocation(t *testing.T) {
+	t.Parallel()
+
+	for _, args := range [][]string{
+		{metadata.MetadataSubcommandName},
+		{cobra.ShellCompRequestCmd},
+		{cobra.ShellCompNoDescRequestCmd},
+		{"completion", "bash"},
+		{"version"},
+		{"help"},
+		{"--help"},
+		{"-h"},
+		{"--version"},
+		{"run", "--help"},
+		{"run", "agent.yaml", "-h"},
+		{"share", "push", "--help"},
+	} {
+		assert.True(t, isManagementInvocation(args), "args %v", args)
+	}
+
+	for _, args := range [][]string{
+		nil,
+		{},
+		{"run", "agent.yaml"},
+		{"new"},
+	} {
+		assert.False(t, isManagementInvocation(args), "args %v", args)
+	}
+}
diff --git a/docs/configuration/overview/index.md b/docs/configuration/overview/index.md
index 2edf4b9b8..d48b55e8f 100644
--- a/docs/configuration/overview/index.md
+++ b/docs/configuration/overview/index.md
@@ -197,6 +197,7 @@ API keys and secrets are read from environment variables — never stored in con
 | `DOCKER_AGENT_DEFAULT_MODEL`        | Default model used when none is specified, in `provider/model` form (e.g. `openai/gpt-5-mini`).      |
 | `DOCKER_AGENT_MODELS_GATEWAY`       | Route model traffic through a gateway. Equivalent to the `--models-gateway` flag.                    |
 | `DOCKER_AGENT_HIDE_TELEMETRY_BANNER`| Set to `1` to suppress the first-run telemetry notice.                                               |
+| `DOCKER_AGENT_AUTO_UPDATE`          | Set to a truthy value (`1`, `true`, `yes`, `on`) to let standalone release binaries self-update before running. See [Optional Self-Updates]({{ '/getting-started/installation/#optional-self-updates' | relative_url }}). |
 
 <div class="callout callout-info" markdown="1">
 <div class="callout-title">Legacy <code>CAGENT_*</code> aliases
diff --git a/docs/getting-started/installation/index.md b/docs/getting-started/installation/index.md
index 39f82ac05..fd960de8a 100644
--- a/docs/getting-started/installation/index.md
+++ b/docs/getting-started/installation/index.md
@@ -67,6 +67,30 @@ docker agent version
 
 Download `docker-agent-windows-amd64.exe` from the [releases page](https://github.com/docker/docker-agent/releases), rename it to `docker-agent.exe` and add it to your PATH. Alternatively you can move it to `~/.docker/cli-plugins`
 
+## Optional Self-Updates
+
+When docker-agent is installed from a standalone GitHub release binary, you can opt in to automatic self-updates by setting `DOCKER_AGENT_AUTO_UPDATE` to a truthy value (`1`, `true`, `yes`, or `on`):
+
+```bash
+# Enable for one command
+DOCKER_AGENT_AUTO_UPDATE=1 docker agent run
+
+# Or enable for the current shell session
+export DOCKER_AGENT_AUTO_UPDATE=1
+docker agent run
+```
+
+With self-updates enabled, docker-agent checks the latest GitHub release before normal commands run. If a newer release exists and your session is interactive, docker-agent asks whether you want to install it or keep running your current version. When the answer is yes (or the session is non-interactive, such as CI or piped input, in which case the update proceeds automatically), it downloads the asset for your OS and architecture, verifies the release-provided SHA-256 digest/checksum, replaces the current binary, and restarts the command with the same arguments.
+
+Self-updates are fail-safe: if checking, downloading, verifying, installing, or restarting fails, docker-agent keeps running the current binary. Version/help/completion commands and Docker CLI plugin metadata handshakes do not trigger self-updates.
+
+<div class="callout callout-info" markdown="1">
+<div class="callout-title">Package-manager installs
+</div>
+  <p>Docker Desktop and Homebrew already manage docker-agent updates. Prefer those update mechanisms when you installed docker-agent that way. Self-updates are mainly intended for standalone release binaries.</p>
+
+</div>
+
 ## Build from Source
 
 For the latest features, or to contribute, build from source:
diff --git a/go.mod b/go.mod
index ad058086b..7c4ab68cd 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
 	charm.land/lipgloss/v2 v2.0.3
 	github.com/99designs/keyring v1.2.2
 	github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.1
+	github.com/Masterminds/semver/v3 v3.2.1
 	github.com/Microsoft/go-winio v0.6.2
 	github.com/a2aproject/a2a-go v0.3.15
 	github.com/alecthomas/chroma/v2 v2.26.1
diff --git a/pkg/selfupdate/exec_unix.go b/pkg/selfupdate/exec_unix.go
new file mode 100644
index 000000000..9d754b80d
--- /dev/null
+++ b/pkg/selfupdate/exec_unix.go
@@ -0,0 +1,44 @@
+//go:build !windows
+
+package selfupdate
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"syscall"
+)
+
+// swapBinary replaces dst with src on Unix. A running executable's inode can be
+// replaced while it is in use, so a rename within the same filesystem is both
+// safe and atomic. If src and dst live on different filesystems (the temp-dir
+// fallback), os.Rename fails with EXDEV and we copy-then-replace instead.
+func swapBinary(dst, src string) error {
+	if err := os.Rename(src, dst); err == nil {
+		return nil
+	} else if !errors.Is(err, syscall.EXDEV) {
+		return err
+	}
+	// Cross-filesystem fallback: copy the contents atomically.
+	if err := atomicWriteFromFile(dst, src); err != nil {
+		return err
+	}
+	_ = os.Remove(src)
+	return nil
+}
+
+// reExecProcess replaces the current process image with path using execve.
+// On success it never returns: the new binary inherits our PID, file
+// descriptors and terminal, so the user sees a seamless restart.
+func reExecProcess(path string, args, env []string) error {
+	if len(args) == 0 {
+		args = []string{path}
+	} else {
+		// argv[0] should be the new binary's path.
+		args = append([]string{path}, args[1:]...)
+	}
+	if err := syscall.Exec(path, args, env); err != nil {
+		return fmt.Errorf("exec %s: %w", path, err)
+	}
+	return nil // unreachable on success
+}
diff --git a/pkg/selfupdate/exec_windows.go b/pkg/selfupdate/exec_windows.go
new file mode 100644
index 000000000..6517efcc9
--- /dev/null
+++ b/pkg/selfupdate/exec_windows.go
@@ -0,0 +1,77 @@
+//go:build windows
+
+package selfupdate
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+)
+
+// swapBinary replaces dst with src on Windows.
+//
+// Windows locks a running executable's file, so we cannot overwrite dst
+// directly. Instead we rename the running binary out of the way (to a sidecar
+// ".old" path, which Windows allows even while the file is mapped) and move the
+// new binary into its place. The stale ".old" file is best-effort cleaned up;
+// if it is still locked it lingers harmlessly until the next run.
+func swapBinary(dst, src string) error {
+	old := dst + ".old"
+	_ = os.Remove(old)
+
+	if err := os.Rename(dst, old); err != nil {
+		return fmt.Errorf("moving current binary aside: %w", err)
+	}
+
+	if err := os.Rename(src, dst); err != nil {
+		if cpErr := atomicWriteFromFile(dst, src); cpErr != nil {
+			// Roll back so we never leave the install without a binary.
+			if rbErr := os.Rename(old, dst); rbErr != nil {
+				return fmt.Errorf("installing new binary: %w (copy fallback failed: %v; rollback also failed: %v)", err, cpErr, rbErr)
+			}
+			return fmt.Errorf("installing new binary: %w (copy fallback failed: %v)", err, cpErr)
+		}
+		_ = os.Remove(src)
+	}
+
+	_ = os.Remove(old)
+	return nil
+}
+
+// reExecProcess relaunches path as a child process inheriting our stdio, waits
+// for it, and exits with its status. Windows has no execve, so the parent
+// process must stay alive until the child finishes to preserve exit-code
+// semantics for the caller (e.g. a shell or the docker CLI).
+func reExecProcess(path string, args, env []string) error {
+	var childArgs []string
+	if len(args) > 1 {
+		childArgs = args[1:]
+	}
+
+	cmd := exec.Command(path, childArgs...) //nolint:gosec // path is our own freshly installed binary
+	cmd.Env = env
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	if err := cmd.Run(); err != nil {
+		var exitErr *exec.ExitError
+		if ok := asExitError(err, &exitErr); ok {
+			os.Exit(exitErr.ExitCode())
+		}
+		return fmt.Errorf("running updated binary: %w", err)
+	}
+
+	os.Exit(0)
+	return nil
+}
+
+// asExitError is a tiny helper kept separate so exec_unix.go does not need to
+// import errors solely for this Windows branch.
+func asExitError(err error, target **exec.ExitError) bool {
+	if e, ok := err.(*exec.ExitError); ok { //nolint:errorlint // direct type assertion is intentional here
+		*target = e
+		return true
+	}
+	return false
+}
diff --git a/pkg/selfupdate/selfupdate.go b/pkg/selfupdate/selfupdate.go
new file mode 100644
index 000000000..60d52b967
--- /dev/null
+++ b/pkg/selfupdate/selfupdate.go
@@ -0,0 +1,681 @@
+// Package selfupdate implements an opt-in, fail-safe self-update mechanism.
+//
+// When enabled via the [EnvAutoUpdate] environment variable, the running
+// binary checks GitHub for a newer release, downloads the asset matching the
+// current OS/arch, verifies it, atomically replaces itself on disk, and
+// re-executes the new binary with the original arguments.
+//
+// The whole mechanism is best-effort: any failure (network, disk, permissions,
+// version parsing, a corrupt download, ...) is logged and swallowed so the
+// caller always falls back to running the current binary. The only observable
+// effect of a failed update is a short delay and a log line.
+package selfupdate
+
+import (
+	"bufio"
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"time"
+
+	"github.com/Masterminds/semver/v3"
+	"github.com/mattn/go-isatty"
+
+	"github.com/docker/docker-agent/pkg/atomicfile"
+)
+
+const (
+	// EnvAutoUpdate enables the self-update mechanism when set to a truthy
+	// value ("1", "true", "yes", "on", case-insensitive).
+	EnvAutoUpdate = "DOCKER_AGENT_AUTO_UPDATE"
+
+	// envReExecMarker is set on the re-executed child process to prevent an
+	// infinite update→re-exec loop. Its presence means "an update has already
+	// been attempted in this process tree, do not try again".
+	envReExecMarker = "DOCKER_AGENT_SELF_UPDATE_REEXEC"
+
+	// envBackupMarker points the re-executed child at the previous binary backup
+	// so it can clean up after a successful restart.
+	envBackupMarker = "DOCKER_AGENT_SELF_UPDATE_BACKUP"
+
+	defaultRepoOwner = "docker"
+	defaultRepoName  = "docker-agent"
+
+	defaultAPIBaseURL      = "https://api.github.com"
+	defaultDownloadBaseURL = "https://github.com"
+
+	// devVersion marks a local/unreleased build that must never be updated.
+	devVersion = "dev"
+
+	// httpTimeout bounds every network operation. The full update budget is
+	// roughly downloadTimeout for the asset plus this for metadata.
+	httpTimeout     = 30 * time.Second
+	downloadTimeout = 5 * time.Minute
+
+	// maxBinarySize caps the downloaded asset to defeat a malicious or broken
+	// release from filling the disk. docker-agent binaries are tens of MiB.
+	maxBinarySize = 512 << 20 // 512 MiB
+)
+
+// Updater performs a single best-effort self-update. The zero value is not
+// usable; call [New] to get one wired with sensible defaults.
+type Updater struct {
+	CurrentVersion string
+	Owner          string
+	Repo           string
+
+	APIBaseURL      string
+	DownloadBaseURL string
+
+	GOOS   string
+	GOARCH string
+
+	HTTPClient *http.Client
+
+	// resolveExecutable returns the absolute, symlink-resolved path of the
+	// binary to replace. Overridable in tests.
+	resolveExecutable func() (string, error)
+
+	// install replaces the current executable with the staged binary and returns
+	// the previous binary backup plus a restore function that puts it back on
+	// disk if the restart step fails. Overridable in tests.
+	install func(dst, src string) (func() error, string, error)
+
+	// reExec replaces (Unix) or relaunches (Windows) the current process with
+	// path, args and env. On success it does not return on Unix; on Windows it
+	// exits the process with the child's status. Overridable in tests.
+	reExec func(path string, args, env []string) error
+
+	// confirm asks the user whether to install the available update. It returns
+	// true to proceed. In non-interactive sessions it must auto-confirm.
+	// Overridable in tests.
+	confirm func(stdin io.Reader, stderr io.Writer, current, latest string) bool
+}
+
+// New returns an Updater configured for the docker-agent GitHub repository,
+// targeting the current binary and platform.
+func New(currentVersion string) *Updater {
+	return &Updater{
+		CurrentVersion:    currentVersion,
+		Owner:             defaultRepoOwner,
+		Repo:              defaultRepoName,
+		APIBaseURL:        defaultAPIBaseURL,
+		DownloadBaseURL:   defaultDownloadBaseURL,
+		GOOS:              runtime.GOOS,
+		GOARCH:            runtime.GOARCH,
+		HTTPClient:        &http.Client{Timeout: downloadTimeout},
+		resolveExecutable: resolveExecutable,
+		install:           installExecutable,
+		reExec:            reExecProcess,
+		confirm:           confirmUpdate,
+	}
+}
+
+// Enabled reports whether the self-update mechanism should run for this
+// process. It is false when disabled by env, or when this process is already
+// the re-executed child of a prior update (loop guard).
+func Enabled() bool {
+	if os.Getenv(envReExecMarker) != "" {
+		return false
+	}
+	return isTruthy(os.Getenv(EnvAutoUpdate))
+}
+
+// Run attempts a self-update. It never returns an error: on success it
+// re-executes the new binary (and does not return on Unix); on any failure or
+// when already up to date it returns so the caller can continue with the
+// current binary. Progress and failures are reported to stderr and slog.
+//
+// When a newer release is available and the session is interactive, the user
+// is prompted to confirm the upgrade; non-interactive sessions auto-confirm.
+func Run(ctx context.Context, stdin io.Reader, stderr io.Writer) {
+	if !Enabled() {
+		return
+	}
+	New(currentVersion()).run(ctx, stdin, stderr)
+}
+
+// run is the testable core. It logs and swallows every error.
+func (u *Updater) run(ctx context.Context, stdin io.Reader, stderr io.Writer) {
+	if err := u.tryUpdate(ctx, stdin, stderr); err != nil {
+		slog.WarnContext(ctx, "Self-update skipped; continuing with current binary", "error", err)
+		fmt.Fprintf(stderr, "docker-agent: self-update failed (%v); continuing with current version\n", err)
+	}
+}
+
+func (u *Updater) tryUpdate(ctx context.Context, stdin io.Reader, stderr io.Writer) error {
+	current, err := semver.NewVersion(u.CurrentVersion)
+	if err != nil {
+		// "dev" and other non-release versions land here. Never clobber a
+		// local build the developer compiled themselves.
+		return fmt.Errorf("current version %q is not a release version: %w", u.CurrentVersion, err)
+	}
+
+	assetName := u.assetName()
+	release, err := u.latestRelease(ctx, assetName)
+	if err != nil {
+		return fmt.Errorf("resolving latest release: %w", err)
+	}
+
+	latest, err := semver.NewVersion(release.Tag)
+	if err != nil {
+		return fmt.Errorf("parsing latest tag %q: %w", release.Tag, err)
+	}
+
+	if !latest.GreaterThan(current) {
+		slog.DebugContext(ctx, "Already on the latest version", "current", u.CurrentVersion, "latest", release.Tag)
+		return nil
+	}
+
+	if !u.confirm(stdin, stderr, u.CurrentVersion, release.Tag) {
+		slog.DebugContext(ctx, "User declined self-update", "current", u.CurrentVersion, "latest", release.Tag)
+		return nil
+	}
+
+	exePath, err := u.resolveExecutable()
+	if err != nil {
+		return fmt.Errorf("locating current executable: %w", err)
+	}
+
+	fmt.Fprintf(stderr, "docker-agent: updating from %s to %s...\n", u.CurrentVersion, release.Tag)
+
+	newPath, err := u.downloadAndStage(ctx, release, exePath)
+	if err != nil {
+		return err
+	}
+
+	if err := u.verifyBinary(ctx, newPath); err != nil {
+		_ = os.Remove(newPath)
+		return fmt.Errorf("verifying downloaded binary: %w", err)
+	}
+
+	restore, backup, err := u.install(exePath, newPath)
+	if err != nil {
+		_ = os.Remove(newPath)
+		return fmt.Errorf("replacing executable: %w", err)
+	}
+
+	fmt.Fprintf(stderr, "docker-agent: updated to %s, restarting...\n", release.Tag)
+
+	// Re-execute the freshly installed binary with the original arguments,
+	// marking the child so it will not attempt to update again. Strip any
+	// inherited self-update markers first so a stale backup path from a prior
+	// cycle cannot shadow the one we set (getenv returns the first match).
+	env := append(selfUpdateEnv(os.Environ()), envReExecMarker+"=1", envBackupMarker+"="+backup)
+	if err := u.reExec(exePath, os.Args, env); err != nil {
+		if restoreErr := restore(); restoreErr != nil {
+			return fmt.Errorf("re-executing updated binary and restoring previous binary: %w", errors.Join(err, restoreErr))
+		}
+		return fmt.Errorf("re-executing updated binary: %w", err)
+	}
+	return nil
+}
+
+// assetName returns the release asset filename for the target platform, e.g.
+// "docker-agent-darwin-arm64" or "docker-agent-windows-amd64.exe".
+func (u *Updater) assetName() string {
+	name := fmt.Sprintf("%s-%s-%s", u.Repo, u.GOOS, u.GOARCH)
+	if u.GOOS == "windows" {
+		name += ".exe"
+	}
+	return name
+}
+
+type releaseInfo struct {
+	Tag         string
+	Asset       string
+	DownloadURL string
+}
+
+// latestRelease fetches the latest GitHub release metadata and locates the
+// asset matching the current platform.
+func (u *Updater) latestRelease(ctx context.Context, assetName string) (releaseInfo, error) {
+	endpoint := fmt.Sprintf("%s/repos/%s/%s/releases/latest", u.APIBaseURL, u.Owner, u.Repo)
+
+	ctx, cancel := context.WithTimeout(ctx, httpTimeout)
+	defer cancel()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
+	if err != nil {
+		return releaseInfo{}, err
+	}
+	req.Header.Set("Accept", "application/vnd.github+json")
+	setGitHubAuth(req)
+
+	resp, err := u.HTTPClient.Do(req)
+	if err != nil {
+		return releaseInfo{}, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return releaseInfo{}, fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode)
+	}
+
+	var release struct {
+		TagName string `json:"tag_name"`
+		Assets  []struct {
+			Name               string `json:"name"`
+			BrowserDownloadURL string `json:"browser_download_url"`
+		} `json:"assets"`
+	}
+	if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&release); err != nil {
+		return releaseInfo{}, fmt.Errorf("decoding release metadata: %w", err)
+	}
+	if release.TagName == "" {
+		return releaseInfo{}, errors.New("latest release has no tag_name")
+	}
+
+	for _, asset := range release.Assets {
+		if asset.Name == assetName {
+			if asset.BrowserDownloadURL == "" {
+				return releaseInfo{}, fmt.Errorf("release asset %s has no download URL", assetName)
+			}
+			if err := u.validateDownloadURL(asset.BrowserDownloadURL); err != nil {
+				return releaseInfo{}, err
+			}
+			return releaseInfo{
+				Tag:         release.TagName,
+				Asset:       asset.Name,
+				DownloadURL: asset.BrowserDownloadURL,
+			}, nil
+		}
+	}
+
+	return releaseInfo{}, fmt.Errorf("latest release %s does not contain asset %s", release.TagName, assetName)
+}
+
+// validateDownloadURL rejects asset URLs that do not point at the trusted
+// download host. The asset URL comes from the GitHub API response, so a
+// tampered or compromised response could otherwise redirect the binary
+// download to an attacker-controlled host. The trusted host is derived from
+// the hardcoded DownloadBaseURL (github.com in production), and the scheme of
+// that base URL is enforced too.
+func (u *Updater) validateDownloadURL(rawURL string) error {
+	parsed, err := url.Parse(rawURL)
+	if err != nil {
+		return fmt.Errorf("parsing asset download URL: %w", err)
+	}
+	base, err := url.Parse(u.DownloadBaseURL)
+	if err != nil {
+		return fmt.Errorf("parsing download base URL: %w", err)
+	}
+	if parsed.Scheme != base.Scheme {
+		return fmt.Errorf("asset download URL %q scheme is not %q", rawURL, base.Scheme)
+	}
+	if !strings.EqualFold(parsed.Hostname(), base.Hostname()) {
+		return fmt.Errorf("asset download URL host %q is not the trusted host %q", parsed.Hostname(), base.Hostname())
+	}
+	return nil
+}
+
+// downloadAndStage downloads the release asset into a temp file next to exePath
+// (same filesystem, so the later rename is atomic) and returns its path.
+func (u *Updater) downloadAndStage(ctx context.Context, release releaseInfo, exePath string) (string, error) {
+	dlURL := release.DownloadURL
+
+	dlCtx, cancel := context.WithTimeout(ctx, downloadTimeout)
+	defer cancel()
+
+	req, err := http.NewRequestWithContext(dlCtx, http.MethodGet, dlURL, http.NoBody)
+	if err != nil {
+		return "", err
+	}
+	setGitHubAuth(req)
+
+	resp, err := u.HTTPClient.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("downloading %s: %w", dlURL, err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("downloading %s: HTTP %d", dlURL, resp.StatusCode)
+	}
+
+	dir := filepath.Dir(exePath)
+	tmp, err := os.CreateTemp(dir, ".docker-agent-update-*")
+	if err != nil {
+		// Fall back to the system temp dir; replaceExecutable copies across
+		// filesystems when an atomic rename is not possible.
+		tmp, err = os.CreateTemp("", ".docker-agent-update-*")
+		if err != nil {
+			return "", fmt.Errorf("creating temp file: %w", err)
+		}
+	}
+	tmpPath := tmp.Name()
+
+	hasher := sha256.New()
+	n, copyErr := io.Copy(io.MultiWriter(tmp, hasher), io.LimitReader(resp.Body, maxBinarySize+1))
+	closeErr := tmp.Close()
+	if copyErr != nil {
+		_ = os.Remove(tmpPath)
+		return "", fmt.Errorf("writing downloaded binary: %w", copyErr)
+	}
+	if closeErr != nil {
+		_ = os.Remove(tmpPath)
+		return "", fmt.Errorf("closing downloaded binary: %w", closeErr)
+	}
+	if n == 0 {
+		_ = os.Remove(tmpPath)
+		return "", errors.New("downloaded binary is empty")
+	}
+	if n > maxBinarySize {
+		_ = os.Remove(tmpPath)
+		return "", fmt.Errorf("downloaded binary exceeds %d bytes", int64(maxBinarySize))
+	}
+
+	if err := os.Chmod(tmpPath, 0o755); err != nil { //nolint:gosec // replacement binary must be executable
+		_ = os.Remove(tmpPath)
+		return "", fmt.Errorf("setting executable permissions: %w", err)
+	}
+
+	// Integrity check is mandatory for self-update: do not execute or install a
+	// downloaded binary unless GitHub provides a digest or the release publishes
+	// a matching SHA-256 entry in checksums.txt.
+	if err := u.verifyChecksum(ctx, release, hex.EncodeToString(hasher.Sum(nil))); err != nil {
+		_ = os.Remove(tmpPath)
+		return "", err
+	}
+
+	return tmpPath, nil
+}
+
+// verifyChecksum verifies gotHex against the SHA-256 listed for the asset in
+// the release's checksums.txt, fetched from the hardcoded DownloadBaseURL
+// rather than any API-supplied value. It fails closed when checksums.txt is
+// missing or does not list the asset, so a tampered API response cannot
+// substitute its own digest for a malicious binary.
+func (u *Updater) verifyChecksum(ctx context.Context, release releaseInfo, gotHex string) error {
+	endpoint := fmt.Sprintf("%s/%s/%s/releases/download/%s/checksums.txt", u.DownloadBaseURL, u.Owner, u.Repo, release.Tag)
+
+	ctx, cancel := context.WithTimeout(ctx, httpTimeout)
+	defer cancel()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
+	if err != nil {
+		return err
+	}
+	setGitHubAuth(req)
+
+	resp, err := u.HTTPClient.Do(req)
+	if err != nil {
+		return fmt.Errorf("fetching checksums.txt: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("fetching checksums.txt: HTTP %d", resp.StatusCode)
+	}
+
+	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+	if err != nil {
+		return fmt.Errorf("reading checksums.txt: %w", err)
+	}
+
+	want, ok := checksumFor(string(body), release.Asset)
+	if !ok {
+		return fmt.Errorf("checksums.txt does not list %s", release.Asset)
+	}
+
+	if !strings.EqualFold(want, gotHex) {
+		return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", release.Asset, want, gotHex)
+	}
+	return nil
+}
+
+// checksumFor parses a "sha256  filename" formatted checksums file and returns
+// the hex digest for the given asset.
+func checksumFor(contents, asset string) (string, bool) {
+	for line := range strings.Lines(contents) {
+		fields := strings.Fields(line)
+		if len(fields) != 2 {
+			continue
+		}
+		// The filename column may carry a leading "*" (binary mode marker).
+		name := strings.TrimPrefix(fields[1], "*")
+		if name == asset {
+			return fields[0], true
+		}
+	}
+	return "", false
+}
+
+// verifyBinary sanity-checks the staged binary by executing it with the
+// "version" subcommand. A binary that cannot even print its version (wrong
+// platform, truncated download, ...) must not replace the working one.
+func (u *Updater) verifyBinary(ctx context.Context, path string) error {
+	// Skip when staged for another platform (tests): we cannot run it here.
+	if u.GOOS != runtime.GOOS || u.GOARCH != runtime.GOARCH {
+		return nil
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, httpTimeout)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, path, "version")
+	// Mark the probe so the freshly downloaded binary does not recursively
+	// attempt its own self-update while we are validating it. Keep the
+	// environment minimal so the probe cannot read model/provider secrets.
+	cmd.Env = []string{envReExecMarker + "=1"}
+	if runtime.GOOS == "windows" {
+		cmd.Env = append(cmd.Env, "SYSTEMROOT="+os.Getenv("SYSTEMROOT"), "PATH="+os.Getenv("PATH"))
+	}
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("staged binary failed to run (%w): %s", err, strings.TrimSpace(string(out)))
+	}
+	return nil
+}
+
+// resolveExecutable returns the absolute, symlink-resolved path of the running
+// binary. Resolving symlinks ensures we replace the real file (e.g. the
+// Homebrew/cli-plugins target) rather than a link to it.
+func resolveExecutable() (string, error) {
+	exe, err := os.Executable()
+	if err != nil {
+		return "", err
+	}
+	if resolved, err := filepath.EvalSymlinks(exe); err == nil {
+		exe = resolved
+	}
+	return filepath.Abs(exe)
+}
+
+// backupFilePrefix is the basename prefix of the temp backup created by
+// backupExecutable. Cleanup only removes paths matching it so a hostile or
+// accidental DOCKER_AGENT_SELF_UPDATE_BACKUP value cannot delete arbitrary
+// files on startup.
+const backupFilePrefix = ".docker-agent-backup-"
+
+// Cleanup removes the previous-binary backup after a successful re-exec. It is
+// deliberately best-effort: cleanup failure must never block normal execution.
+//
+// The backup path comes from an environment variable, so it is validated to
+// look like a backup file produced by this package before being removed. This
+// prevents an attacker-controlled environment from turning startup into an
+// arbitrary file deletion.
+func Cleanup(ctx context.Context) {
+	backup := os.Getenv(envBackupMarker)
+	if backup == "" || !isOwnedBackupPath(backup) {
+		return
+	}
+	if err := os.Remove(backup); err != nil && !errors.Is(err, os.ErrNotExist) {
+		slog.DebugContext(ctx, "Could not remove self-update backup", "path", backup, "error", err)
+	}
+}
+
+// isOwnedBackupPath reports whether p looks like a backup file created by
+// backupExecutable, i.e. its basename carries the expected prefix.
+func isOwnedBackupPath(p string) bool {
+	return strings.HasPrefix(filepath.Base(p), backupFilePrefix)
+}
+
+// installExecutable swaps the binary at dst with the staged file at src and
+// returns a restore function that can put the previous binary back if restart
+// fails. The platform-specific details (a running binary cannot be overwritten
+// on Windows) live in swapBinary's platform implementations.
+func installExecutable(dst, src string) (func() error, string, error) {
+	backup, err := backupExecutable(dst)
+	if err != nil {
+		return nil, "", fmt.Errorf("backing up current executable: %w", err)
+	}
+
+	if err := swapBinary(dst, src); err != nil {
+		_ = os.Remove(backup)
+		return nil, "", err
+	}
+
+	restored := false
+	return func() error {
+		if restored {
+			return nil
+		}
+		restored = true
+		defer os.Remove(backup)
+		return swapBinary(dst, backup)
+	}, backup, nil
+}
+
+func backupExecutable(path string) (string, error) {
+	in, err := os.Open(path)
+	if err != nil {
+		return "", err
+	}
+	defer in.Close()
+
+	tmp, err := os.CreateTemp(filepath.Dir(path), backupFilePrefix+"*")
+	if err != nil {
+		return "", err
+	}
+	backup := tmp.Name()
+
+	_, copyErr := io.Copy(tmp, in)
+	syncErr := tmp.Sync()
+	closeErr := tmp.Close()
+	if copyErr != nil || syncErr != nil || closeErr != nil {
+		_ = os.Remove(backup)
+		switch {
+		case copyErr != nil:
+			return "", copyErr
+		case syncErr != nil:
+			return "", syncErr
+		default:
+			return "", closeErr
+		}
+	}
+
+	if err := os.Chmod(backup, 0o755); err != nil { //nolint:gosec // backup must be executable if restored
+		_ = os.Remove(backup)
+		return "", err
+	}
+	return backup, nil
+}
+
+// atomicWriteFromFile copies src into dst atomically (used by the Windows path
+// and by the cross-filesystem fallback). Kept here so both platform files can
+// share it.
+func atomicWriteFromFile(dst, src string) error {
+	f, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	return atomicfile.Write(dst, f, 0o755)
+}
+
+// confirmUpdate asks the user whether to install the available update. On a
+// non-interactive session (stdin is not a terminal, e.g. CI or piped input) it
+// auto-confirms so automation keeps working. On an interactive session it
+// prompts and treats anything other than an explicit "yes" as a decline.
+func confirmUpdate(stdin io.Reader, stderr io.Writer, current, latest string) bool {
+	if !isInteractive(stdin) {
+		return true
+	}
+
+	fmt.Fprintf(stderr, "An update is available (%s). Do you want to install it or continue with version %s? [Y/n] ", latest, current)
+
+	line, err := bufio.NewReader(stdin).ReadString('\n')
+	if err != nil && line == "" {
+		// Could not read an answer (EOF on an empty line): be conservative and
+		// keep running the current version rather than upgrading unprompted.
+		return false
+	}
+
+	return answerIsYes(line)
+}
+
+// answerIsYes reports whether a prompt answer means "proceed". The prompt
+// defaults to yes, so an empty answer confirms; anything other than y/yes
+// declines.
+func answerIsYes(answer string) bool {
+	switch strings.ToLower(strings.TrimSpace(answer)) {
+	case "", "y", "yes":
+		return true
+	default:
+		return false
+	}
+}
+
+// isInteractive reports whether stdin is a terminal we can prompt on. It probes
+// for an Fd() accessor (satisfied by *os.File and by common wrappers) rather
+// than asserting a concrete *os.File, so a wrapped real terminal is still
+// detected. A reader without a file descriptor (e.g. a bytes buffer in tests)
+// or a non-terminal descriptor (pipe, redirect, CI) is non-interactive.
+func isInteractive(stdin io.Reader) bool {
+	f, ok := stdin.(interface{ Fd() uintptr })
+	if !ok {
+		return false
+	}
+	return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
+}
+
+// selfUpdateEnv returns environ with every DOCKER_AGENT_SELF_UPDATE_* entry
+// removed so the caller can append fresh markers without leaving duplicates.
+// Duplicates matter because getenv returns the first match, so a stale backup
+// path inherited from a prior update cycle would otherwise shadow the new one
+// and leak the temp backup file.
+func selfUpdateEnv(environ []string) []string {
+	filtered := make([]string, 0, len(environ))
+	for _, kv := range environ {
+		key, _, _ := strings.Cut(kv, "=")
+		if key == envReExecMarker || key == envBackupMarker {
+			continue
+		}
+		filtered = append(filtered, kv)
+	}
+	return filtered
+}
+
+// isTruthy reports whether s represents an enabled boolean flag.
+func isTruthy(s string) bool {
+	switch strings.ToLower(strings.TrimSpace(s)) {
+	case "1", "true", "yes", "on":
+		return true
+	default:
+		return false
+	}
+}
+
+// setGitHubAuth adds a bearer token from the environment when available,
+// raising the GitHub rate limit and enabling access to private assets.
+func setGitHubAuth(req *http.Request) {
+	token := os.Getenv("GITHUB_TOKEN")
+	if token == "" {
+		token = os.Getenv("GH_TOKEN")
+	}
+	if token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+}
diff --git a/pkg/selfupdate/selfupdate_test.go b/pkg/selfupdate/selfupdate_test.go
new file mode 100644
index 000000000..5dde3b2e7
--- /dev/null
+++ b/pkg/selfupdate/selfupdate_test.go
@@ -0,0 +1,529 @@
+package selfupdate
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestIsTruthy(t *testing.T) {
+	t.Parallel()
+
+	for _, tc := range []struct {
+		in   string
+		want bool
+	}{
+		{"1", true},
+		{"true", true},
+		{"TRUE", true},
+		{"yes", true},
+		{"on", true},
+		{" true ", true},
+		{"0", false},
+		{"false", false},
+		{"", false},
+		{"nope", false},
+	} {
+		assert.Equal(t, tc.want, isTruthy(tc.in), "input %q", tc.in)
+	}
+}
+
+func TestEnabled(t *testing.T) {
+	t.Setenv(EnvAutoUpdate, "")
+	t.Setenv(envReExecMarker, "")
+	assert.False(t, Enabled())
+
+	t.Setenv(EnvAutoUpdate, "true")
+	assert.True(t, Enabled())
+
+	// The re-exec marker disables updates even when explicitly enabled.
+	t.Setenv(envReExecMarker, "1")
+	assert.False(t, Enabled())
+}
+
+func TestAssetName(t *testing.T) {
+	t.Parallel()
+
+	u := &Updater{Repo: "docker-agent", GOOS: "linux", GOARCH: "amd64"}
+	assert.Equal(t, "docker-agent-linux-amd64", u.assetName())
+
+	u = &Updater{Repo: "docker-agent", GOOS: "windows", GOARCH: "arm64"}
+	assert.Equal(t, "docker-agent-windows-arm64.exe", u.assetName())
+}
+
+func TestChecksumFor(t *testing.T) {
+	t.Parallel()
+
+	contents := "abc123  docker-agent-linux-amd64\n" +
+		"def456 *docker-agent-darwin-arm64\n" +
+		"bad999  nested/docker-agent-windows-amd64.exe\n"
+
+	got, ok := checksumFor(contents, "docker-agent-linux-amd64")
+	assert.True(t, ok)
+	assert.Equal(t, "abc123", got)
+
+	got, ok = checksumFor(contents, "docker-agent-darwin-arm64")
+	assert.True(t, ok)
+	assert.Equal(t, "def456", got)
+
+	_, ok = checksumFor(contents, "docker-agent-windows-amd64.exe")
+	assert.False(t, ok, "path-bearing entries should not match")
+}
+
+// newFakeRelease returns an httptest server emulating the GitHub release API
+// and download endpoints for the given tag and asset payload.
+const testAssetName = "docker-agent-plan9-mips"
+
+func newFakeRelease(t *testing.T, tag string, payload []byte, withChecksums bool) *httptest.Server {
+	t.Helper()
+
+	assetName := testAssetName
+	sum := sha256.Sum256(payload)
+	checksums := fmt.Sprintf("%s  %s\n", hex.EncodeToString(sum[:]), assetName)
+
+	var baseURL string
+	mux := http.NewServeMux()
+	mux.HandleFunc("/repos/docker/docker-agent/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
+		fmt.Fprintf(w, `{"tag_name":%q,"assets":[{"name":%q,"browser_download_url":%q}]}`, tag, assetName, baseURL+"/docker/docker-agent/releases/download/"+tag+"/"+assetName)
+	})
+	mux.HandleFunc("/docker/docker-agent/releases/download/"+tag+"/"+assetName, func(w http.ResponseWriter, _ *http.Request) {
+		_, _ = w.Write(payload)
+	})
+	mux.HandleFunc("/docker/docker-agent/releases/download/"+tag+"/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
+		if !withChecksums {
+			http.NotFound(w, nil)
+			return
+		}
+		_, _ = io.WriteString(w, checksums)
+	})
+
+	srv := httptest.NewServer(mux)
+	baseURL = srv.URL
+	t.Cleanup(srv.Close)
+	return srv
+}
+
+// newTestUpdater wires an Updater against srv, targeting a non-host platform so
+// verifyBinary is skipped, and capturing the re-exec call instead of execing.
+func newTestUpdater(t *testing.T, srv *httptest.Server, currentVer, exePath string) (*Updater, *reExecCapture) {
+	t.Helper()
+
+	capt := &reExecCapture{}
+	return &Updater{
+		CurrentVersion:  currentVer,
+		Owner:           "docker",
+		Repo:            "docker-agent",
+		APIBaseURL:      srv.URL,
+		DownloadBaseURL: srv.URL,
+		// Deliberately not the host platform: verifyBinary returns early so we
+		// don't try to exec a fake binary.
+		GOOS:       "plan9",
+		GOARCH:     "mips",
+		HTTPClient: srv.Client(),
+		resolveExecutable: func() (string, error) {
+			return exePath, nil
+		},
+		reExec:  capt.fn,
+		install: installExecutable,
+		confirm: func(io.Reader, io.Writer, string, string) bool { return true },
+	}, capt
+}
+
+type reExecCapture struct {
+	mu     sync.Mutex
+	called bool
+	path   string
+	args   []string
+	env    []string
+}
+
+func (c *reExecCapture) fn(path string, args, env []string) error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.called = true
+	c.path = path
+	c.args = args
+	c.env = env
+	return nil
+}
+
+func TestTryUpdateSuccess(t *testing.T) {
+	payload := []byte("#!/bin/sh\necho new binary\n")
+	srv := newFakeRelease(t, "v2.0.0", payload, true)
+
+	dir := t.TempDir()
+	exePath := filepath.Join(dir, "docker-agent")
+	require.NoError(t, os.WriteFile(exePath, []byte("old binary"), 0o755))
+
+	u, capt := newTestUpdater(t, srv, "v1.0.0", exePath)
+
+	var stderr strings.Builder
+	require.NoError(t, u.tryUpdate(t.Context(), nil, &stderr))
+
+	// The on-disk binary was replaced with the downloaded payload.
+	got, err := os.ReadFile(exePath)
+	require.NoError(t, err)
+	assert.Equal(t, payload, got)
+
+	// And the new binary was re-executed with the loop-guard env marker.
+	assert.True(t, capt.called)
+	assert.Equal(t, exePath, capt.path)
+	assert.Contains(t, capt.env, envReExecMarker+"=1")
+}
+
+func TestTryUpdateDeclinedDoesNotUpdate(t *testing.T) {
+	payload := []byte("#!/bin/sh\necho new binary\n")
+	srv := newFakeRelease(t, "v2.0.0", payload, true)
+
+	dir := t.TempDir()
+	exePath := filepath.Join(dir, "docker-agent")
+	require.NoError(t, os.WriteFile(exePath, []byte("old binary"), 0o755))
+
+	u, capt := newTestUpdater(t, srv, "v1.0.0", exePath)
+	u.confirm = func(io.Reader, io.Writer, string, string) bool { return false }
+
+	var stderr strings.Builder
+	require.NoError(t, u.tryUpdate(t.Context(), nil, &stderr))
+
+	assert.False(t, capt.called, "declining must not re-exec")
+	got, _ := os.ReadFile(exePath)
+	assert.Equal(t, "old binary", string(got), "binary must be untouched when declined")
+}
+
+func TestConfirmUpdateNonInteractiveAutoConfirms(t *testing.T) {
+	t.Parallel()
+
+	// A non-*os.File reader (e.g. a pipe in CI) is non-interactive: auto-confirm.
+	var stderr strings.Builder
+	assert.True(t, confirmUpdate(strings.NewReader(""), &stderr, "v1.0.0", "v2.0.0"))
+	assert.Empty(t, stderr.String(), "must not prompt in a non-interactive session")
+}
+
+func TestAnswerIsYes(t *testing.T) {
+	t.Parallel()
+
+	for _, tc := range []struct {
+		in   string
+		want bool
+	}{
+		{"", true}, // default is yes
+		{"\n", true},
+		{"y", true},
+		{"Y", true},
+		{"yes", true},
+		{" YES ", true},
+		{"n", false},
+		{"no", false},
+		{"nope", false},
+		{"x", false},
+	} {
+		assert.Equal(t, tc.want, answerIsYes(tc.in), "input %q", tc.in)
+	}
+}
+
+func TestTryUpdateAlreadyLatest(t *testing.T) {
+	srv := newFakeRelease(t, "v1.0.0", []byte("x"), true)
+
+	dir := t.TempDir()
+	exePath := filepath.Join(dir, "docker-agent")
+	require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755))
+
+	u, capt := newTestUpdater(t, srv, "v1.0.0", exePath)
+
+	var stderr strings.Builder
+	require.NoError(t, u.tryUpdate(t.Context(), nil, &stderr))
+
+	assert.False(t, capt.called, "should not re-exec when already up to date")
+	got, _ := os.ReadFile(exePath)
+	assert.Equal(t, "old", string(got), "binary must be untouched")
+}
+
+func TestTryUpdateDevVersionNeverUpdates(t *testing.T) {
+	srv := newFakeRelease(t, "v1.0.0", []byte("x"), true)
+
+	dir := t.TempDir()
+	exePath := filepath.Join(dir, "docker-agent")
+	require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755))
+
+	u, capt := newTestUpdater(t, srv, devVersion, exePath)
+
+	var stderr strings.Builder
+	err := u.tryUpdate(t.Context(), nil, &stderr)
+	require.Error(t, err, "dev builds must not be replaced")
+	assert.False(t, capt.called)
+}
+
+func TestTryUpdateChecksumMismatch(t *testing.T) {
+	payload := []byte("real payload")
+
+	dir := t.TempDir()
+	exePath := filepath.Join(dir, "docker-agent")
+	require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755))
+
+	// Server advertises a checksum that does not match the payload.
+	bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch {
+		case strings.HasSuffix(r.URL.Path, "/releases/latest"):
+			fmt.Fprintf(w, `{"tag_name":"v2.0.0","assets":[{"name":"docker-agent-plan9-mips","browser_download_url":%q}]}`, "http://"+r.Host+"/download/docker-agent-plan9-mips")
+		case strings.HasSuffix(r.URL.Path, "checksums.txt"):
+			fmt.Fprint(w, "deadbeef  docker-agent-plan9-mips\n")
+		default:
+			_, _ = w.Write(payload)
+		}
+	}))
+	t.Cleanup(bad.Close)
+
+	u, capt := newTestUpdater(t, bad, "v1.0.0", exePath)
+	u.APIBaseURL = bad.URL
+	u.DownloadBaseURL = bad.URL
+
+	var stderr strings.Builder
+	err := u.tryUpdate(t.Context(), nil, &stderr)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "checksum mismatch")
+	assert.False(t, capt.called)
+
+	// The original binary must be intact on failure.
+	got, _ := os.ReadFile(exePath)
+	assert.Equal(t, "old", string(got))
+}
+
+func TestTryUpdateMissingChecksumFailsClosed(t *testing.T) {
+	payload := []byte("real payload")
+	srv := newFakeRelease(t, "v2.0.0", payload, false)
+
+	dir := t.TempDir()
+	exePath := filepath.Join(dir, "docker-agent")
+	require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755))
+
+	u, capt := newTestUpdater(t, srv, "v1.0.0", exePath)
+
+	var stderr strings.Builder
+	err := u.tryUpdate(t.Context(), nil, &stderr)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "checksums.txt")
+	assert.False(t, capt.called)
+
+	got, err := os.ReadFile(exePath)
+	require.NoError(t, err)
+	assert.Equal(t, "old", string(got))
+}
+
+func TestTryUpdateReExecFailureRestoresPreviousBinary(t *testing.T) {
+	payload := []byte("new binary")
+	srv := newFakeRelease(t, "v2.0.0", payload, true)
+
+	dir := t.TempDir()
+	exePath := filepath.Join(dir, "docker-agent")
+	require.NoError(t, os.WriteFile(exePath, []byte("old binary"), 0o755))
+
+	u, _ := newTestUpdater(t, srv, "v1.0.0", exePath)
+	u.reExec = func(string, []string, []string) error {
+		return errors.New("boom")
+	}
+
+	var stderr strings.Builder
+	err := u.tryUpdate(t.Context(), nil, &stderr)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "re-executing updated binary")
+
+	got, err := os.ReadFile(exePath)
+	require.NoError(t, err)
+	assert.Equal(t, "old binary", string(got))
+}
+
+func TestTryUpdateDownloadNotFound(t *testing.T) {
+	// Latest resolves but the asset 404s: must fail and leave binary intact.
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if strings.HasSuffix(r.URL.Path, "/releases/latest") {
+			fmt.Fprintf(w, `{"tag_name":"v2.0.0","assets":[{"name":"docker-agent-plan9-mips","browser_download_url":%q}]}`, "http://"+r.Host+"/missing/docker-agent-plan9-mips")
+			return
+		}
+		http.NotFound(w, r)
+	}))
+	t.Cleanup(srv.Close)
+
+	dir := t.TempDir()
+	exePath := filepath.Join(dir, "docker-agent")
+	require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755))
+
+	u, capt := newTestUpdater(t, srv, "v1.0.0", exePath)
+
+	var stderr strings.Builder
+	err := u.tryUpdate(t.Context(), nil, &stderr)
+	require.Error(t, err)
+	assert.False(t, capt.called)
+	got, _ := os.ReadFile(exePath)
+	assert.Equal(t, "old", string(got))
+}
+
+func TestRunSwallowsErrors(t *testing.T) {
+	// A totally unreachable server must not panic or propagate: Run is
+	// best-effort and only logs.
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		http.Error(w, "boom", http.StatusInternalServerError)
+	}))
+	t.Cleanup(srv.Close)
+
+	dir := t.TempDir()
+	exePath := filepath.Join(dir, "docker-agent")
+	require.NoError(t, os.WriteFile(exePath, []byte("old"), 0o755))
+
+	u, capt := newTestUpdater(t, srv, "v1.0.0", exePath)
+
+	var stderr strings.Builder
+	u.run(t.Context(), nil, &stderr) // must not panic
+	assert.False(t, capt.called)
+	assert.Contains(t, stderr.String(), "self-update failed")
+}
+
+func TestLatestReleaseAuthHeader(t *testing.T) {
+	t.Setenv("GITHUB_TOKEN", "secret-token")
+
+	var gotAuth string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotAuth = r.Header.Get("Authorization")
+		fmt.Fprintf(w, `{"tag_name":"v9.9.9","assets":[{"name":"docker-agent-plan9-mips","browser_download_url":%q}]}`, "http://"+r.Host+"/download")
+	}))
+	t.Cleanup(srv.Close)
+
+	u := &Updater{
+		Owner:           "docker",
+		Repo:            "docker-agent",
+		APIBaseURL:      srv.URL,
+		DownloadBaseURL: srv.URL,
+		HTTPClient:      srv.Client(),
+	}
+
+	release, err := u.latestRelease(t.Context(), "docker-agent-plan9-mips")
+	require.NoError(t, err)
+	assert.Equal(t, "v9.9.9", release.Tag)
+	assert.Equal(t, "Bearer secret-token", gotAuth)
+}
+
+func TestLatestReleaseRejectsUntrustedDownloadHost(t *testing.T) {
+	// The asset download URL points at an attacker-controlled host while the
+	// trusted DownloadBaseURL is the test server: resolution must fail rather
+	// than follow the foreign URL.
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		fmt.Fprintf(w, `{"tag_name":"v9.9.9","assets":[{"name":"docker-agent-plan9-mips","browser_download_url":%q}]}`, "http://evil.example.com/docker-agent-plan9-mips")
+	}))
+	t.Cleanup(srv.Close)
+
+	u := &Updater{
+		Owner:           "docker",
+		Repo:            "docker-agent",
+		APIBaseURL:      srv.URL,
+		DownloadBaseURL: srv.URL,
+		HTTPClient:      srv.Client(),
+	}
+
+	_, err := u.latestRelease(t.Context(), "docker-agent-plan9-mips")
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "not the trusted host")
+}
+
+func TestValidateDownloadURL(t *testing.T) {
+	t.Parallel()
+
+	u := &Updater{DownloadBaseURL: "https://github.com"}
+
+	require.NoError(t, u.validateDownloadURL("https://github.com/docker/docker-agent/releases/download/v1/asset"))
+	require.NoError(t, u.validateDownloadURL("https://GitHub.com/docker/docker-agent/releases/download/v1/asset"))
+
+	require.Error(t, u.validateDownloadURL("https://objects.githubusercontent.com/asset"), "foreign host must be rejected")
+	require.Error(t, u.validateDownloadURL("http://github.com/asset"), "non-HTTPS must be rejected")
+	require.Error(t, u.validateDownloadURL("https://evil.example.com/asset"))
+	require.Error(t, u.validateDownloadURL("://bad"))
+}
+
+func TestSelfUpdateEnvStripsMarkers(t *testing.T) {
+	t.Parallel()
+
+	in := []string{
+		"PATH=/usr/bin",
+		envBackupMarker + "=/tmp/stale",
+		"HOME=/home/me",
+		envReExecMarker + "=1",
+	}
+
+	got := selfUpdateEnv(in)
+	assert.Equal(t, []string{"PATH=/usr/bin", "HOME=/home/me"}, got)
+
+	// Appending fresh markers must yield exactly one entry for each key.
+	full := append(selfUpdateEnv(in), envReExecMarker+"=1", envBackupMarker+"=/tmp/new")
+	assert.Equal(t, 1, countKey(full, envReExecMarker))
+	assert.Equal(t, 1, countKey(full, envBackupMarker))
+	assert.Contains(t, full, envBackupMarker+"=/tmp/new")
+}
+
+func countKey(env []string, key string) int {
+	n := 0
+	for _, kv := range env {
+		if k, _, _ := strings.Cut(kv, "="); k == key {
+			n++
+		}
+	}
+	return n
+}
+
+func TestCleanupRemovesBackup(t *testing.T) {
+	backup := filepath.Join(t.TempDir(), backupFilePrefix+"123")
+	require.NoError(t, os.WriteFile(backup, []byte("old"), 0o755))
+	t.Setenv(envBackupMarker, backup)
+
+	Cleanup(t.Context())
+
+	_, err := os.Stat(backup)
+	require.ErrorIs(t, err, os.ErrNotExist)
+}
+
+func TestCleanupIgnoresForeignBackupPath(t *testing.T) {
+	// A path that does not look like one of our backups must never be removed,
+	// even if pointed at by the environment variable.
+	victim := filepath.Join(t.TempDir(), "important.txt")
+	require.NoError(t, os.WriteFile(victim, []byte("keep"), 0o644))
+	t.Setenv(envBackupMarker, victim)
+
+	Cleanup(t.Context())
+
+	got, err := os.ReadFile(victim)
+	require.NoError(t, err, "foreign path must not be deleted")
+	assert.Equal(t, "keep", string(got))
+}
+
+func TestIsOwnedBackupPath(t *testing.T) {
+	t.Parallel()
+
+	assert.True(t, isOwnedBackupPath("/tmp/"+backupFilePrefix+"abc"))
+	assert.True(t, isOwnedBackupPath(backupFilePrefix+"abc"))
+	assert.False(t, isOwnedBackupPath("/tmp/important.txt"))
+	assert.False(t, isOwnedBackupPath("/etc/passwd"))
+	assert.False(t, isOwnedBackupPath(""))
+}
+
+func TestSwapBinary(t *testing.T) {
+	dir := t.TempDir()
+	dst := filepath.Join(dir, "docker-agent")
+	src := filepath.Join(dir, "staged")
+	require.NoError(t, os.WriteFile(dst, []byte("old"), 0o755))
+	require.NoError(t, os.WriteFile(src, []byte("new"), 0o755))
+
+	require.NoError(t, swapBinary(dst, src))
+
+	got, err := os.ReadFile(dst)
+	require.NoError(t, err)
+	assert.Equal(t, "new", string(got))
+}
diff --git a/pkg/selfupdate/version.go b/pkg/selfupdate/version.go
new file mode 100644
index 000000000..d108091f4
--- /dev/null
+++ b/pkg/selfupdate/version.go
@@ -0,0 +1,9 @@
+package selfupdate
+
+import "github.com/docker/docker-agent/pkg/version"
+
+// currentVersion returns the compiled-in release version. Wrapped so the rest
+// of the package depends only on a string and stays trivially testable.
+func currentVersion() string {
+	return version.Version
+}
