Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ RUN apk add --no-cache ca-certificates docker-cli && \
ARG TARGETOS TARGETARCH
ENV DOCKER_MCP_IN_CONTAINER=1
ENV TERM=xterm-256color
# Disable the once-a-day GitHub release check inside the container image:
# images are tagged immutably and can't self-upgrade, so the hint would be
# misleading and waste a network round-trip on every `run`.
ENV DOCKER_AGENT_DISABLE_VERSION_CHECK=1
COPY --from=docker/mcp-gateway:v2 /docker-mcp /usr/local/lib/docker/cli-plugins/
COPY --from=builder-linux /binaries/docker-agent-$TARGETOS-$TARGETARCH /docker-agent
USER docker-agent
Expand Down
8 changes: 8 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/docker/docker-agent/pkg/tui"
"github.com/docker/docker-agent/pkg/tui/styles"
"github.com/docker/docker-agent/pkg/userconfig"
"github.com/docker/docker-agent/pkg/version/check"
)

type runExecFlags struct {
Expand Down Expand Up @@ -145,6 +146,13 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command
}()
}

// Kick off a best-effort, background check for a newer release. Results
// are cached on disk so the TUI status bar (and a future `version` call)
// can surface an upgrade hint without blocking on a network call. Only
// `run`/`exec` triggers this; other subcommands never reach out to
// GitHub. Disabled by setting DOCKER_AGENT_DISABLE_VERSION_CHECK=1.
check.RefreshAsync(ctx)

if f.sandbox {
return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate, f.sbx)
}
Expand Down
9 changes: 9 additions & 0 deletions cmd/root/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/docker/docker-agent/pkg/cli"
"github.com/docker/docker-agent/pkg/telemetry"
"github.com/docker/docker-agent/pkg/version"
"github.com/docker/docker-agent/pkg/version/check"
)

func newVersionCmd() *cobra.Command {
Expand All @@ -33,4 +34,12 @@ func runVersionCommand(cmd *cobra.Command, args []string) {
}
out.Printf("%s version %s\n", commandName, version.Version)
out.Printf("Commit: %s\n", version.Commit)

// Best-effort upgrade hint based on the cached result of the last
// `docker agent run`. We never reach out to GitHub from this subcommand;
// if the cache is empty (e.g. `run` has never been used) the hint is
// simply not shown.
if latest := check.LatestCached(version.Version); latest != "" {
out.Printf("\nA newer version is available: %s\nRelease notes: https://github.com/docker/docker-agent/releases/tag/%s\n", latest, latest)
}
}
6 changes: 6 additions & 0 deletions docs/configuration/overview/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ API keys and secrets are read from environment variables — never stored in con
| `DOCKER_AGENT_AUTO_INSTALL` | Set to `false` to disable automatic tool installation |
| `DOCKER_AGENT_TOOLS_DIR` | Override the base directory for installed tools (default: `~/.cagent/tools/`) |

**Update Notifications:**

