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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ export MANAGED_AGENT_SANDBOX=docker
go run ./cmd/managed-agent orchestrate
```

`MANAGED_AGENT_SANDBOX` is a strict deployment-level provider selection:
`local` is the offline default and `docker` is the isolated opt-in. Unknown
provider names fail startup. Provider-specific configuration is intentionally
kept out of the Managed Agents public API.

Do not run workers with different model or sandbox configuration on the same
Temporal Task Queue.

Expand Down
68 changes: 50 additions & 18 deletions cmd/managed-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

Expand All @@ -34,6 +35,11 @@ const defaultAddr = "127.0.0.1:8080"
// guardModelSandbox.
const unsafeLocalSandboxEnv = "MANAGED_AGENT_ALLOW_UNSAFE_LOCAL_SANDBOX"

const (
sandboxProviderEnv = "MANAGED_AGENT_SANDBOX"
sandboxImageEnv = "MANAGED_AGENT_SANDBOX_IMAGE"
)

// resolveRuntime returns the self-hosted agent core and whether it is backed by
// a real (network-backed) model. It uses the real Messages API client when
// MANAGED_AGENT_MODEL_BASE_URL and MANAGED_AGENT_MODEL_API_KEY are set,
Expand All @@ -58,26 +64,52 @@ func resolveRuntime() (rt agentruntime.AgentRuntime, realModel bool, err error)
return agentruntime.NewAgentCore(client, domain.NewRandomIDGen()), realModel, nil
}

// resolveSandboxProvider selects the sandbox backend from the environment and
// reports whether the selection is the local (non-isolating) provider. The
// default is the offline, dev-grade local provider so the binary runs (and
// tests stay offline) with no configuration. Set MANAGED_AGENT_SANDBOX=docker
// to opt into the Docker-backed provider, which gives real isolation (Linux
// namespaces/cgroups + --network none). MANAGED_AGENT_SANDBOX_IMAGE overrides
// the container image (NewDockerProvider defaults to alpine:latest when empty).
// sandboxProviderRegistry declares the adapters compiled into this worker.
// Factories are lazy: selecting local never initializes Docker, and future
// optional remote adapters will not require credentials unless selected.
func sandboxProviderRegistry() (*sandbox.ProviderRegistry, error) {
return sandbox.NewProviderRegistry(
sandbox.ProviderRegistration{
Name: sandbox.LocalProviderName,
Factory: func() (sandbox.Provider, error) {
return sandbox.NewLocalProvider(), nil
},
},
sandbox.ProviderRegistration{
Name: sandbox.DockerProviderName,
Factory: func() (sandbox.Provider, error) {
return sandbox.NewDockerProvider(sandbox.DockerConfig{
DefaultImage: os.Getenv(sandboxImageEnv),
})
},
},
)
}

// resolveSandboxProvider selects the process-wide sandbox backend from the
// internal registry and reports whether it is the local non-isolating provider.
// Provider choice is deployment configuration, not part of the Managed Agents
// public API. Unknown names fail closed instead of silently falling back to
// host execution.
func resolveSandboxProvider() (p sandbox.Provider, isLocal bool, err error) {
if os.Getenv("MANAGED_AGENT_SANDBOX") == "docker" {
dp, err := sandbox.NewDockerProvider(sandbox.DockerConfig{
DefaultImage: os.Getenv("MANAGED_AGENT_SANDBOX_IMAGE"),
})
if err != nil {
return nil, false, err
}
log.Printf("sandbox: docker provider (real isolation)")
return dp, false, nil
name := strings.TrimSpace(os.Getenv(sandboxProviderEnv))
if name == "" {
name = sandbox.LocalProviderName
}
registry, err := sandboxProviderRegistry()
if err != nil {
return nil, false, err
}
provider, err := registry.Open(name)
if err != nil {
return nil, false, err
}
if name == sandbox.LocalProviderName {
log.Printf("sandbox: local provider (dev-grade guardrail, not a security boundary)")
return provider, true, nil
}
log.Printf("sandbox: local provider (dev-grade guardrail, not a security boundary)")
return sandbox.NewLocalProvider(), true, nil
log.Printf("sandbox: %s provider", name)
return provider, false, nil
}

// guardModelSandbox refuses to start when a real, network-backed model is paired
Expand Down
28 changes: 28 additions & 0 deletions cmd/managed-agent/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,34 @@ func TestResolveSandboxProvider_DefaultsToLocal(t *testing.T) {
}
}

func TestResolveSandboxProvider_RejectsUnknownSelection(t *testing.T) {
t.Setenv(sandboxProviderEnv, "dockre")
_, _, err := resolveSandboxProvider()
if err == nil {
t.Fatal("resolveSandboxProvider accepted an unknown provider")
}
if !strings.Contains(err.Error(), `unsupported provider "dockre"`) ||
!strings.Contains(err.Error(), "available: docker, local") {
t.Fatalf("resolveSandboxProvider error = %q", err)
}
}

func TestSandboxProviderRegistry_IsLazy(t *testing.T) {
t.Setenv("PATH", "")
t.Setenv(sandboxImageEnv, "unused.invalid/image")
registry, err := sandboxProviderRegistry()
if err != nil {
t.Fatal(err)
}
provider, err := registry.Open(sandbox.LocalProviderName)
if err != nil {
t.Fatalf("opening local initialized optional Docker provider: %v", err)
}
if provider.Name() != sandbox.LocalProviderName {
t.Fatalf("provider name = %q", provider.Name())
}
}

func TestResolveRuntime_UsesFakeModelWithoutEnv(t *testing.T) {
t.Setenv("MANAGED_AGENT_MODEL_BASE_URL", "")
t.Setenv("MANAGED_AGENT_MODEL_API_KEY", "")
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ model vendor, sandbox backend, or worker topology.
| `internal/store` | Deprecated SQLite compatibility implementation |
| `internal/agentruntime` | Model/tool orchestration behind `AgentRuntime` |
| `internal/model` | Offline and Messages API model clients |
| `internal/sandbox` | Local and Docker execution providers |
| `internal/sandbox` | Provider registry, lifecycle contract, and local/Docker adapters |

The dependency direction points inward: transport and infrastructure depend on
application/domain semantics, while the domain has no HTTP, SQL, model-client,
Expand Down
4 changes: 4 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ export MANAGED_AGENT_SANDBOX=docker
go run ./cmd/managed-agent orchestrate
```

