Files
David Gageot c04652c376 feat(sandbox): add cloud mode and v3 kit support
Adds --cloud to run agents in fresh remote sbx sandboxes without
uploading local files, config, or credentials, plus --sandbox-kit/
--kit/--kit-arg for sbx v3 workload and mixin composition. Local
sandbox reuse now matches on an exact launch fingerprint instead of
adopting or removing unrelated sandboxes, and gateway login gets a
v3 credential/network mixin alongside the existing v2 one.

Freezes the resolved agent source before forwarding it into the
sandbox and stages built-in agents read-only so a guest alias can't
swap the selection. Cloud runs fail closed on a bad user config,
require an explicit model for built-in agents, and create sandboxes
under a random name so an interrupted response can be reconciled
without resuming or stopping someone else's sandbox. Kit hashing is
now unambiguous and ignores .git, and local state dir overrides are
constrained to the sandbox workspace.

Assisted-By: docker-agent
2026-09-25 15:02:30 +02:00

104 lines
3.0 KiB
Go

package sandbox
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"slices"
"github.com/docker/docker-agent/pkg/atomicfile"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/sources"
"github.com/docker/docker-agent/pkg/paths"
)
// ExtraWorkspace returns the directory to mount as a read-only extra
// workspace when the agent file lives outside the main workspace.
//
// The agent reference may be a path, an OCI/URL reference, a built-in
// name, or an alias defined in the user's config — ExtraWorkspace
// delegates resolution to [sources.Resolve] so all of those forms are
// handled the same way runtime code handles them. Only [Source]s that
// expose a containing directory (i.e. local file sources) produce a
// mount; OCI / URL / built-in / bytes sources return "" because there
// is no host file to bind-mount.
//
// Returns "" when no extra mount is needed (the agent file is already
// under wd), the reference cannot be resolved, or the resolved source
// has no on-disk parent directory.
func ExtraWorkspace(wd, agentRef string) string {
if agentRef == "" {
return ""
}
source, err := sources.Resolve(agentRef, nil)
if err != nil {
return ""
}
return ExtraWorkspaceForSource(wd, source)
}
// ExtraWorkspaceForSource uses the frozen selection rather than reloading aliases.
func ExtraWorkspaceForSource(wd string, source config.Source) string {
parent := source.ParentDir()
if parent == "" {
return ""
}
absParent, err := CanonicalPath(parent)
if err != nil {
return ""
}
absWd, err := CanonicalPath(wd)
if err != nil {
return ""
}
// No extra mount needed if the file is already under the workspace.
rel, err := filepath.Rel(absWd, absParent)
if err == nil && rel != ".." && !startsWithParent(rel) {
return ""
}
return absParent
}
// startsWithParent reports whether rel begins with a "../" segment,
// which means absParent is not a subdirectory of absWd.
func startsWithParent(rel string) bool {
const dotdot = ".." + string(filepath.Separator)
return len(rel) >= len(dotdot) && rel[:len(dotdot)] == dotdot
}
// GuestAgentRef pins built-ins to a file so the guest cannot resolve their names
// through a different alias in the mounted user configuration.
func GuestAgentRef(ctx context.Context, source config.Source) (ref, extra string, err error) {
if source.ParentDir() != "" {
ref, err = CanonicalFilePath(source.Name())
return ref, "", err
}
if !slices.Contains(sources.BuiltinAgentNames(), source.Name()) {
return source.Name(), "", nil
}
data, err := source.Read(ctx)
if err != nil {
return "", "", err
}
dir := filepath.Join(paths.GetCacheDir(), "sandbox-agents", source.Name())
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", fmt.Errorf("staging builtin agent: %w", err)
}
dir, err = CanonicalPath(dir)
if err != nil {
return "", "", err
}
ref = filepath.Join(dir, "agent.yaml")
if err := atomicfile.Write(ref, bytes.NewReader(data), 0o600); err != nil {
return "", "", fmt.Errorf("staging builtin agent: %w", err)
}
return ref, dir, nil
}