| Variable | Description |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DOCKER_AGENT_DISABLE_VERSION_CHECK` | Set to `1`/`true` to disable the once-a-day GitHub release check used to surface an upgrade hint in the TUI status bar and the `version` subcommand. Only `docker agent run` ever performs the check. |

<div class="callout callout-warning" markdown="1">
<div class="callout-title">⚠️ Important
</div>
Expand Down
2 changes: 1 addition & 1 deletion pkg/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi
m.chatPage = initialChatPage

// Initialize status bar (pass m as help provider)
m.statusBar = statusbar.New(m, statusbar.WithTitle(m.appName+" "+m.appVersion))
m.statusBar = statusbar.New(m, statusbar.WithTitle(buildStatusBarTitle(m.appName, m.appVersion)))

// Add the initial session to the supervisor
sv.AddSession(ctx, initialApp, initialApp.Session(), initialWorkingDir, cleanup)
Expand Down
20 changes: 20 additions & 0 deletions pkg/tui/upgrade_hint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package tui

import (
"github.com/docker/docker-agent/pkg/version/check"
)

// buildStatusBarTitle returns the right-side string of the status bar:
// "<appName> <appVersion>", optionally suffixed with "(update available: vX.Y.Z)"
// when a newer release tag has been observed in the local cache.
//
// Only cached results are consulted so the TUI never blocks on I/O at
// startup; the cache is refreshed in the background by `docker agent run`
// (see cmd/root/run.go).
func buildStatusBarTitle(appName, appVersion string) string {
base := appName + " " + appVersion
if latest := check.LatestCached(appVersion); latest != "" {
return base + " (update available: " + latest + ")"
}
return base
}
35 changes: 35 additions & 0 deletions pkg/tui/upgrade_hint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package tui

import (
"strings"
"testing"

"gotest.tools/v3/assert"

"github.com/docker/docker-agent/pkg/version/check"
)

func TestBuildStatusBarTitle(t *testing.T) {
t.Run("no upgrade", func(t *testing.T) {
check.SeedCacheForTest(t, "v1.0.0")
assert.Equal(t, "docker agent v1.0.0", buildStatusBarTitle("docker agent", "v1.0.0"))
})

t.Run("upgrade available", func(t *testing.T) {
check.SeedCacheForTest(t, "v1.2.3")
got := buildStatusBarTitle("docker agent", "v1.0.0")
assert.Assert(t, strings.Contains(got, "docker agent v1.0.0"))
assert.Assert(t, strings.Contains(got, "update available: v1.2.3"))
})

t.Run("dev build is silent", func(t *testing.T) {
check.SeedCacheForTest(t, "v1.2.3")
assert.Equal(t, "docker agent dev", buildStatusBarTitle("docker agent", "dev"))
})

t.Run("disabled is silent", func(t *testing.T) {
check.SeedCacheForTest(t, "v1.2.3")
t.Setenv(check.DisableEnvVar, "1")
assert.Equal(t, "docker agent v1.0.0", buildStatusBarTitle("docker agent", "v1.0.0"))
})
}
255 changes: 255 additions & 0 deletions pkg/version/check/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
package check

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/docker/docker-agent/pkg/paths"
)

// DisableEnvVar is the environment variable that disables the version check
// when set to a truthy value (1, true, yes, on, …).
const DisableEnvVar = "DOCKER_AGENT_DISABLE_VERSION_CHECK"

const (
cacheTTL = 24 * time.Hour
fetchTimeout = 5 * time.Second
releasesURL = "https://api.github.com/repos/docker/docker-agent/releases/latest"
cacheFileName = "version-check.json"
)

// LatestCached returns the latest known release tag if it is strictly newer
// than current, or "" otherwise.
//
// The function never reaches out to the network — it only consults the local
// cache populated by [RefreshAsync]. It also returns "" when the check is
// disabled or when current is "dev" (development build).
func LatestCached(current string) string {
if disabled() || current == "" || current == "dev" {
return ""
}
entry, _ := readCache()
if !IsNewer(entry.LatestVersion, current) {
return ""
}
return entry.LatestVersion
}

// RefreshAsync triggers a background refresh of the on-disk cache when it is
// stale, returning immediately. Errors are logged at debug level and
// otherwise ignored.
//
// The returned channel is closed once the goroutine completes. Tests use it
// to deterministically wait for completion; production callers can ignore it.
func RefreshAsync(ctx context.Context) <-chan struct{} {
done := make(chan struct{})

if disabled() {
close(done)
return done
}
if entry, _ := readCache(); entry.fresh(time.Now()) {
close(done)
return done
}

go func() {
defer close(done)

fetchCtx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()

tag, err := fetchLatestTag(fetchCtx, releasesURL)
if err != nil {
slog.Debug("Version check fetch failed", "error", err)
return
}
if err := writeCache(tag); err != nil {
slog.Debug("Version check cache write failed", "error", err)
}
}()

return done
}

// disabled reports whether the version check has been turned off via
// [DisableEnvVar].
func disabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(DisableEnvVar))) {
case "1", "true", "yes", "on":
return true
}
return false
}

// fetchLatestTag returns the `tag_name` field of the latest stable release.
// The endpoint is parameterised to keep the function unit-testable.
func fetchLatestTag(ctx context.Context, url string) (string, error) {
// Use a custom client with timeout and redirect limit to prevent
// SSRF/redirect loops. The context timeout in RefreshAsync is a backstop;
// this client-level timeout ensures the request doesn't hang.
client := &http.Client{
Timeout: fetchTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("stopped after 3 redirects")
}
return nil
},
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")

resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}

var payload struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
return "", fmt.Errorf("decode release payload: %w", err)
}
if payload.TagName == "" {
return "", errors.New("release payload missing tag_name")
}
return payload.TagName, nil
}

// cacheEntry is the JSON payload persisted to disk.
type cacheEntry struct {
LatestVersion string `json:"latest_version"`
CheckedAt int64 `json:"checked_at"`
}

// fresh reports whether the entry is still within [cacheTTL].
func (e cacheEntry) fresh(now time.Time) bool {
return e.CheckedAt > 0 && now.Sub(time.Unix(e.CheckedAt, 0)) < cacheTTL
}

// cachePath returns the absolute path of the cache file.
func cachePath() string {
return filepath.Join(paths.GetCacheDir(), cacheFileName)
}

// readCache returns the cached entry, or a zero entry if the file is missing
// or unreadable. The file is small enough that we do not worry about partial
// reads: if Unmarshal fails, callers simply see "no cache" for one call.
func readCache() (cacheEntry, error) {
data, err := os.ReadFile(cachePath())
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return cacheEntry{}, nil
}
return cacheEntry{}, err
}
var entry cacheEntry
if err := json.Unmarshal(data, &entry); err != nil {
return cacheEntry{}, err
}
return entry, nil
}

// writeCache persists the given release tag along with the current timestamp.
// It uses atomic write-and-rename to prevent corruption from concurrent writes.
func writeCache(latest string) error {
if err := os.MkdirAll(paths.GetCacheDir(), 0o755); err != nil {
return fmt.Errorf("create cache dir: %w", err)
}
data, err := json.Marshal(cacheEntry{LatestVersion: latest, CheckedAt: time.Now().Unix()})
if err != nil {
return err
}

// Write to a temporary file first, then atomically rename it to prevent
// corruption if multiple processes write concurrently.
tmpPath := cachePath() + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
return err
}
return os.Rename(tmpPath, cachePath())
}

// IsNewer reports whether the semver-like tag latest is strictly greater than
// current. The comparison is intentionally tolerant:
//
// - A leading "v" is stripped from both sides.
// - Build metadata ("+meta") is ignored.
// - A pre-release ("-rc.1") sorts strictly older than the same release.
// - Components that fail to parse as integers are treated as 0, so
// malformed inputs simply do not trigger a notification.
// - Empty strings or "dev" never compare as newer.
func IsNewer(latest, current string) bool {
if latest == "" || current == "" || current == "dev" || latest == "dev" {
return false
}

la, lpre := splitVersion(latest)
cu, cpre := splitVersion(current)

if cmp := compareNumeric(la, cu); cmp != 0 {
return cmp > 0
}
// Equal numeric parts: a release outranks a pre-release of the same
// version (1.2.3 > 1.2.3-rc.1). Otherwise treat as equal.
return lpre == "" && cpre != ""
}

// splitVersion strips a leading "v", drops "+build" metadata, and splits off
// any "-prerelease" suffix. For example "v1.2.3-rc.1+meta" → ("1.2.3", "rc.1").
func splitVersion(v string) (numeric, pre string) {
v = strings.TrimPrefix(v, "v")
if i := strings.Index(v, "+"); i >= 0 {
v = v[:i]
}
if num, p, ok := strings.Cut(v, "-"); ok {
return num, p
}
return v, ""
}

// compareNumeric compares dotted numeric strings ("1.2.3") component by
// component, returning -1, 0 or +1. Missing trailing components are treated
// as zero so "1.2" == "1.2.0".
func compareNumeric(a, b string) int {
ap := strings.Split(a, ".")
bp := strings.Split(b, ".")
for i := range max(len(ap), len(bp)) {
var ai, bi int
if i < len(ap) {
ai, _ = strconv.Atoi(ap[i])
}
if i < len(bp) {
bi, _ = strconv.Atoi(bp[i])
}
if ai != bi {
if ai > bi {
return 1
}
return -1
}
}
return 0
}
Loading
Loading