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
2 changes: 1 addition & 1 deletion cmd/root/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
autoStatus.Note = "credentials are supplied by the models gateway"
// Mirrors the run-time preflight: the Docker AI Gateway authenticates
// with the Docker Desktop JWT, not per-provider API keys.
if environment.IsTrustedDockerURL(f.runConfig.ModelsGateway) {
if environment.IsDockerDomainURL(f.runConfig.ModelsGateway) {
if _, ok := findSource(ctx, sources, environment.DockerDesktopTokenEnv); !ok {
autoStatus.Usable = false
autoIssues = append(autoIssues,
Expand Down
27 changes: 8 additions & 19 deletions cmd/root/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -532,9 +532,8 @@ func newGatewayServer(t *testing.T, body string) (*httptest.Server, *atomic.Valu
return server, &lastAuth
}

// gatewayTestEnv is the hermetic env for gateway tests: httptest binds to
// 127.0.0.1, which IsTrustedDockerURL treats as trusted, so discovery
// requires the Docker Desktop token.
// gatewayTestEnv is the hermetic env for gateway tests: loopback gateways are
// trusted to receive an available Docker token but do not require one.
func gatewayTestEnv(extra map[string]string) map[string]string {
env := map[string]string{environment.DockerDesktopTokenEnv: "test-docker-token"}
maps.Copy(env, extra)
Expand Down Expand Up @@ -567,7 +566,7 @@ func TestModelsListCommand_GatewayProviderFilter(t *testing.T) {
require.Len(t, rows, 1, "--provider must filter the live gateway results")
assert.Equal(t, "google", rows[0].Provider)
assert.Equal(t, "mock-gemini", rows[0].Model)
assert.Equal(t, "Bearer test-docker-token", lastAuth.Load(), "a trusted Docker gateway must be queried with the Docker token")
assert.Equal(t, "Bearer test-docker-token", lastAuth.Load(), "a trusted loopback gateway may receive the Docker token")
}

// TestModelsListCommand_GatewayNormalizesAndSorts covers the full live
Expand Down Expand Up @@ -689,10 +688,9 @@ func TestModelsListCommand_GatewayFallback(t *testing.T) {
t.Parallel()

tests := []struct {
name string
handler http.HandlerFunc
env map[string]string
wantNotQueried bool
name string
handler http.HandlerFunc
env map[string]string
}{
{
name: "endpoint not found",
Expand All @@ -714,25 +712,19 @@ func TestModelsListCommand_GatewayFallback(t *testing.T) {
env: gatewayTestEnv(map[string]string{"ANTHROPIC_API_KEY": "test-key"}),
},
{
// httptest is localhost, hence Docker-trusted: without the token
// the live request must not even be attempted, but the auth
// failure must not remove directly usable providers.
name: "missing Docker token",
handler: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"openai/mock-gpt"}]}`))
http.Error(w, "unavailable", http.StatusServiceUnavailable)
},
env: map[string]string{"ANTHROPIC_API_KEY": "test-key"},
wantNotQueried: true,
env: map[string]string{"ANTHROPIC_API_KEY": "test-key"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var queried atomic.Bool
gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
queried.Store(true)
tt.handler(w, r)
}))
t.Cleanup(gateway.Close)
Expand All @@ -756,9 +748,6 @@ func TestModelsListCommand_GatewayFallback(t *testing.T) {
assert.Contains(t, output, "claude-sonnet-5", "the direct anthropic provider must survive the gateway failure")
assert.Contains(t, output, catalogOnlyModel, "the catalog fallback must be read")
assert.Contains(t, output, "corp-model-a", "a usable custom provider must survive the gateway failure")
if tt.wantNotQueried {
assert.False(t, queried.Load(), "a trusted Docker gateway must not be queried without the Docker token")
}
})
}
}
Expand Down
8 changes: 4 additions & 4 deletions docs/features/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ $ docker agent run [config] [message...] [flags]
| `--env-from-file <path>` | Load environment variables from file (repeatable) |
| `--flavor <name>` | Enable a config flavor, a YAML patch defined under the config's `flavors` section (repeatable, applied in order). See [Flavors](../../configuration/flavors/index.md). |
| `--code-mode-tools` | Provide a single tool to call other tools via JavaScript (forces code-mode tools globally) |
| `--models-gateway <addr>` | Route model traffic through a gateway. Also reads `DOCKER_AGENT_MODELS_GATEWAY` (legacy `CAGENT_MODELS_GATEWAY`) env var. |
| `--models-gateway <addr>` | Route model traffic through a gateway. Docker Desktop sign-in is required only for HTTPS `docker.com` gateways; loopback and third-party gateways need no Docker token. Also reads `DOCKER_AGENT_MODELS_GATEWAY` (legacy `CAGENT_MODELS_GATEWAY`) env var. |
| `--hook-pre-tool-use <cmd>` | Add a pre-tool-use hook command (repeatable). See [Hooks](../../configuration/hooks/index.md). |
| `--hook-post-tool-use <cmd>` | Add a post-tool-use hook command (repeatable) |
| `--hook-session-start <cmd>` | Add a session-start hook command (repeatable) |
Expand All @@ -69,7 +69,7 @@ $ docker agent run [config] [message...] [flags]
| `--hook-stop <cmd>` | Add a stop hook command, fired when the model finishes responding (repeatable) |
| `--fake <path>` | Replay AI responses from a cassette file (for testing). Mutually exclusive with `--record`. |
| `--fake-stream [ms]` | When replaying with `--fake`, simulate streaming with a delay between chunks (defaults to 15ms when given without a value). |
| `--record [path]` | Record AI API interactions to a cassette file and generate a TUI e2e test from the session (auto-generates filename if no path given). Routes through `--models-gateway` when one is configured. |
| `--record [path]` | Record AI API interactions to a cassette file and generate a TUI e2e test from the session (auto-generates filename if no path given). Routes through `--models-gateway` when one is configured. Encrypted agent config and its digest are never stored in cassettes. |
| `-d, --debug` | Enable debug logging |
| `--log-file <path>` | Custom debug log location |
| `-o, --otel` | Enable OpenTelemetry observability: traces, metrics, and logs. Requires `OTEL_EXPORTER_OTLP_ENDPOINT` to export to a collector. |
Expand Down Expand Up @@ -211,7 +211,7 @@ $ docker agent models --provider openai
$ docker agent models --format json | jq
```

When a models gateway is configured (`--models-gateway`, `DOCKER_AGENT_MODELS_GATEWAY`, or the user config), the command first queries the gateway's `/v1/models` endpoint. A non-empty response is authoritative for the models routed through the gateway: the listing shows the models the gateway serves (`--provider` filters within it), alongside any custom providers you have configured, which serve their models from their own endpoints rather than through the gateway. If the gateway cannot be queried or serves no usable model (endpoint not implemented, empty list, invalid response, timeout, missing authentication), the command falls back to the providers you have configured directly — provider API keys, provider aliases, and custom providers — plus the model catalog; a failure of one source never prevents the others from being listed. The Docker Desktop token is only sent (and required) when the gateway targets a trusted Docker URL.
When a models gateway is configured (`--models-gateway`, `DOCKER_AGENT_MODELS_GATEWAY`, or the user config), the command first queries the gateway's `/v1/models` endpoint. A non-empty response is authoritative for the models routed through the gateway: the listing shows the models the gateway serves (`--provider` filters within it), alongside any custom providers you have configured, which serve their models from their own endpoints rather than through the gateway. If the gateway cannot be queried or serves no usable model (endpoint not implemented, empty list, invalid response, timeout, missing authentication), the command falls back to the providers you have configured directly — provider API keys, provider aliases, and custom providers — plus the model catalog; a failure of one source never prevents the others from being listed. Docker Desktop authentication is required only for HTTPS `docker.com` gateways. An available Docker Desktop token may also be sent to trusted loopback gateways, but is never sent to third-party gateways.

### `docker agent toolsets`

Expand Down Expand Up @@ -288,7 +288,7 @@ $ docker agent serve api <agent-file>|<agents-dir>|<registry-ref> [flags]
| `-s, --session-db <path>` | `session.db` | Path to the SQLite session database (relative paths resolve against the working directory). |
| `--pull-interval <minutes>`| `0` | Periodically re-pull OCI/URL references and refresh the agent definition. `0` disables auto-pull. |
| `--fake <path>` | (none) | Replay AI responses from a cassette file (for testing). Mutually exclusive with `--record`. |
| `--record <path>` | (none) | Record AI API interactions to a cassette file. Routes through `--models-gateway` when one is configured. |
| `--record <path>` | (none) | Record AI API interactions to a cassette file. Routes through `--models-gateway` when one is configured; encrypted agent config and its digest are omitted from cassettes. |
| `--mcp-oauth-redirect-uri <url>` | (none) | OAuth redirect URI for the unmanaged MCP OAuth flow in server mode. When set, the runtime drives PKCE and code exchange in-process and sends the full authorize URL to the client via elicitation. See [Remote MCP](../remote-mcp/index.md) for details. |

> **Diagnostics:** Set `CAGENT_PPROF_ADDR=127.0.0.1:6060` (or `--pprof-addr`, a hidden flag) to start a live Go pprof server at `/debug/pprof/`. Use a loopback address; a non-loopback binding logs a security warning.
Expand Down
2 changes: 1 addition & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func readInstructionFiles(parentDir string, paths []string) (string, error) {
//
// This allows exiting early with a proper error message instead of failing later when trying to use a model or tool.
func CheckRequiredEnvVars(ctx context.Context, cfg *latest.Config, modelsGateway string, env environment.Provider) error {
if modelsGateway != "" && environment.IsTrustedDockerURL(modelsGateway) {
if modelsGateway != "" && environment.IsDockerDomainURL(modelsGateway) {
if jwt, _ := env.Get(ctx, environment.DockerDesktopTokenEnv); jwt == "" {
return errors.New("sorry, you first need to sign in Docker Desktop to use the Docker AI Gateway")
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ func TestCheckRequiredEnvVarsWithModelGateway(t *testing.T) {
require.NoError(t, err)

err = CheckRequiredEnvVars(t.Context(), cfg, "http://localhost:8080", &noEnvProvider{})
require.ErrorContains(t, err, "sign in Docker Desktop")
require.NoError(t, err)
})

t.Run("localhost gateway with token", func(t *testing.T) {
Expand Down
16 changes: 12 additions & 4 deletions pkg/environment/docker-desktop.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ const (
DockerDesktopTokenEnv = "DOCKER_TOKEN"
)

// IsDockerDomainURL reports whether rawURL targets docker.com or one of its
// subdomains over HTTPS.
func IsDockerDomainURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme != "https" {
return false
}
host := strings.ToLower(u.Hostname())
return host == "docker.com" || strings.HasSuffix(host, ".docker.com")
}

// IsTrustedDockerURL checks if the URL targets a domain trusted to receive
// the Docker Desktop JWT. It matches:
// - "docker.com" and any subdomain (e.g. "desktop.docker.com") over HTTPS only
Expand All @@ -32,10 +43,7 @@ func IsTrustedDockerURL(rawURL string) bool {
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return true
}
if u.Scheme != "https" {
return false
}
return host == "docker.com" || strings.HasSuffix(host, ".docker.com")
return IsDockerDomainURL(rawURL)
}

type DockerDesktopProvider struct{}
Expand Down
26 changes: 26 additions & 0 deletions pkg/environment/docker_desktop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@ import (
"github.com/docker/docker-agent/pkg/environment"
)

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

tests := []struct {
url string
want bool
}{
{"https://docker.com", true},
{"https://api.docker.com/models", true},
{"https://DOCKER.COM", true},
{"http://docker.com", false},
{"https://docker.com.evil.com", false},
{"https://notdocker.com", false},
{"http://localhost:8080", false},
{"https://127.0.0.1:8080", false},
{"not-a-url", false},
}

for _, tt := range tests {
t.Run(tt.url, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, environment.IsDockerDomainURL(tt.url))
})
}
}

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

Expand Down
37 changes: 37 additions & 0 deletions pkg/fake/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"

"github.com/docker/docker-agent/pkg/environment"
"github.com/docker/docker-agent/pkg/httpclient"
)

// ProxyOptions configures the fake proxy behavior.
Expand Down Expand Up @@ -93,6 +94,16 @@ func StartStreamingRecordingProxy(
cassettePath string,
upstreamGateway string,
headerUpdater func(host string, req *http.Request),
) (string, func() error, error) {
return startStreamingRecordingProxy(ctx, cassettePath, upstreamGateway, headerUpdater, http.DefaultTransport)
}

func startStreamingRecordingProxy(
ctx context.Context,
cassettePath string,
upstreamGateway string,
headerUpdater func(host string, req *http.Request),
transport http.RoundTripper,
) (string, func() error, error) {
// Fail fast on a bad gateway URL instead of returning 500s per request.
if upstreamGateway != "" {
Expand All @@ -106,6 +117,27 @@ func StartStreamingRecordingProxy(
return "", nil, fmt.Errorf("failed to create streaming recorder: %w", err)
}

streamRec.transport = transport
streamRec.SetCaptureRequest(func(req *http.Request) ([]byte, error) {
recorded := req.Clone(req.Context())
if req.GetBody != nil {
body, err := req.GetBody()
if err != nil {
return nil, err
}
recorded.Body = body
}
if err := httpclient.RemoveEncryptedConfig(recorded); err != nil {
return nil, err
}
req.Header.Del(httpclient.EncryptedConfigDigestHeader)
if recorded.Body == nil || recorded.Body == http.NoBody {
return nil, nil
}
defer recorded.Body.Close()
return io.ReadAll(recorded.Body)
})

e := echo.New()
e.HideBanner = true
e.HidePort = true
Expand Down Expand Up @@ -448,6 +480,11 @@ func Handle(transport http.RoundTripper, headerUpdater func(host string, req *ht
if headerUpdater != nil {
headerUpdater(host, req)
}
if !environment.IsTrustedDockerURL(options.UpstreamGateway) {
if err := httpclient.RemoveEncryptedConfig(req); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to scrub encrypted agent config")
}
}

client := &http.Client{
Timeout: 0, // no timeout, let ctx control it
Expand Down
109 changes: 109 additions & 0 deletions pkg/fake/proxy_gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,117 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/dnaeon/go-vcr.v4/pkg/cassette"

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

func TestStartRecordingProxy_EncryptedConfigSecrecy(t *testing.T) {
const (
encrypted = "ENCRYPTED-AGENT-CONFIG"
digest = "sha256:DIGEST-SECRET"
)

tests := []struct {
name string
upstreamTrustURL string
wantUpstreamField bool
}{
{name: "untrusted upstream", upstreamTrustURL: "https://gateway.example.com"},
{name: "trusted loopback upstream", upstreamTrustURL: "http://localhost:8080", wantUpstreamField: true},
{name: "trusted Docker upstream", upstreamTrustURL: "https://models.docker.com", wantUpstreamField: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var upstreamBody []byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
upstreamBody, err = io.ReadAll(r.Body)
assert.NoError(t, err)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer upstream.Close()

cassettePath := t.TempDir() + "/recording"
transport := hostRewriteRoundTripper{target: upstream.URL}
proxyURL, cleanup, err := startStreamingRecordingProxy(t.Context(), cassettePath, tt.upstreamTrustURL,
gatewayAuthHeaderUpdater(tt.upstreamTrustURL), transport)
require.NoError(t, err)
t.Cleanup(func() { _ = cleanup() })

req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, proxyURL+"/v1/chat/completions",
strings.NewReader(`{"model":"gpt-4o","encrypted_agent_config":"`+encrypted+`"}`))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Cagent-Forward", "https://api.openai.com/v1")
req.Header.Set(httpclient.EncryptedConfigDigestHeader, digest)

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.NoError(t, cleanup())

if tt.wantUpstreamField {
assert.Contains(t, string(upstreamBody), encrypted)
} else {
assert.NotContains(t, string(upstreamBody), encrypted)
}

data, err := os.ReadFile(cassettePath + ".yaml")
require.NoError(t, err)
assert.NotContains(t, string(data), encrypted)
assert.NotContains(t, string(data), digest)
})
}
}

type hostRewriteRoundTripper struct {
target string
}

func (t hostRewriteRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
target, err := http.NewRequestWithContext(req.Context(), req.Method, t.target+req.URL.RequestURI(), req.Body)
if err != nil {
return nil, err
}
target.Header = req.Header.Clone()
return http.DefaultTransport.RoundTrip(target)
}

func TestStartRecordingProxy_NoUpstreamScrubsEncryptedConfig(t *testing.T) {
const encrypted = "NO-UPSTREAM-SECRET"

var upstreamBody []byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
upstreamBody, err = io.ReadAll(r.Body)
assert.NoError(t, err)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer upstream.Close()

cassettePath := t.TempDir() + "/recording"
proxyURL, cleanup, err := StartStreamingRecordingProxy(t.Context(), cassettePath, "", nil)
require.NoError(t, err)
t.Cleanup(func() { _ = cleanup() })

req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, proxyURL+"/v1/chat/completions",
strings.NewReader(`{"model":"gpt-4o","encrypted_agent_config":"`+encrypted+`"}`))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Cagent-Forward", upstream.URL)

resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.NoError(t, cleanup())

assert.NotContains(t, string(upstreamBody), encrypted)
data, err := os.ReadFile(cassettePath + ".yaml")
require.NoError(t, err)
assert.NotContains(t, string(data), encrypted)
}

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

Expand Down
Loading
Loading