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 pkg/desktop/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package desktop

import (
"context"
"time"

"github.com/kofalt/go-memoize"
)

// versionMemoizer caches the Docker Desktop version lookup with a TTL so:
// - if docker-agent starts before Desktop is ready, version detection
// recovers automatically once Desktop comes up;
// - if Desktop is upgraded mid-session, the new version is picked up
// within at most one TTL.
var versionMemoizer = memoize.NewMemoizer(5*time.Minute, 10*time.Minute)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question: what drove the 5 min TTL (and the 10 min cleanup interval)? The PR description mentions "recovers automatically … upgraded mid-session" which argues for some refresh, but 5 minutes is a fairly arbitrary middle ground — Desktop upgrades are rare (once a week), the lookup is cheap-ish (one local socket call with a 2 s ceiling), and the value almost never changes within a session.

A short rationale comment near the NewMemoizer call (e.g. "5 min keeps the steady-state cost ≤ 1 socket call per 5 min per process while still picking up Desktop upgrades within a few minutes") would save the next reader the same question. Happy to leave the value as-is.


// GetVersion returns the running Docker Desktop version (e.g. "4.74.0") or
// an empty string if Docker Desktop is not running or the call fails.
//
// The lookup is bounded by a short internal timeout so a stale or missing
// backend socket cannot stall callers on hot paths (it is queried on every
// outbound built-in tool HTTP request). ctx is used for the underlying
// HTTP call on a cache miss; on a cache hit the cached value is returned
// without consulting ctx.
func GetVersion(ctx context.Context) string {
v, _, _ := memoize.Call(versionMemoizer, "desktopVersion", func() (string, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

var info struct {
CurrentVersion string `json:"currentVersion"`
}
_ = ClientBackend.Get(ctx, "/update", &info)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: the _ = ClientBackend.Get(...) ignores the error and lets the memoizer cache the empty string for the full 5 minutes. That's intentional for the steady-state "Desktop not running" case (good — we don't want to hammer the socket), but it also means a transient failure (e.g. backend.sock momentarily unavailable while Desktop restarts, or a 2 s timeout under load) gets pinned for 5 minutes before we try again.

If we wanted to differentiate, returning a non-nil error from the closure on Get failures would let go-memoize re-attempt sooner (its TTL applies per cache entry, but errors aren't cached the same way — worth double-checking the library semantics). Not a blocker for this PR — the header is best-effort by design — but worth a comment in the code so the next reader knows the swallow is deliberate.

return info.CurrentVersion, nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MEDIUM] Empty Desktop version cached for full 5-minute TTL when Desktop is unavailable at first call

GetVersion always returns (info.CurrentVersion, nil) — even when the HTTP call to Desktop's /update endpoint fails (Desktop not running, socket not present, timeout, etc.). In that case info.CurrentVersion is the zero value "", and since go-memoize only caches results where error == nil, the empty string gets cached for the full 5-minute TTL.

This contradicts the PR description's claim that the lookup "recovers automatically if Desktop starts after docker-agent": if GetVersion is first called before Desktop is ready, the X-Docker-Desktop-Version header will be silently omitted for up to 5 minutes even after Desktop becomes available — rather than recovering on the next request.

Fix: return a non-nil error when the lookup fails so go-memoize does not cache the failure:

if err := ClientBackend.Get(ctx, "/update", &info); err != nil {
    return "", err   // not cached; next caller retries
}
return info.CurrentVersion, nil

This way a transient failure (Desktop not yet up) never poisons the cache.

})
return v
}
2 changes: 1 addition & 1 deletion pkg/tools/builtin/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ func (t *Tool) Tools(context.Context) ([]tools.Tool, error) {
}

func setHeaders(req *http.Request, headers map[string]string) {
req.Header.Set("User-Agent", useragent.Header)
useragent.SetIdentity(req)
for k, v := range upstream.ResolveHeaders(req.Context(), headers) {
req.Header.Set(k, v)
}
Expand Down
22 changes: 22 additions & 0 deletions pkg/tools/builtin/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/js"
"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/useragent"
"github.com/docker/docker-agent/pkg/version"
)

// newAPIToolForTest constructs an APITool that bypasses SSRF dial-time
Expand Down Expand Up @@ -133,6 +135,26 @@ func TestAPITool_Headers(t *testing.T) {
assert.Equal(t, "another-value", ts.receivedHeaders.Get("X-Another-Header"))
}