The provider name is validated strictly. The compiled choices are currently
`local` and `docker`; an unknown value fails worker startup and never silently
falls back to local host execution.

The configured endpoint must expose an Anthropic-shaped `/v1/messages` API.
Do not run workers with different model or sandbox configuration on the same
Temporal Task Queue. Keep credentials in the environment and never commit them.
Expand Down
20 changes: 16 additions & 4 deletions docs/sandboxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ Temporal Activity -> SessionManager -> sandbox.Provider -> sandbox.Sandbox
`SessionManager` gives each session one logical sandbox. PostgreSQL persists the
provider name and opaque external ID; a restarted worker calls `Attach` instead
of creating an empty replacement. `Sandbox` exposes command execution, confined
file access, a workspace root, and teardown.
file access, a workspace root, and teardown. The execution worker selects one
compiled adapter through an internal registry. `MANAGED_AGENT_SANDBOX` accepts
`local` (the default) or `docker`; an unknown name fails startup instead of
falling back to host execution. Provider selection does not add fields to the
Managed Agents Environment or Session APIs.

## Support levels

Expand Down Expand Up @@ -60,6 +64,12 @@ A backend implements the core lifecycle contract when it can:
5. read and write paths relative to the workspace;
6. destroy the resource idempotently.

These requirements are executable in
`internal/sandbox/sandboxtest`. Both built-in providers run the same suite,
including cross-client Create/Attach, workspace preservation, ownership
rejection, cancellation, and post-delete missing-reference behavior.
Provider-specific tests cover isolation and resource controls separately.

The built-in toolset currently assumes a POSIX-like environment with
`/bin/sh`, `find`, and `grep`. A backend that does not provide those commands is
not compatible with all executing built-ins yet.
Expand All @@ -82,9 +92,9 @@ not compatible with all executing built-ins yet.

Remote adapters must use the same lifecycle tests. Production deployments still
need orphan reconciliation for the create-before-binding crash window, provider
health reporting, and a provider registry suitable for heterogeneous workers.
Pause, snapshot, fork, quotas, and eviction remain optional capabilities rather
than requirements of the core interface.
health reporting, and provider-aware task routing when heterogeneous workers
share a control plane. Pause, snapshot, fork, quotas, and eviction remain
optional capabilities rather than requirements of the core interface.

The upstream behavior informing the session/environment distinction is
documented in Claude's
Expand All @@ -100,6 +110,8 @@ A backend contribution should:

- keep its external dependency optional and fail fast when explicitly selected
but unavailable;
- register a lazy factory under a stable lowercase provider name and pass the
shared `sandboxtest` lifecycle suite;
- preserve the session-scoped ownership contract;
- document its trust boundary, network defaults, resource controls, host
requirements, and unsupported lifecycle features;
Expand Down
46 changes: 46 additions & 0 deletions internal/sandbox/conformance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package sandbox_test

import (
"os/exec"
"testing"
"time"

"github.com/yanpgwang/managed-agent-go/internal/sandbox"
"github.com/yanpgwang/managed-agent-go/internal/sandbox/sandboxtest"
)

func TestLocalProviderConformance(t *testing.T) {
sandboxtest.Run(t, sandboxtest.Config{
NewProvider: func(*testing.T) sandbox.Provider {
return sandbox.NewLocalProvider()
},
Spec: sandbox.Spec{Timeout: 30 * time.Second},
})
}

func TestDockerProviderConformance(t *testing.T) {
sandboxtest.Run(t, sandboxtest.Config{
NewProvider: func(t *testing.T) sandbox.Provider {
t.Helper()
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker not installed")
}
if err := exec.Command(
"docker",
"version",
"--format",
"{{.Server.Version}}",
).Run(); err != nil {
t.Skip("docker daemon not reachable")
}
provider, err := sandbox.NewDockerProvider(sandbox.DockerConfig{
DefaultImage: "alpine:latest",
})
if err != nil {
t.Fatal(err)
}
return provider
},
Spec: sandbox.Spec{Timeout: 30 * time.Second},
})
}
3 changes: 1 addition & 2 deletions internal/sandbox/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
)

const (
dockerProviderName = "docker"
dockerManagedLabel = "io.mango.managed"
dockerSessionKeyLabel = "io.mango.session_key"
)
Expand Down Expand Up @@ -74,7 +73,7 @@ func NewDockerProvider(cfg DockerConfig) (Provider, error) {
return &dockerProvider{dockerPath: path, defaultImage: image}, nil
}

func (p *dockerProvider) Name() string { return dockerProviderName }
func (p *dockerProvider) Name() string { return DockerProviderName }

// runDocker invokes the docker CLI with the given args, capturing stdout/stderr
// (capped) and the process exit code. The passed ctx bounds the whole call.
Expand Down
63 changes: 55 additions & 8 deletions internal/sandbox/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import (
"time"
)

const localProviderName = "local"

// maxOutput caps the bytes captured from stdout and stderr, independently.
const maxOutput = 100_000

Expand All @@ -36,7 +34,7 @@ func NewLocalProvider() Provider {
return &localProvider{baseDir: filepath.Join(os.TempDir(), "managed-agent-sandboxes")}
}

func (p *localProvider) Name() string { return localProviderName }
func (p *localProvider) Name() string { return LocalProviderName }

func (p *localProvider) Create(
ctx context.Context,
Expand All @@ -49,11 +47,7 @@ func (p *localProvider) Create(
if sessionKey == "" {
return Ref{}, nil, errors.New("sandbox: session key is required")
}
root := spec.WorkDir
if root == "" {
sum := sha256.Sum256([]byte(sessionKey))
root = filepath.Join(p.baseDir, fmt.Sprintf("session-%x", sum[:16]))
}
root := p.rootFor(sessionKey, spec)
if err := os.MkdirAll(root, 0o700); err != nil {
return Ref{}, nil, fmt.Errorf("sandbox: create root: %w", err)
}
Expand Down Expand Up @@ -85,6 +79,20 @@ func (p *localProvider) Attach(
ref.Provider,
))
}
expectedRoot, err := canonicalLocalPath(p.rootFor(sessionKey, spec))
if err != nil {
return nil, fmt.Errorf("sandbox: resolve expected local workspace: %w", err)
}
referencedRoot, err := canonicalLocalPath(ref.ID)
if err != nil {
return nil, fmt.Errorf("sandbox: resolve referenced local workspace: %w", err)
}
if referencedRoot != expectedRoot {
return nil, Permanent(fmt.Errorf(
"sandbox: local workspace %q belongs to another session",
ref.ID,
))
}
info, err := os.Stat(ref.ID)
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%w: local workspace %q", ErrNotFound, ref.ID)
Expand All @@ -98,6 +106,45 @@ func (p *localProvider) Attach(
return p.attachRoot(ref.ID, spec)
}

// canonicalLocalPath resolves symlinks through the deepest existing ancestor.
// This lets Attach validate session ownership before reporting a missing
// deterministic workspace, even when an operator removed the whole local
// provider base directory.
func canonicalLocalPath(value string) (string, error) {
absolute, err := filepath.Abs(value)
if err != nil {
return "", err
}
current := absolute
var missing []string
for {
resolved, err := filepath.EvalSymlinks(current)
if err == nil {
for i := len(missing) - 1; i >= 0; i-- {
resolved = filepath.Join(resolved, missing[i])
}
return resolved, nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", err
}
parent := filepath.Dir(current)
if parent == current {
return "", err
}
missing = append(missing, filepath.Base(current))
current = parent
}
}

func (p *localProvider) rootFor(sessionKey string, spec Spec) string {
if spec.WorkDir != "" {
return spec.WorkDir
}
sum := sha256.Sum256([]byte(sessionKey))
return filepath.Join(p.baseDir, fmt.Sprintf("session-%x", sum[:16]))
}

func (p *localProvider) attachRoot(root string, spec Spec) (Sandbox, error) {
// Resolve symlinks so confinement checks compare canonical paths (e.g. on
// darwin /tmp is a symlink to /private/tmp).
Expand Down
Loading