mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
teamloader discovers the agent config envelope (the signed/encrypted blob a trusted Docker /gordon-agent fetch returns) by type-asserting the source it is handed for config.EncryptedConfigSource, then adopts it into RuntimeConfig.EncryptedConfig so it rides in the JSON body of every gateway-bound model request. In API-server mode — `docker agent serve api`, i.e. Docker Desktop — NewSessionManager wraps every source in a sourceLoader for caching and periodic refresh. sourceLoader only implemented Name/ParentDir/Read, so the capability was lost at the decorator: the assertion failed, no envelope was forwarded, and the gateway had nothing to verify. Because the gateway deliberately fails open on a request that carries no config, nothing errored — prompt verification just silently never ran, emitting no verified_prompt/agentConfigHash trace attributes at all. The CLI path was unaffected: it passes the source to teamloader undecorated. Forward EncryptedConfig() through to the inner source, mirroring the same passthrough pkg/config/hcl already does for its decorator. It reads through rather than caching, so an artifact repushed under a new signature is picked up by the existing refresh loop without restarting the daemon, and returns "" for sources without the capability (local files), leaving request bodies untouched. A compile-time interface assertion pins the capability, since dropping the method would otherwise silently stop forwarding rather than fail the build.
144 lines
3.7 KiB
Go
144 lines
3.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/docker/docker-agent/pkg/config"
|
|
)
|
|
|
|
var sourceRetrySchedule = []time.Duration{2 * time.Second, 15 * time.Second, 70 * time.Second}
|
|
|
|
// sourceLoader must keep satisfying config.EncryptedConfigSource: it decorates
|
|
// every agent source in API-server mode, and teamloader discovers the agent
|
|
// config envelope through a type assertion, so dropping the method would
|
|
// silently stop forwarding it to the models gateway rather than fail to build.
|
|
var _ config.EncryptedConfigSource = (*sourceLoader)(nil)
|
|
|
|
type sourceLoader struct {
|
|
inner config.Source
|
|
refreshInterval time.Duration
|
|
|
|
mu sync.RWMutex
|
|
data []byte
|
|
err error
|
|
}
|
|
|
|
func newSourceLoader(ctx context.Context, inner config.Source, refreshInterval time.Duration) *sourceLoader {
|
|
sl := &sourceLoader{
|
|
inner: inner,
|
|
refreshInterval: refreshInterval,
|
|
}
|
|
|
|
sl.load(ctx)
|
|
|
|
if sl.hasError() {
|
|
go sl.retryStartup(ctx)
|
|
}
|
|
|
|
if refreshInterval > 0 {
|
|
go sl.refreshLoop(ctx)
|
|
}
|
|
|
|
return sl
|
|
}
|
|
|
|
func (sl *sourceLoader) Name() string {
|
|
return sl.inner.Name()
|
|
}
|
|
|
|
func (sl *sourceLoader) ParentDir() string {
|
|
return sl.inner.ParentDir()
|
|
}
|
|
|
|
func (sl *sourceLoader) Read(_ context.Context) ([]byte, error) {
|
|
sl.mu.RLock()
|
|
defer sl.mu.RUnlock()
|
|
return sl.data, sl.err
|
|
}
|
|
|
|
// EncryptedConfig forwards the inner source's captured agent config envelope so
|
|
// this caching decorator stays transparent to callers that type-assert for
|
|
// [config.EncryptedConfigSource] — chiefly teamloader, which adopts the value
|
|
// into RuntimeConfig.EncryptedConfig so it is forwarded to a trusted Docker
|
|
// models gateway on every model request.
|
|
//
|
|
// Without this passthrough the capability is silently lost in API-server mode
|
|
// (`docker agent serve api`, i.e. Docker Desktop): NewSessionManager wraps every
|
|
// source in a sourceLoader, so teamloader's assertion fails, no config envelope
|
|
// is forwarded, and the gateway's prompt verification degrades to a no-op — it
|
|
// fails open on a request that carries no config, so nothing surfaces as an
|
|
// error. The CLI path is unaffected because it hands the source over undecorated.
|
|
// See also the identical passthrough on the HCL decorator (pkg/config/hcl).
|
|
//
|
|
// It reads through to the inner source rather than caching the value: the config
|
|
// envelope is refreshed by the very same Read that refreshLoop performs, so a
|
|
// tag repushed under a new signature is picked up without a restart. Returns ""
|
|
// when the inner source does not support the capability.
|
|
func (sl *sourceLoader) EncryptedConfig() string {
|
|
if ecs, ok := sl.inner.(config.EncryptedConfigSource); ok {
|
|
return ecs.EncryptedConfig()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (sl *sourceLoader) load(ctx context.Context) {
|
|
data, err := sl.inner.Read(ctx)
|
|
|
|
sl.mu.Lock()
|
|
defer sl.mu.Unlock()
|
|
|
|
if err != nil {
|
|
// Only log errors, keep previous data if available
|
|
slog.WarnContext(ctx, "Failed to refresh source",
|
|
"source", sl.inner.Name(),
|
|
"error", err)
|
|
// Only update error if we don't have data yet
|
|
if len(sl.data) == 0 {
|
|
sl.err = err
|
|
}
|
|
} else {
|
|
sl.data = data
|
|
sl.err = nil
|
|
}
|
|
}
|
|
|
|
func (sl *sourceLoader) hasError() bool {
|
|
sl.mu.RLock()
|
|
defer sl.mu.RUnlock()
|
|
return sl.err != nil
|
|
}
|
|
|
|
func (sl *sourceLoader) retryStartup(ctx context.Context) {
|
|
for _, delay := range sourceRetrySchedule {
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return
|
|
case <-timer.C:
|
|
}
|
|
|
|
sl.load(ctx)
|
|
if !sl.hasError() {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (sl *sourceLoader) refreshLoop(ctx context.Context) {
|
|
ticker := time.NewTicker(sl.refreshInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
sl.load(ctx)
|
|
}
|
|
}
|
|
}
|