// TestAPITool_IdentityHeaders pins the docker-agent identity headers that
// every built-in tool sends so backends can attribute traffic to a specific
// docker-agent install. X-Docker-Desktop-Version is only present when
// Docker Desktop is reachable, so we accept its absence.
func TestAPITool_IdentityHeaders(t *testing.T) {
t.Parallel()
ts := getTestServer(t)

tool := newAPIToolForTest(latest.APIToolConfig{
Method: http.MethodGet,
Endpoint: ts.serverURL,
}, testExpander())

_, err := tool.callTool(t.Context(), tools.ToolCall{})
require.NoError(t, err)

assert.Equal(t, useragent.Header, ts.receivedHeaders.Get("User-Agent"))
assert.Equal(t, version.Version, ts.receivedHeaders.Get(useragent.HeaderAgentVersion))
}

func TestAPITool_DefaultOutputSchema(t *testing.T) {
t.Parallel()

Expand Down
8 changes: 4 additions & 4 deletions pkg/tools/builtin/fetch/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func (h *fetchHandler) fetchURL(ctx context.Context, client *http.Client, urlStr
robots, cached := robotsCache[host]
if !cached {
var err error
robots, err = h.fetchRobots(ctx, client, parsedURL, useragent.Header)
robots, err = h.fetchRobots(ctx, client, parsedURL)
if err != nil {
result.Error = fmt.Sprintf("robots.txt check failed: %v", err)
return result
Expand All @@ -183,8 +183,8 @@ func (h *fetchHandler) fetchURL(ctx context.Context, client *http.Client, urlStr
result.Error = fmt.Sprintf("failed to create request: %v", err)
return result
}
req.Header.Set("User-Agent", useragent.Header)
req.Header.Set("Accept", fmtHandler.accept)
useragent.SetIdentity(req)
// Apply caller-configured headers last so an operator-supplied
// Authorization, User-Agent, Accept, ... wins over the defaults set above.
for k, v := range h.headers {
Expand Down Expand Up @@ -233,7 +233,7 @@ func (h *fetchHandler) fetchURL(ctx context.Context, client *http.Client, urlStr
// - caller-supplied headers (Authorization, X-Api-Key, ...) are stripped
// when crossing host boundaries, so credentials never leak to a
// third-party host that handles a robots.txt redirect.
func (h *fetchHandler) fetchRobots(ctx context.Context, client *http.Client, targetURL *url.URL, userAgent string) (*robotstxt.RobotsData, error) {
func (h *fetchHandler) fetchRobots(ctx context.Context, client *http.Client, targetURL *url.URL) (*robotstxt.RobotsData, error) {
// Build robots.txt URL
robotsURL := &url.URL{
Scheme: targetURL.Scheme,
Expand All @@ -248,7 +248,7 @@ func (h *fetchHandler) fetchRobots(ctx context.Context, client *http.Client, tar
return nil, nil
}

req.Header.Set("User-Agent", userAgent)
useragent.SetIdentity(req)
// Apply custom headers to robots.txt requests too, so authenticated
// endpoints that also protect robots.txt work correctly. Cross-host
// leaks are prevented by the shared client's CheckRedirect.
Expand Down
8 changes: 7 additions & 1 deletion pkg/tools/builtin/fetch/fetch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/useragent"
"github.com/docker/docker-agent/pkg/version"
)

// newFetchToolForTest constructs a FetchTool that bypasses SSRF dial-time
Expand Down Expand Up @@ -438,14 +440,16 @@ func TestFetchTool_WithHeadersOption(t *testing.T) {
}

func TestFetch_Headers_SentOnRequest(t *testing.T) {
var gotAuthorization, gotAPIKey string
var gotAuthorization, gotAPIKey, gotUserAgent, gotAgentVersion string
url := runHTTPServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/robots.txt" {
http.NotFound(w, r)
return
}
gotAuthorization = r.Header.Get("Authorization")
gotAPIKey = r.Header.Get("X-Api-Key")
gotUserAgent = r.Header.Get("User-Agent")
gotAgentVersion = r.Header.Get(useragent.HeaderAgentVersion)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "ok")
})
Expand All @@ -463,6 +467,8 @@ func TestFetch_Headers_SentOnRequest(t *testing.T) {
assert.Contains(t, result.Output, "Successfully fetched")
assert.Equal(t, "Bearer secret-token", gotAuthorization)
assert.Equal(t, "key-123", gotAPIKey)
assert.Equal(t, useragent.Header, gotUserAgent)
assert.Equal(t, version.Version, gotAgentVersion)
}

// TestFetch_Headers_OverrideDefaults pins the precedence rule: caller-supplied
Expand Down
9 changes: 5 additions & 4 deletions pkg/tools/builtin/openapi/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,11 +382,12 @@ func sanitizeToolName(name string) string {
return name
}

// setHeaders sets the User-Agent and custom headers on an HTTP request.
// Header values may contain ${headers.NAME} placeholders that are resolved
// from upstream headers stored in the request context.
// setHeaders sets identity headers (User-Agent, X-Docker-Agent-Version,
// X-Docker-Desktop-Version) plus any operator-supplied custom headers on
// an HTTP request. Header values may contain ${headers.NAME} placeholders
// that are resolved from upstream headers stored in the request context.
func setHeaders(req *http.Request, headers map[string]string) {
req.Header.Set("User-Agent", useragent.Header)
useragent.SetIdentity(req)
for k, v := range upstream.ResolveHeaders(req.Context(), headers) {
req.Header.Set(k, v)
}
Expand Down
4 changes: 4 additions & 0 deletions pkg/tools/builtin/openapi/openapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/useragent"
"github.com/docker/docker-agent/pkg/version"
)

// newOpenAPIToolForTest constructs an OpenAPITool that bypasses SSRF
Expand Down Expand Up @@ -342,6 +344,8 @@ func TestOpenAPITool_CustomHeaders(t *testing.T) {

assert.Equal(t, "Bearer test-token", receivedHeaders.Get("Authorization"))
assert.Equal(t, "custom-value", receivedHeaders.Get("X-Custom"))
assert.Equal(t, useragent.Header, receivedHeaders.Get("User-Agent"))
assert.Equal(t, version.Version, receivedHeaders.Get(useragent.HeaderAgentVersion))
}

func TestOpenAPITool_ErrorResponse(t *testing.T) {
Expand Down
25 changes: 25 additions & 0 deletions pkg/useragent/useragent.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
// Package useragent centralizes the HTTP identity headers that built-in
// tools (api, fetch, openapi, ...) attach to every outbound request.
package useragent

import (
"fmt"
"net/http"
"runtime"

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

// Header is the User-Agent value sent by built-in HTTP tools. It also
// doubles as the agent name fed to robotstxt.RobotsData.TestAgent so
// site operators see the same identity in both places.
var Header = fmt.Sprintf("Cagent/%s (%s; %s)", version.Version, runtime.GOOS, runtime.GOARCH)

// HTTP header names emitted by [SetIdentity] in addition to User-Agent.
const (
HeaderAgentVersion = "X-Docker-Agent-Version"
HeaderDesktopVersion = "X-Docker-Desktop-Version"
)

// SetIdentity stamps the request with User-Agent, X-Docker-Agent-Version,
// and (when Docker Desktop is reachable) X-Docker-Desktop-Version. Callers
// that want operator-supplied overrides should apply those headers AFTER
// calling SetIdentity.
func SetIdentity(req *http.Request) {
req.Header.Set("User-Agent", Header)
req.Header.Set(HeaderAgentVersion, version.Version)
if v := desktop.GetVersion(req.Context()); v != "" {
req.Header.Set(HeaderDesktopVersion, v)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (privacy/scope): SetIdentity is wired into every api / fetch / openapi outbound request, which means X-Docker-Desktop-Version is broadcast to every host the operator points a built-in tool at — not just Docker-owned backends. The PR description acknowledges this and defers an opt-out, which I think is fine, but two thoughts for the next iteration:

  1. Consider scoping the Desktop header to known Docker hosts (Hub, Hub gateway, *.docker.com, *.docker.io) by default. The agent-version header is harmless to broadcast; the Desktop-version one is the genuinely new signal and the one most likely to surprise a security-minded operator.
  2. If we keep it global, the README / docs should mention the new header explicitly so operators auditing outbound traffic aren't surprised. I don't see a docs update in this PR — happy to merge without one if a follow-up is tracked.

Neither blocks this PR.

}
}
21 changes: 21 additions & 0 deletions pkg/useragent/useragent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package useragent

import (
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

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

func TestSetIdentity(t *testing.T) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/", http.NoBody)
require.NoError(t, err)

SetIdentity(req)

assert.Equal(t, Header, req.Header.Get("User-Agent"))
assert.Equal(t, version.Version, req.Header.Get(HeaderAgentVersion))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: two behaviours that the PR description calls out as load-bearing don't have a unit test here:

  1. Desktop unreachable → X-Docker-Desktop-Version omitted, not set to empty string. Today this is implicit (because no Desktop socket is around in CI) — the existing tests in api/fetch/openapi assert presence of the agent-version header but don't assert absence of the Desktop one. A test that injects a stub returning "" and asserts req.Header.Get(HeaderDesktopVersion) == "" and that the key is not present in req.Header would pin the contract.
  2. Operator-supplied override wins. The setHeaders callers apply caller headers after SetIdentity, which is the documented precedence — but there's no test pinning that a User-Agent or X-Docker-Agent-Version in the operator config overrides the defaults. pkg/tools/builtin/fetch/fetch_test.go has a TestFetch_Headers_OverrideDefaults for User-Agent; an analogous one for X-Docker-Agent-Version (and ideally one in api/openapi) would catch a future refactor that flips the order.

Both are quick to add and would lock in the invariants the PR description leans on.

Loading