Skip to content
Merged
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
37 changes: 37 additions & 0 deletions cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] isManagementInvocation skips update for --help/-h at argv[0] but not global flags mixed with subcommands

isManagementInvocation only inspects args[0] (the first subcommand token). If the user runs:

docker-agent run --help

then args[0] is "run", which does not match the guard list, so an update + re-exec will be triggered before the help text is shown. The user might not expect an update when asking for help on a specific subcommand. This is probably acceptable by design (the PR description says version/help queries are skipped), but the comment says "version/help queries" yet the guard only catches top-level --help/help, not per-subcommand help.

This is a minor UX issue, not a correctness bug, but worth documenting in the code comment or extending the guard:

// Check both first-position flags and trailing --help on any subcommand
for _, arg := range args {
    if arg == "--help" || arg == "-h" {
        return true
    }
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8a43cf9. isManagementInvocation now scans all args for --help/-h (in addition to the args[0] switch), so per-subcommand help such as run --help is skipped and no longer triggers an update + re-exec. Updated the doc comment and added run --help / run agent.yaml -h / share push --help cases to the test.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect! The fix correctly addresses the issue:

✅ isManagementInvocation now scans all args for --help/-h (lines 253-257), not just args[0]
✅ Updated doc comment accurately describes the behavior: Help and version are detected anywhere in args, not just at args[0]
✅ Test cases confirm per-subcommand help is now recognized: run --help, run agent.yaml -h, share push --help

This ensures users won't see unexpected self-update behavior when asking for help on any subcommand. Thanks for the quick fix!

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,
Expand Down
39 changes: 39 additions & 0 deletions cmd/root/selfupdate_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions docs/configuration/overview/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/getting-started/installation/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions pkg/selfupdate/exec_unix.go
Original file line number Diff line number Diff line change
@@ -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
}
77 changes: 77 additions & 0 deletions pkg/selfupdate/exec_windows.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading