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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
*.db-shm
*.db-wal
/data/
/.env
/.env.*
!/.env.example
!/.env.*.example

# Go
/vendor/
Expand Down
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ endif

.DEFAULT_GOAL := help

.PHONY: help build lint test test-race vet verify docs-check image image-smoke \
.PHONY: help build lint test test-race vet verify docs-check image image-smoke dev-env-init \
local-config local-up local-down local-health local-ps local-logs

help:
Expand All @@ -32,6 +32,7 @@ help:
@echo " make vet run go vet"
@echo " make verify run the core Go checks"
@echo " make docs-check install and verify documentation dependencies"
@echo " make dev-env-init create ~/.config/mango/dev.env with mode 0600"
@echo
@echo "Container"
@echo " make image build $(IMAGE)"
Expand Down Expand Up @@ -67,6 +68,15 @@ docs-check:
npm --prefix website run typecheck
npm --prefix website run build

dev-env-init:
@mkdir -p "$${XDG_CONFIG_HOME:-$$HOME/.config}/mango"
@if test -e "$${XDG_CONFIG_HOME:-$$HOME/.config}/mango/dev.env"; then \
echo "development environment already exists; leaving it unchanged"; \
else \
install -m 600 config/dev.env.example "$${XDG_CONFIG_HOME:-$$HOME/.config}/mango/dev.env"; \
echo "created $${XDG_CONFIG_HOME:-$$HOME/.config}/mango/dev.env"; \
fi

image:
$(DOCKER) build $(DOCKER_BUILD_ARGS) --tag $(IMAGE) .

Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,10 @@ 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.
`local` is the offline default; `docker`, `e2b`, `cube`, `opensandbox`, and
`daytona` are explicit opt-ins. 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 Expand Up @@ -223,6 +224,19 @@ make docs-check
make image-smoke
```

Create the optional developer-only environment file once:

```bash
make dev-env-init
$EDITOR ~/.config/mango/dev.env
scripts/with-dev-env go test ./...
```

The file is outside the repository, shared by worktrees, and must remain mode
`0600`. The wrapper exports it only to the requested command; the server never
silently reads a dotenv file. Rotate any credential that has been pasted into
chat, logs, or an issue before storing its replacement there.

`make verify` requires golangci-lint 2.12.x and checks changed Go code against
the standard lint set plus `gofmt`.

Expand Down
163 changes: 163 additions & 0 deletions cmd/managed-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"context"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -38,6 +40,33 @@ const unsafeLocalSandboxEnv = "MANAGED_AGENT_ALLOW_UNSAFE_LOCAL_SANDBOX"
const (
sandboxProviderEnv = "MANAGED_AGENT_SANDBOX"
sandboxImageEnv = "MANAGED_AGENT_SANDBOX_IMAGE"

e2bAPIKeyEnv = "E2B_API_KEY"
e2bAPIURLEnv = "E2B_API_URL"
e2bTemplateEnv = "E2B_TEMPLATE_ID"
e2bDomainEnv = "E2B_DOMAIN"
e2bIdleTimeoutEnv = "E2B_IDLE_TIMEOUT"

cubeAPIKeyEnv = "CUBE_API_KEY"
cubeAPIURLEnv = "CUBE_API_URL"
cubeTemplateEnv = "CUBE_TEMPLATE_ID"
cubeDomainEnv = "CUBE_SANDBOX_DOMAIN"
cubeProxyNodeIPEnv = "CUBE_PROXY_NODE_IP"
cubeProxyPortEnv = "CUBE_PROXY_PORT_HTTP"
cubeProxySchemeEnv = "CUBE_PROXY_SCHEME"
cubeIdleTimeoutEnv = "CUBE_IDLE_TIMEOUT"

openSandboxDomainEnv = "OPEN_SANDBOX_DOMAIN"
openSandboxAPIKeyEnv = "OPEN_SANDBOX_API_KEY"
openSandboxImageEnv = "OPEN_SANDBOX_IMAGE"
openSandboxUseProxyEnv = "OPEN_SANDBOX_USE_SERVER_PROXY"

daytonaAPIKeyEnv = "DAYTONA_API_KEY"
daytonaAPIURLEnv = "DAYTONA_API_URL"
daytonaTargetEnv = "DAYTONA_TARGET"
daytonaSnapshotEnv = "DAYTONA_SNAPSHOT"
daytonaImageEnv = "DAYTONA_IMAGE"
daytonaAutoPauseEnv = "DAYTONA_AUTO_PAUSE_MINUTES"
)

// resolveRuntime returns the self-hosted agent core and whether it is backed by
Expand Down Expand Up @@ -83,9 +112,143 @@ func sandboxProviderRegistry() (*sandbox.ProviderRegistry, error) {
})
},
},
sandbox.ProviderRegistration{
Name: sandbox.E2BProviderName,
Factory: func() (sandbox.Provider, error) {
idleTimeout, err := envDuration(e2bIdleTimeoutEnv)
if err != nil {
return nil, err
}
return sandbox.NewE2BProvider(sandbox.E2BConfig{
APIURL: os.Getenv(e2bAPIURLEnv),
APIKey: os.Getenv(e2bAPIKeyEnv),
TemplateID: os.Getenv(e2bTemplateEnv),
Domain: os.Getenv(e2bDomainEnv),
IdleTimeout: idleTimeout,
})
},
},
sandbox.ProviderRegistration{
Name: sandbox.CubeProviderName,
Factory: func() (sandbox.Provider, error) {
proxyPort, err := envPositiveInt(cubeProxyPortEnv)
if err != nil {
return nil, err
}
idleTimeout, err := envDuration(cubeIdleTimeoutEnv)
if err != nil {
return nil, err
}
return sandbox.NewCubeProvider(sandbox.CubeConfig{
APIURL: os.Getenv(cubeAPIURLEnv),
APIKey: os.Getenv(cubeAPIKeyEnv),
TemplateID: os.Getenv(cubeTemplateEnv),
Domain: os.Getenv(cubeDomainEnv),
ProxyNodeIP: os.Getenv(cubeProxyNodeIPEnv),
ProxyPort: proxyPort,
ProxyScheme: os.Getenv(cubeProxySchemeEnv),
IdleTimeout: idleTimeout,
})
},
},
sandbox.ProviderRegistration{
Name: sandbox.OpenSandboxProviderName,
Factory: func() (sandbox.Provider, error) {
useProxy, err := envBool(openSandboxUseProxyEnv)
if err != nil {
return nil, err
}
return sandbox.NewOpenSandboxProvider(sandbox.OpenSandboxConfig{
BaseURL: os.Getenv(openSandboxDomainEnv),
APIKey: os.Getenv(openSandboxAPIKeyEnv),
Image: firstNonEmpty(
os.Getenv(openSandboxImageEnv),
os.Getenv(sandboxImageEnv),
),
UseProxy: useProxy,
})
},
},
sandbox.ProviderRegistration{
Name: sandbox.DaytonaProviderName,
Factory: func() (sandbox.Provider, error) {
autoPauseMinutes, err := envPositiveInt(daytonaAutoPauseEnv)
if err != nil {
return nil, err
}
return sandbox.NewDaytonaProvider(sandbox.DaytonaConfig{
APIURL: os.Getenv(daytonaAPIURLEnv),
APIKey: os.Getenv(daytonaAPIKeyEnv),
Target: os.Getenv(daytonaTargetEnv),
Snapshot: os.Getenv(daytonaSnapshotEnv),
Image: firstNonEmpty(
os.Getenv(daytonaImageEnv),
os.Getenv(sandboxImageEnv),
),
AutoPauseMinutes: autoPauseMinutes,
})
},
},
)
}

func envDuration(name string) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return 0, nil
}
parsed, err := time.ParseDuration(value)
if err != nil || parsed <= 0 {
return 0, fmt.Errorf(
"configuration: %s must be a positive Go duration, got %q",
name,
value,
)
}
return parsed, nil
}

func envPositiveInt(name string) (int, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return 0, nil
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return 0, fmt.Errorf(
"configuration: %s must be a positive integer, got %q",
name,
value,
)
}
return parsed, nil
}

func envBool(name string) (bool, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return false, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf(
"configuration: %s must be a boolean, got %q",
name,
value,
)
}
return parsed, nil
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

// 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
Expand Down
42 changes: 41 additions & 1 deletion cmd/managed-agent/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ func TestResolveSandboxProvider_RejectsUnknownSelection(t *testing.T) {
t.Fatal("resolveSandboxProvider accepted an unknown provider")
}
if !strings.Contains(err.Error(), `unsupported provider "dockre"`) ||
!strings.Contains(err.Error(), "available: docker, local") {
!strings.Contains(
err.Error(),
"available: cube, daytona, docker, e2b, local, opensandbox",
) {
t.Fatalf("resolveSandboxProvider error = %q", err)
}
}
Expand All @@ -82,6 +85,43 @@ func TestSandboxProviderRegistry_IsLazy(t *testing.T) {
}
}

func TestResolveSandboxProvider_RejectsInvalidSelectedProviderConfig(t *testing.T) {
t.Setenv(sandboxProviderEnv, sandbox.E2BProviderName)
t.Setenv(e2bAPIKeyEnv, "test-key")
t.Setenv(e2bIdleTimeoutEnv, "eventually")

_, _, err := resolveSandboxProvider()
if err == nil || !strings.Contains(err.Error(), e2bIdleTimeoutEnv) {
t.Fatalf("resolveSandboxProvider error = %v, want invalid %s", err, e2bIdleTimeoutEnv)
}
}

func TestSandboxProviderRegistry_DoesNotValidateUnusedProviderConfig(t *testing.T) {
t.Setenv(sandboxProviderEnv, sandbox.LocalProviderName)
t.Setenv(e2bIdleTimeoutEnv, "eventually")
t.Setenv(openSandboxUseProxyEnv, "sometimes")

provider, _, err := resolveSandboxProvider()
if err != nil {
t.Fatalf("unused provider configuration affected local: %v", err)
}
if provider.Name() != sandbox.LocalProviderName {
t.Fatalf("provider = %q, want local", provider.Name())
}
}

func TestSandboxEnvironmentParsersRejectInvalidValues(t *testing.T) {
t.Setenv(cubeProxyPortEnv, "many")
if _, err := envPositiveInt(cubeProxyPortEnv); err == nil {
t.Fatalf("envPositiveInt accepted invalid %s", cubeProxyPortEnv)
}

t.Setenv(openSandboxUseProxyEnv, "sometimes")
if _, err := envBool(openSandboxUseProxyEnv); err == nil {
t.Fatalf("envBool accepted invalid %s", openSandboxUseProxyEnv)
}
}

func TestResolveRuntime_UsesFakeModelWithoutEnv(t *testing.T) {
t.Setenv("MANAGED_AGENT_MODEL_BASE_URL", "")
t.Setenv("MANAGED_AGENT_MODEL_API_KEY", "")
Expand Down
37 changes: 37 additions & 0 deletions config/dev.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Copy this file to ~/.config/mango/dev.env and chmod it 0600.
# Keep values empty until the corresponding provider is used.
# Values are loaded literally; do not add shell quotes or export statements.

# Select one backend for the API/worker process.
MANAGED_AGENT_SANDBOX=local

# E2B Cloud.
E2B_API_KEY=
E2B_API_URL=https://api.e2b.app
E2B_TEMPLATE_ID=base
E2B_DOMAIN=e2b.app
E2B_IDLE_TIMEOUT=5m

# Tencent CubeSandbox (self-hosted).
CUBE_API_URL=http://127.0.0.1:3000
CUBE_API_KEY=
CUBE_TEMPLATE_ID=
CUBE_SANDBOX_DOMAIN=cube.app
CUBE_PROXY_NODE_IP=
CUBE_PROXY_PORT_HTTP=80
CUBE_PROXY_SCHEME=http
CUBE_IDLE_TIMEOUT=5m

# OpenSandbox (self-hosted). A full URL is accepted as the domain.
OPEN_SANDBOX_DOMAIN=http://127.0.0.1:8080
OPEN_SANDBOX_API_KEY=
OPEN_SANDBOX_IMAGE=python:3.12-slim
OPEN_SANDBOX_USE_SERVER_PROXY=false

# Daytona Cloud or self-hosted.
DAYTONA_API_KEY=
DAYTONA_API_URL=https://app.daytona.io/api
DAYTONA_TARGET=us
DAYTONA_SNAPSHOT=
DAYTONA_IMAGE=python:3.12-slim
DAYTONA_AUTO_PAUSE_MINUTES=15
16 changes: 11 additions & 5 deletions docs/architecture/runtime-and-sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,17 @@ Containers share the host kernel. This provider has not been audited for
hostile multi-tenant use; stronger isolation such as gVisor or a remote sandbox
can be added behind the same provider interface.

Anthropic Sandbox Runtime (SRT) is a technically compatible candidate for a
future local process provider, but it is not implemented. SRT is an
experimental command wrapper rather than a persistent container service, so it
would preserve a session workspace without providing a durable container root
filesystem or remote sandbox identity.
### Remote providers

E2B, CubeSandbox, OpenSandbox, and Daytona are selected through the same
deployment-level registry. The worker remains the lifecycle owner: it maps one
session to one external sandbox, persists only the provider name and opaque ID,
reattaches after restart, and deletes the resource through a durable Temporal
Activity. The external service owns isolation and workspace storage.

Provider credentials, endpoint routing, templates, images, and auto-pause
settings stay in worker environment variables. They do not change the Managed
Agents Environment or Session wire models.

## Session-scoped ownership

Expand Down
Loading