From 1ac09fb832b40441b4c5cfae945633dd83ec1457 Mon Sep 17 00:00:00 2001 From: Yanpeng Wang Date: Thu, 30 Jul 2026 17:58:17 +0800 Subject: [PATCH] feat: add remote sandbox service adapters --- .gitignore | 4 + Makefile | 12 +- README.md | 20 +- cmd/managed-agent/main.go | 163 +++++ cmd/managed-agent/main_test.go | 42 +- config/dev.env.example | 37 + docs/architecture/runtime-and-sandbox.md | 16 +- docs/getting-started.md | 27 +- docs/roadmap.md | 2 +- docs/sandboxes.md | 64 +- go.mod | 39 +- go.sum | 66 +- internal/sandbox/daytona.go | 466 +++++++++++++ internal/sandbox/e2b.go | 735 ++++++++++++++++++++ internal/sandbox/e2b_test.go | 260 +++++++ internal/sandbox/live_conformance_test.go | 116 +++ internal/sandbox/opensandbox.go | 494 +++++++++++++ internal/sandbox/remote.go | 162 +++++ internal/sandbox/remote_conformance_test.go | 644 +++++++++++++++++ internal/sandbox/sandbox.go | 19 +- internal/sandbox/session.go | 7 +- scripts/with-dev-env | 51 ++ 22 files changed, 3397 insertions(+), 49 deletions(-) create mode 100644 config/dev.env.example create mode 100644 internal/sandbox/daytona.go create mode 100644 internal/sandbox/e2b.go create mode 100644 internal/sandbox/e2b_test.go create mode 100644 internal/sandbox/live_conformance_test.go create mode 100644 internal/sandbox/opensandbox.go create mode 100644 internal/sandbox/remote.go create mode 100644 internal/sandbox/remote_conformance_test.go create mode 100755 scripts/with-dev-env diff --git a/.gitignore b/.gitignore index d40f5e7..95d6b59 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ *.db-shm *.db-wal /data/ +/.env +/.env.* +!/.env.example +!/.env.*.example # Go /vendor/ diff --git a/Makefile b/Makefile index bd3b97f..c964d82 100644 --- a/Makefile +++ b/Makefile @@ -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: @@ -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)" @@ -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) . diff --git a/README.md b/README.md index afd6a09..4c7e045 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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`. diff --git a/cmd/managed-agent/main.go b/cmd/managed-agent/main.go index 628eec2..ea25d63 100644 --- a/cmd/managed-agent/main.go +++ b/cmd/managed-agent/main.go @@ -4,10 +4,12 @@ import ( "context" "errors" "flag" + "fmt" "log" "net/http" "os" "os/signal" + "strconv" "strings" "syscall" "time" @@ -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 @@ -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 diff --git a/cmd/managed-agent/main_test.go b/cmd/managed-agent/main_test.go index 26bc9f2..98776fd 100644 --- a/cmd/managed-agent/main_test.go +++ b/cmd/managed-agent/main_test.go @@ -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) } } @@ -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", "") diff --git a/config/dev.env.example b/config/dev.env.example new file mode 100644 index 0000000..aaedb38 --- /dev/null +++ b/config/dev.env.example @@ -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 diff --git a/docs/architecture/runtime-and-sandbox.md b/docs/architecture/runtime-and-sandbox.md index da9d2e2..0dc0ca0 100644 --- a/docs/architecture/runtime-and-sandbox.md +++ b/docs/architecture/runtime-and-sandbox.md @@ -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 diff --git a/docs/getting-started.md b/docs/getting-started.md index 966481a..53957af 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -146,9 +146,11 @@ 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 provider name is validated strictly. The compiled choices are `local`, +`docker`, `e2b`, `cube`, `opensandbox`, and `daytona`; an unknown value fails +worker startup and never silently falls back to local host execution. Remote +provider variables and live-test commands are listed in +[Sandbox backends](sandboxes.md). The configured endpoint must expose an Anthropic-shaped `/v1/messages` API. Do not run workers with different model or sandbox configuration on the same @@ -160,6 +162,25 @@ override the startup guard. This is a dev-only escape hatch — the local sandbo runs tool commands on the host with no isolation, so never use it with untrusted input or in production. +## Reusable local credentials + +Development secrets should not be committed or copied between worktrees. +Create a user-local file once, then edit the values you actually use: + +```bash +make dev-env-init +$EDITOR ~/.config/mango/dev.env +``` + +Run a command with that environment explicitly: + +```bash +scripts/with-dev-env go run ./cmd/managed-agent orchestrate +``` + +The wrapper requires the file to have no group or other permissions. Set +`MANGO_ENV_FILE` only when a different repository-external path is needed. + ## Clean up ```bash diff --git a/docs/roadmap.md b/docs/roadmap.md index 8059cda..3009c68 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -29,7 +29,7 @@ After the core loop is complete: - supported server tools such as web search and fetch; - token usage and model/tool execution spans; - additional preview event types; -- provider-backed sandbox adapters. +- promotion of preview sandbox adapters after recorded live conformance. ## Production hardening diff --git a/docs/sandboxes.md b/docs/sandboxes.md index 4a558f3..1492059 100644 --- a/docs/sandboxes.md +++ b/docs/sandboxes.md @@ -10,8 +10,9 @@ production-ready merely because it can execute a command: its isolation model, lifecycle guarantees, operational dependencies, and known limits must also be clear. -Local and Docker execution do not add another HTTP service. The worker uses the -same in-process Go boundary that remote service adapters will implement: +Local and Docker execution do not add another HTTP service. Remote adapters +call an independently deployed sandbox service through the same in-process Go +boundary: ```text Temporal Activity -> SessionManager -> sandbox.Provider -> sandbox.Sandbox @@ -22,13 +23,16 @@ 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. 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. +`local` (the default), `docker`, `e2b`, `cube`, `opensandbox`, or `daytona`; 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 - **Available**: implemented, documented, and exercised by repository tests. +- **Preview**: implemented with offline and opt-in live conformance, but still + awaiting promotion based on repeatable service-level validation. - **Planned**: selected for a dedicated adapter, but not implemented. - **Evaluating**: useful integration shape, without a committed adapter. @@ -40,10 +44,10 @@ These labels describe project support, not a security certification. |---|---|---|---|---| | Local process | Available; default | Host process plus confined workspace; not an isolation boundary | Reattaches by durable workspace path on the same host | Offline tests and trusted local development only | | Docker | Available; opt-in | Container filesystem, namespaces/cgroups, configurable limits, network disabled by default | Reattaches by container ID on the same Docker daemon | Controlled single-host self-hosting | -| [E2B](https://github.com/e2b-dev/E2B) | Planned | Remote sandbox service | Provider-owned durable sandbox ID | Managed production | -| [Tencent CubeSandbox](https://github.com/TencentCloud/CubeSandbox) | Planned | E2B-compatible microVM service | Provider-owned durable sandbox ID | Self-hosted production | -| [OpenSandbox](https://github.com/opensandbox-group/OpenSandbox) | Planned | Docker or Kubernetes-backed sandbox service | Provider-owned durable sandbox ID | Self-hosted production | -| [Daytona](https://www.daytona.io/docs/en/sandboxes/) | Planned | Managed sandbox service | Durable identity and lifecycle API | Managed production | +| [E2B](https://github.com/e2b-dev/E2B) | Preview | Managed microVM service | E2B ID plus auto-pause filesystem persistence | Managed production | +| [Tencent CubeSandbox](https://github.com/TencentCloud/CubeSandbox) | Preview | E2B-compatible microVM service | Provider-owned durable sandbox ID | Self-hosted production on Linux/KVM | +| [OpenSandbox](https://github.com/opensandbox-group/OpenSandbox) | Preview; Docker runtime manually live-verified | Docker or Kubernetes-backed sandbox service | Provider-owned durable sandbox ID | Self-hosted production | +| [Daytona](https://www.daytona.io/docs/en/sandboxes/) | Preview | Managed or self-hosted sandbox service | Deterministic name, durable ID, and auto-pause | Managed production | | [Modal](https://modal.com/docs/guide/sandboxes) | Planned | Managed sandbox service | Provider-owned durable sandbox ID | Managed production | | [Runloop](https://docs.runloop.ai/docs/devboxes/overview) | Planned | Managed devbox service | Suspend, resume, and snapshot lifecycle | Managed production | | [Kubernetes SIG Agent Sandbox](https://github.com/kubernetes-sigs/agent-sandbox) | Planned | Kubernetes CRD, controller, and routing layer | Stateful sandbox resource | Kubernetes deployments | @@ -64,11 +68,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, +These requirements are executable in `internal/sandbox/sandboxtest`. Local, +Docker, and every remote provider's opt-in live test 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. +rejection, cancellation, and post-delete missing-reference behavior. Offline +adapter tests cover the same contract without credentials. Provider-specific +tests cover protocol translation, 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 @@ -78,6 +83,9 @@ not compatible with all executing built-ins yet. - The first tool-using run idempotently creates the provider resource and persists `{provider, external_id, spec_hash}` in PostgreSQL. +- Remote services receive a fixed-length hash of the session key as their + ownership label; credentials and raw session identifiers are not persisted in + the provider reference. - Later turns reuse the cached client; a restarted worker attaches through the persisted reference. - Different sessions never share a logical sandbox. @@ -96,6 +104,36 @@ 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. +## Remote provider configuration + +Credentials stay in worker configuration. They are never written to PostgreSQL +or returned by the Managed Agents API. + +| Provider | Required | Common optional values | +|---|---|---| +| `e2b` | `E2B_API_KEY` | `E2B_API_URL`, `E2B_TEMPLATE_ID`, `E2B_DOMAIN`, `E2B_IDLE_TIMEOUT` | +| `cube` | `CUBE_API_URL`, `CUBE_TEMPLATE_ID` | `CUBE_API_KEY`, `CUBE_SANDBOX_DOMAIN`, `CUBE_PROXY_*`, `CUBE_IDLE_TIMEOUT` | +| `opensandbox` | `OPEN_SANDBOX_DOMAIN` | `OPEN_SANDBOX_API_KEY`, `OPEN_SANDBOX_IMAGE`, `OPEN_SANDBOX_USE_SERVER_PROXY` | +| `daytona` | `DAYTONA_API_KEY` | `DAYTONA_API_URL`, `DAYTONA_TARGET`, `DAYTONA_SNAPSHOT`, `DAYTONA_IMAGE`, `DAYTONA_AUTO_PAUSE_MINUTES` | + +For local development, `make dev-env-init` creates +`~/.config/mango/dev.env` from `config/dev.env.example` with mode `0600`. +`scripts/with-dev-env ` loads it explicitly and works across +worktrees. + +Live conformance is opt-in: + +```bash +scripts/with-dev-env env MANGO_LIVE_E2B=1 \ + go test ./internal/sandbox -run '^TestE2BLiveConformance$' -count=1 + +scripts/with-dev-env env MANGO_LIVE_OPENSANDBOX=1 \ + go test ./internal/sandbox -run '^TestOpenSandboxLiveConformance$' -count=1 +``` + +The equivalent gates are `MANGO_LIVE_CUBE` and `MANGO_LIVE_DAYTONA`. +Ordinary tests never contact a service or create billable resources. + The upstream behavior informing the session/environment distinction is documented in Claude's [cloud environment setup](https://platform.claude.com/docs/en/managed-agents/environments) diff --git a/go.mod b/go.mod index 3ea2cc6..ada1a48 100644 --- a/go.mod +++ b/go.mod @@ -3,27 +3,50 @@ module github.com/yanpgwang/managed-agent-go go 1.26.4 require ( + github.com/alibaba/OpenSandbox/sdks/sandbox/go v1.0.5 github.com/anthropics/anthropic-sdk-go v1.61.0 + github.com/daytona/clients/sdk-go v0.202.0 github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats.go v1.52.0 github.com/pressly/goose/v3 v3.27.3 github.com/stretchr/testify v1.11.1 + github.com/tencentcloud/CubeSandbox/sdk/go v0.0.0-20260730082406-8ad40de9ed5f go.temporal.io/api v1.63.0 go.temporal.io/sdk v1.46.0 modernc.org/sqlite v1.54.0 ) require ( + github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect + github.com/aws/smithy-go v1.25.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/daytona/clients/analytics-api-client-go v0.0.0-20260722121532-3d2223c79fe5 // indirect + github.com/daytona/clients/api-client-go v0.202.0 // indirect + github.com/daytona/clients/toolbox-api-client-go v0.202.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/mock v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -37,7 +60,7 @@ require ( github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect github.com/nexus-rpc/sdk-go v0.6.0 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron v1.2.0 // indirect github.com/sethvargo/go-retry v0.4.0 // indirect @@ -47,6 +70,16 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.54.0 // indirect diff --git a/go.sum b/go.sum index ac97767..194e219 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,57 @@ +github.com/alibaba/OpenSandbox/sdks/sandbox/go v1.0.5 h1:7mZNkBh4VaI+i4VOTJIknLCBkbGizTkdHYqgcMqeDBM= +github.com/alibaba/OpenSandbox/sdks/sandbox/go v1.0.5/go.mod h1:w0nIMCTL1L3oSS67ABFOdYxrezYQZhk01UtmKSi2UhA= github.com/anthropics/anthropic-sdk-go v1.61.0 h1:JRTnm1tPqn5xo1xd1zfrcFDlcoWXVMvV1K68YmhpZKw= github.com/anthropics/anthropic-sdk-go v1.61.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI= +github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= +github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= +github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/daytona/clients/analytics-api-client-go v0.0.0-20260722121532-3d2223c79fe5 h1:vlfG6CqZ0wLdks8dzvNtuo8mBADO/RL0q5J2tseEI4s= +github.com/daytona/clients/analytics-api-client-go v0.0.0-20260722121532-3d2223c79fe5/go.mod h1:+kRgiUXoVO3BX/VzeNadSCTXPXElAkPk1pNk1haKIDw= +github.com/daytona/clients/api-client-go v0.202.0 h1:k0y5gEp72UY59RQC1dvjukvkwuDnAoKDEftbvhEM+e0= +github.com/daytona/clients/api-client-go v0.202.0/go.mod h1:US+IQTDTZam9c4UpPMB3WDYYMIyWSKlgfDZzwWffY94= +github.com/daytona/clients/sdk-go v0.202.0 h1:GKW3J/si/cPAHv1NkGl1gXG1njL9fpgQs0WtaXQBiSw= +github.com/daytona/clients/sdk-go v0.202.0/go.mod h1:TnLnK1v9fcBKO66brdzSYWD0RVEhlb9DdjavgPkm8CA= +github.com/daytona/clients/toolbox-api-client-go v0.202.0 h1:DcWyDkiaeBwgqy+TGQujXtP1zZRkDsmucc5iRtf1SqY= +github.com/daytona/clients/toolbox-api-client-go v0.202.0/go.mod h1:lwZA3j823fNNiAgTSMThjZkYWegIigoFIREAtdo7o3E= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -31,10 +68,12 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= @@ -73,16 +112,17 @@ github.com/nexus-rpc/sdk-go v0.6.0 h1:QRgnP2zTbxEbiyWG/aXH8uSC5LV/Mg1fqb19jb4DBl github.com/nexus-rpc/sdk-go v0.6.0/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= @@ -94,6 +134,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tencentcloud/CubeSandbox/sdk/go v0.0.0-20260730082406-8ad40de9ed5f h1:4pEqkXNQvAPMBo+8LQKFkpArkgheTI6NdxV4VJgRhbM= +github.com/tencentcloud/CubeSandbox/sdk/go v0.0.0-20260730082406-8ad40de9ed5f/go.mod h1:4TpzAuDL+yundwIr7UK0N1hVV6L9R/sqcinE+z4W6G0= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -111,6 +153,12 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -119,10 +167,14 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.temporal.io/api v1.63.0 h1:YZFOTA0/thRUIUC4qunAWdHhPh/IG4vy/+WjfEvT+ZE= go.temporal.io/api v1.63.0/go.mod h1:0k75tRljEuELWGeXjEZZO7zYqBln4+1FrG6+IMOMy7Q= go.temporal.io/sdk v1.46.0 h1:zD2l907+4iVkLsnJZwFj/oIIjYsoqyjsHlKO/3tDKoU= go.temporal.io/sdk v1.46.0/go.mod h1:x3v/9ImVh469kiHspoq1xgLdPnetbfuCAm+Y1+sUtIo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= diff --git a/internal/sandbox/daytona.go b/internal/sandbox/daytona.go new file mode 100644 index 0000000..827f8a2 --- /dev/null +++ b/internal/sandbox/daytona.go @@ -0,0 +1,466 @@ +package sandbox + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "path" + "strconv" + "strings" + "time" + + "github.com/daytona/clients/sdk-go/pkg/daytona" + daytonaerrors "github.com/daytona/clients/sdk-go/pkg/errors" + daytonaoptions "github.com/daytona/clients/sdk-go/pkg/options" + daytonatypes "github.com/daytona/clients/sdk-go/pkg/types" +) + +const ( + DaytonaProviderName = "daytona" + defaultDaytonaImage = "python:3.12-slim" + defaultDaytonaRoot = "/home/daytona" +) + +type DaytonaConfig struct { + APIURL string + APIKey string + Target string + Snapshot string + Image string + AutoPauseMinutes int + HTTPClient *http.Client +} + +type daytonaResource struct { + id string + labels map[string]string + remote daytonaRemote +} + +type daytonaRemote interface { + ID() string + Exec(context.Context, string, string, time.Duration) (string, string, int, error) + ReadFile(context.Context, string) ([]byte, error) + WriteFile(context.Context, string, []byte) error + Start(context.Context) error + Destroy(context.Context) error +} + +type daytonaService interface { + Get(context.Context, string) (daytonaResource, error) + Create(context.Context, string, string, Spec) (daytonaResource, error) +} + +type daytonaProvider struct { + service daytonaService + root string +} + +func NewDaytonaProvider(cfg DaytonaConfig) (Provider, error) { + apiKey := strings.TrimSpace(cfg.APIKey) + if apiKey == "" { + return nil, errors.New( + "sandbox: DAYTONA_API_KEY is required for the daytona provider", + ) + } + autoPause := cfg.AutoPauseMinutes + if autoPause <= 0 { + autoPause = 15 + } + image := strings.TrimSpace(cfg.Image) + if image == "" { + image = defaultDaytonaImage + } + client, err := daytona.NewClientWithConfig(&daytonatypes.DaytonaConfig{ + APIKey: apiKey, + APIUrl: strings.TrimRight(strings.TrimSpace(cfg.APIURL), "/"), + Target: strings.TrimSpace(cfg.Target), + OtelEnabled: false, + HTTPClient: cfg.HTTPClient, + }) + if err != nil { + return nil, fmt.Errorf("sandbox: initialize Daytona client: %w", err) + } + return &daytonaProvider{ + service: &daytonaSDKService{ + client: client, + snapshot: strings.TrimSpace(cfg.Snapshot), + image: image, + autoPauseMinutes: autoPause, + }, + root: defaultDaytonaRoot, + }, nil +} + +func newDaytonaProvider(service daytonaService, root string) Provider { + if root == "" { + root = defaultDaytonaRoot + } + return &daytonaProvider{service: service, root: root} +} + +func (p *daytonaProvider) Name() string { return DaytonaProviderName } + +func (p *daytonaProvider) Create( + ctx context.Context, + sessionKey string, + spec Spec, +) (Ref, Sandbox, error) { + if sessionKey == "" { + return Ref{}, nil, errors.New("sandbox: session key is required") + } + if err := ctx.Err(); err != nil { + return Ref{}, nil, err + } + name := deterministicRemoteName(p.Name(), sessionKey) + resource, err := p.service.Get(ctx, name) + if err == nil { + return p.attachResource(ctx, sessionKey, resource, spec) + } + if !isDaytonaNotFound(err) { + return Ref{}, nil, fmt.Errorf("sandbox: daytona get by name: %w", err) + } + resource, err = p.service.Create(ctx, name, sessionKey, spec) + if err != nil { + // Daytona names are unique. A conflict or lost response means the + // deterministic name is the recovery key. + resource, getErr := p.service.Get(ctx, name) + if getErr == nil { + return p.attachResource(ctx, sessionKey, resource, spec) + } + return Ref{}, nil, fmt.Errorf("sandbox: daytona create: %w", err) + } + return p.attachResource(ctx, sessionKey, resource, spec) +} + +func (p *daytonaProvider) Attach( + ctx context.Context, + sessionKey string, + ref Ref, + spec Spec, +) (Sandbox, error) { + if err := validateRemoteReference(p.Name(), sessionKey, ref); err != nil { + return nil, err + } + resource, err := p.service.Get(ctx, ref.ID) + if err != nil { + if isDaytonaNotFound(err) { + return nil, fmt.Errorf("%w: daytona sandbox %q", ErrNotFound, ref.ID) + } + return nil, fmt.Errorf("sandbox: daytona get: %w", err) + } + if err := validateRemoteOwnership( + p.Name(), + resource.id, + sessionKey, + resource.labels, + ); err != nil { + return nil, err + } + if err := resource.remote.Start(ctx); err != nil { + if isDaytonaNotFound(err) { + return nil, fmt.Errorf("%w: daytona sandbox %q", ErrNotFound, ref.ID) + } + return nil, fmt.Errorf("sandbox: daytona start: %w", err) + } + box := &daytonaBox{ + remote: resource.remote, + root: p.root, + timeout: spec.Timeout, + } + if err := box.ensureRoot(ctx); err != nil { + return nil, err + } + return box, nil +} + +func (p *daytonaProvider) attachResource( + ctx context.Context, + sessionKey string, + resource daytonaResource, + spec Spec, +) (Ref, Sandbox, error) { + ref := Ref{Provider: p.Name(), ID: resource.id} + box, err := p.Attach(ctx, sessionKey, ref, spec) + return ref, box, err +} + +type daytonaBox struct { + remote daytonaRemote + root string + timeout time.Duration +} + +func (s *daytonaBox) Root() string { return s.root } + +func (s *daytonaBox) ensureRoot(ctx context.Context) error { + _, stderr, code, err := s.remote.Exec( + ctx, + "mkdir -p "+shellQuote(s.root), + "/", + s.timeout, + ) + if err != nil { + return fmt.Errorf("sandbox: daytona create workspace: %w", err) + } + if code != 0 { + return fmt.Errorf( + "sandbox: daytona create workspace failed (exit %d): %s", + code, + strings.TrimSpace(stderr), + ) + } + return nil +} + +func (s *daytonaBox) Exec( + ctx context.Context, + cmd Command, +) (*Result, error) { + if cmd.Path == "" { + return nil, errors.New("sandbox: command path is required") + } + runCtx, cancel := commandTimeout(ctx, s.timeout) + defer cancel() + stdout, stderr, code, err := s.remote.Exec( + runCtx, + remoteCommandLine(cmd), + s.root, + s.timeout, + ) + if err != nil { + if runCtx.Err() != nil { + return remoteResult(runCtx, s.timeout, stdout, stderr, code) + } + return nil, fmt.Errorf("sandbox: daytona exec: %w", err) + } + return remoteResult(runCtx, s.timeout, stdout, stderr, code) +} + +func (s *daytonaBox) ReadFile( + ctx context.Context, + value string, +) ([]byte, error) { + full, err := containedRemotePath(s.root, value) + if err != nil { + return nil, err + } + return s.remote.ReadFile(ctx, full) +} + +func (s *daytonaBox) WriteFile( + ctx context.Context, + value string, + data []byte, +) error { + full, err := containedRemotePath(s.root, value) + if err != nil { + return err + } + _, stderr, code, err := s.remote.Exec( + ctx, + "mkdir -p "+shellQuote(path.Dir(full)), + s.root, + s.timeout, + ) + if err != nil { + return fmt.Errorf("sandbox: daytona create parent: %w", err) + } + if code != 0 { + return fmt.Errorf( + "sandbox: daytona create parent failed (exit %d): %s", + code, + strings.TrimSpace(stderr), + ) + } + return s.remote.WriteFile(ctx, full, data) +} + +func (s *daytonaBox) Destroy(ctx context.Context) error { + err := s.remote.Destroy(ctx) + if isDaytonaNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("sandbox: daytona destroy: %w", err) + } + return nil +} + +type daytonaSDKService struct { + client *daytona.Client + snapshot string + image string + autoPauseMinutes int +} + +func (s *daytonaSDKService) Get( + ctx context.Context, + idOrName string, +) (daytonaResource, error) { + box, err := s.client.Get(ctx, idOrName) + if err != nil { + return daytonaResource{}, err + } + return daytonaResource{ + id: box.ID, + labels: box.Labels, + remote: &daytonaSDKRemote{sandbox: box}, + }, nil +} + +func (s *daytonaSDKService) Create( + ctx context.Context, + name string, + sessionKey string, + spec Spec, +) (daytonaResource, error) { + autoPause := s.autoPauseMinutes + neverDelete := -1 + noTTL := 0 + base := daytonatypes.SandboxBaseParams{ + Name: name, + Labels: remoteMetadata(sessionKey), + AutoPauseInterval: &autoPause, + AutoDeleteInterval: &neverDelete, + TtlMinutes: &noTTL, + NetworkBlockAll: spec.Network == "" || spec.Network == "none", + } + var params any + if s.snapshot != "" && spec.Image == "" { + params = daytonatypes.SnapshotParams{ + SandboxBaseParams: base, + Snapshot: s.snapshot, + } + } else { + image := s.image + if spec.Image != "" { + image = spec.Image + } + params = daytonatypes.ImageParams{ + SandboxBaseParams: base, + Image: image, + Resources: daytonaResources(spec), + } + } + box, err := s.client.Create(ctx, params) + if err != nil { + return daytonaResource{}, err + } + return daytonaResource{ + id: box.ID, + labels: box.Labels, + remote: &daytonaSDKRemote{sandbox: box}, + }, nil +} + +type daytonaSDKRemote struct { + sandbox *daytona.Sandbox +} + +func (s *daytonaSDKRemote) ID() string { return s.sandbox.ID } + +func (s *daytonaSDKRemote) Exec( + ctx context.Context, + command string, + cwd string, + timeout time.Duration, +) (string, string, int, error) { + options := []func(*daytonaoptions.ExecuteCommand){ + daytonaoptions.WithCwd(cwd), + } + if timeout > 0 { + options = append(options, daytonaoptions.WithExecuteTimeout(timeout)) + } + result, err := s.sandbox.Process.ExecuteCommand(ctx, command, options...) + if err != nil { + return "", "", -1, err + } + return result.Result, "", result.ExitCode, nil +} + +func (s *daytonaSDKRemote) ReadFile( + ctx context.Context, + value string, +) (data []byte, err error) { + reader, err := s.sandbox.FileSystem.DownloadFileStream(ctx, value) + if err != nil { + return nil, err + } + defer func() { + if closeErr := reader.Close(); err == nil { + err = closeErr + } + }() + return io.ReadAll(reader) +} + +func (s *daytonaSDKRemote) WriteFile( + ctx context.Context, + value string, + data []byte, +) error { + return s.sandbox.FileSystem.UploadFileStream( + ctx, + bytes.NewReader(data), + value, + ) +} + +func (s *daytonaSDKRemote) Start(ctx context.Context) error { + return s.sandbox.Start(ctx) +} + +func (s *daytonaSDKRemote) Destroy(ctx context.Context) error { + return s.sandbox.DeleteAndWait(ctx, time.Minute) +} + +func daytonaResources(spec Spec) *daytonatypes.Resources { + resources := &daytonatypes.Resources{} + hasResource := false + if spec.CPUs != "" { + if cpu := parseWholeCPU(spec.CPUs); cpu > 0 { + resources.CPU = cpu + hasResource = true + } + } + if memory := parseMemoryMB(spec.Memory); memory > 0 { + resources.Memory = memory + hasResource = true + } + if !hasResource { + return nil + } + return resources +} + +func parseWholeCPU(value string) int { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || parsed <= 0 || parsed != math.Trunc(parsed) { + return 0 + } + return int(parsed) +} + +func parseMemoryMB(value string) int { + normalized := strings.ToLower(strings.TrimSpace(value)) + for _, suffix := range []string{"mib", "mb", "mi", "m"} { + if strings.HasSuffix(normalized, suffix) { + normalized = strings.TrimSpace(strings.TrimSuffix(normalized, suffix)) + break + } + } + parsed, err := strconv.Atoi(normalized) + if err != nil || parsed <= 0 { + return 0 + } + return parsed +} + +func isDaytonaNotFound(err error) bool { + return errors.Is(err, daytonaerrors.ErrNotFound) +} diff --git a/internal/sandbox/e2b.go b/internal/sandbox/e2b.go new file mode 100644 index 0000000..25f71f6 --- /dev/null +++ b/internal/sandbox/e2b.go @@ -0,0 +1,735 @@ +package sandbox + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "sort" + "strings" + "time" + + cubesandbox "github.com/tencentcloud/CubeSandbox/sdk/go" +) + +const ( + E2BProviderName = "e2b" + CubeProviderName = "cube" + + defaultE2BAPIURL = "https://api.e2b.app" + defaultE2BDomain = "e2b.app" + defaultE2BTemplate = "base" +) + +// E2BConfig configures E2B's managed service. APIKey is worker-local and is +// never included in the durable Ref. +type E2BConfig struct { + APIURL string + APIKey string + TemplateID string + Domain string + IdleTimeout time.Duration + HTTPClient *http.Client +} + +// CubeConfig configures a self-hosted CubeSandbox deployment. +type CubeConfig struct { + APIURL string + APIKey string + TemplateID string + Domain string + ProxyNodeIP string + ProxyPort int + ProxyScheme string + IdleTimeout time.Duration + HTTPClient *http.Client +} + +type e2bResource struct { + id string + metadata map[string]string +} + +type e2bServiceSandbox interface { + ID() string + Exec(context.Context, string, string, time.Duration) (string, string, int, error) + ReadFile(context.Context, string) ([]byte, error) + WriteFile(context.Context, string, []byte) error + Destroy(context.Context) error +} + +type e2bService interface { + List(context.Context, map[string]string) ([]e2bResource, error) + Get(context.Context, string) (e2bResource, error) + Create(context.Context, string, Spec) (e2bServiceSandbox, error) + Connect(context.Context, string) (e2bServiceSandbox, error) +} + +type e2bLikeProvider struct { + name string + service e2bService + root string +} + +func NewE2BProvider(cfg E2BConfig) (Provider, error) { + apiURL := strings.TrimRight(strings.TrimSpace(cfg.APIURL), "/") + if apiURL == "" { + apiURL = defaultE2BAPIURL + } + apiKey := strings.TrimSpace(cfg.APIKey) + if apiKey == "" { + return nil, errors.New("sandbox: E2B_API_KEY is required for the e2b provider") + } + templateID := strings.TrimSpace(cfg.TemplateID) + if templateID == "" { + templateID = defaultE2BTemplate + } + domain := strings.TrimSpace(cfg.Domain) + if domain == "" { + domain = defaultE2BDomain + } + idleTimeout := cfg.IdleTimeout + if idleTimeout <= 0 { + idleTimeout = 5 * time.Minute + } + transport, err := newE2BTransport(cfg.HTTPClient, apiURL, apiKey, idleTimeout) + if err != nil { + return nil, err + } + client := cubesandbox.NewClient(cubesandbox.Config{ + APIURL: apiURL, + TemplateID: templateID, + SandboxDomain: domain, + ProxyScheme: "https", + Timeout: idleTimeout, + RequestTimeout: remoteDefaultPeriod, + }, cubesandbox.WithHTTPClient(transport)) + return newE2BLikeProvider( + E2BProviderName, + &cubeSDKService{ + name: E2BProviderName, + client: client, + control: &e2bControlClient{ + apiURL: apiURL, + client: remoteControlHTTPClient(transport), + }, + idleTimeout: idleTimeout, + }, + remoteDefaultRoot, + ), nil +} + +func NewCubeProvider(cfg CubeConfig) (Provider, error) { + apiURL := strings.TrimRight(strings.TrimSpace(cfg.APIURL), "/") + if apiURL == "" { + return nil, errors.New("sandbox: CUBE_API_URL is required for the cube provider") + } + templateID := strings.TrimSpace(cfg.TemplateID) + if templateID == "" { + return nil, errors.New("sandbox: CUBE_TEMPLATE_ID is required for the cube provider") + } + idleTimeout := cfg.IdleTimeout + if idleTimeout <= 0 { + idleTimeout = 5 * time.Minute + } + scheme := strings.TrimSpace(cfg.ProxyScheme) + if scheme == "" { + scheme = "http" + } + clientCfg := cubesandbox.Config{ + APIURL: apiURL, + APIKey: strings.TrimSpace(cfg.APIKey), + TemplateID: templateID, + ProxyNodeIP: strings.TrimSpace(cfg.ProxyNodeIP), + ProxyPortHTTP: cfg.ProxyPort, + ProxyScheme: scheme, + SandboxDomain: strings.TrimSpace(cfg.Domain), + Timeout: idleTimeout, + RequestTimeout: remoteDefaultPeriod, + } + options := []cubesandbox.ClientOption{} + controlHTTP := remoteControlHTTPClient(cfg.HTTPClient) + if cfg.HTTPClient != nil { + options = append(options, cubesandbox.WithHTTPClient(cfg.HTTPClient)) + } + client := cubesandbox.NewClient(clientCfg, options...) + return newE2BLikeProvider( + CubeProviderName, + &cubeSDKService{ + name: CubeProviderName, + client: client, + control: &e2bControlClient{ + apiURL: apiURL, + apiKey: strings.TrimSpace(cfg.APIKey), + bearerAuth: true, + client: controlHTTP, + }, + idleTimeout: idleTimeout, + }, + remoteDefaultRoot, + ), nil +} + +func newE2BLikeProvider( + name string, + service e2bService, + root string, +) Provider { + if root == "" { + root = remoteDefaultRoot + } + return &e2bLikeProvider{ + name: name, + service: service, + root: root, + } +} + +func (p *e2bLikeProvider) Name() string { return p.name } + +func (p *e2bLikeProvider) Create( + ctx context.Context, + sessionKey string, + spec Spec, +) (Ref, Sandbox, error) { + if sessionKey == "" { + return Ref{}, nil, errors.New("sandbox: session key is required") + } + if err := ctx.Err(); err != nil { + return Ref{}, nil, err + } + existing, err := p.findSession(ctx, sessionKey) + if err != nil { + return Ref{}, nil, err + } + if len(existing) > 0 { + return p.attachResource(ctx, sessionKey, existing[0], spec) + } + remote, err := p.service.Create(ctx, sessionKey, spec) + if err != nil { + // A lost response or a concurrent creator may have provisioned the + // resource. Metadata lookup is the provider-side recovery path. + existing, findErr := p.findSession(ctx, sessionKey) + if findErr == nil && len(existing) > 0 { + return p.attachResource(ctx, sessionKey, existing[0], spec) + } + return Ref{}, nil, fmt.Errorf("sandbox: %s create: %w", p.name, err) + } + box := &e2bLikeSandbox{ + providerName: p.name, + remote: remote, + root: p.root, + timeout: spec.Timeout, + } + if err := box.ensureRoot(ctx); err != nil { + _ = remote.Destroy(context.Background()) + return Ref{}, nil, err + } + return Ref{Provider: p.name, ID: remote.ID()}, box, nil +} + +func (p *e2bLikeProvider) Attach( + ctx context.Context, + sessionKey string, + ref Ref, + spec Spec, +) (Sandbox, error) { + if err := validateRemoteReference(p.name, sessionKey, ref); err != nil { + return nil, err + } + resource, err := p.service.Get(ctx, ref.ID) + if err != nil { + if isCubeNotFound(err) { + return nil, fmt.Errorf( + "%w: %s sandbox %q", + ErrNotFound, + p.name, + ref.ID, + ) + } + return nil, fmt.Errorf("sandbox: %s get: %w", p.name, err) + } + if resource.id == "" { + return nil, fmt.Errorf( + "%w: %s sandbox %q", + ErrNotFound, + p.name, + ref.ID, + ) + } + if err := validateRemoteOwnership( + p.name, + resource.id, + sessionKey, + resource.metadata, + ); err != nil { + return nil, err + } + remote, err := p.service.Connect(ctx, resource.id) + if err != nil { + if isCubeNotFound(err) { + return nil, fmt.Errorf("%w: %s sandbox %q", ErrNotFound, p.name, ref.ID) + } + return nil, fmt.Errorf("sandbox: %s connect: %w", p.name, err) + } + box := &e2bLikeSandbox{ + providerName: p.name, + remote: remote, + root: p.root, + timeout: spec.Timeout, + } + if err := box.ensureRoot(ctx); err != nil { + return nil, err + } + return box, nil +} + +func (p *e2bLikeProvider) attachResource( + ctx context.Context, + sessionKey string, + resource e2bResource, + spec Spec, +) (Ref, Sandbox, error) { + box, err := p.Attach( + ctx, + sessionKey, + Ref{Provider: p.name, ID: resource.id}, + spec, + ) + if err != nil { + return Ref{}, nil, err + } + return Ref{Provider: p.name, ID: resource.id}, box, nil +} + +func (p *e2bLikeProvider) findSession( + ctx context.Context, + sessionKey string, +) ([]e2bResource, error) { + resources, err := p.service.List(ctx, remoteMetadata(sessionKey)) + if err != nil { + return nil, fmt.Errorf("sandbox: %s list: %w", p.name, err) + } + matches := make([]e2bResource, 0, 1) + for _, resource := range resources { + if resource.metadata[remoteManagedKey] == remoteManagedValue && + resource.metadata[remoteSessionKey] == + remoteSessionIdentity(sessionKey) { + matches = append(matches, resource) + } + } + sort.Slice(matches, func(i, j int) bool { return matches[i].id < matches[j].id }) + return matches, nil +} + +type e2bLikeSandbox struct { + providerName string + remote e2bServiceSandbox + root string + timeout time.Duration +} + +func (s *e2bLikeSandbox) Root() string { return s.root } + +func (s *e2bLikeSandbox) ensureRoot(ctx context.Context) error { + _, stderr, code, err := s.remote.Exec( + ctx, + "mkdir -p "+shellQuote(s.root), + "/", + s.timeout, + ) + if err != nil { + return fmt.Errorf("sandbox: %s create workspace: %w", s.providerName, err) + } + if code != 0 { + return fmt.Errorf( + "sandbox: %s create workspace failed (exit %d): %s", + s.providerName, + code, + strings.TrimSpace(stderr), + ) + } + return nil +} + +func (s *e2bLikeSandbox) Exec( + ctx context.Context, + cmd Command, +) (*Result, error) { + if cmd.Path == "" { + return nil, errors.New("sandbox: command path is required") + } + runCtx, cancel := commandTimeout(ctx, s.timeout) + defer cancel() + stdout, stderr, code, err := s.remote.Exec( + runCtx, + remoteCommandLine(cmd), + s.root, + s.timeout, + ) + if err != nil { + if runCtx.Err() != nil { + return remoteResult(runCtx, s.timeout, stdout, stderr, code) + } + return nil, fmt.Errorf("sandbox: %s exec: %w", s.providerName, err) + } + return remoteResult(runCtx, s.timeout, stdout, stderr, code) +} + +func (s *e2bLikeSandbox) ReadFile( + ctx context.Context, + value string, +) ([]byte, error) { + full, err := containedRemotePath(s.root, value) + if err != nil { + return nil, err + } + return s.remote.ReadFile(ctx, full) +} + +func (s *e2bLikeSandbox) WriteFile( + ctx context.Context, + value string, + data []byte, +) error { + full, err := containedRemotePath(s.root, value) + if err != nil { + return err + } + _, stderr, code, err := s.remote.Exec( + ctx, + "mkdir -p "+shellQuote(path.Dir(full)), + s.root, + s.timeout, + ) + if err != nil { + return fmt.Errorf("sandbox: %s create parent: %w", s.providerName, err) + } + if code != 0 { + return fmt.Errorf( + "sandbox: %s create parent failed (exit %d): %s", + s.providerName, + code, + strings.TrimSpace(stderr), + ) + } + return s.remote.WriteFile(ctx, full, data) +} + +func (s *e2bLikeSandbox) Destroy(ctx context.Context) error { + err := s.remote.Destroy(ctx) + if isCubeNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("sandbox: %s destroy: %w", s.providerName, err) + } + return nil +} + +type cubeSDKService struct { + name string + client *cubesandbox.Client + control *e2bControlClient + idleTimeout time.Duration +} + +func (s *cubeSDKService) List( + ctx context.Context, + metadata map[string]string, +) ([]e2bResource, error) { + items, err := s.control.List(ctx, metadata) + if err != nil { + return nil, err + } + resources := make([]e2bResource, 0, len(items)) + for _, item := range items { + resources = append(resources, e2bResource{ + id: item.SandboxID, + metadata: item.Metadata, + }) + } + return resources, nil +} + +func (s *cubeSDKService) Get( + ctx context.Context, + id string, +) (e2bResource, error) { + item, err := s.control.Get(ctx, id) + if err != nil { + return e2bResource{}, err + } + return e2bResource{id: item.SandboxID, metadata: item.Metadata}, nil +} + +func (s *cubeSDKService) Create( + ctx context.Context, + sessionKey string, + spec Spec, +) (e2bServiceSandbox, error) { + timeout := s.idleTimeout + allowInternet := spec.Network != "" && spec.Network != "none" + opts := cubesandbox.CreateOptions{ + Timeout: &timeout, + Metadata: remoteMetadata(sessionKey), + Extra: map[string]any{ + "autoPause": true, + "autoPauseMemory": false, + "autoResume": map[string]bool{"enabled": false}, + "secure": true, + }, + } + if s.name == E2BProviderName { + opts.Extra["allow_internet_access"] = allowInternet + } else { + opts.AllowInternetAccess = &allowInternet + } + created, err := s.client.Create(ctx, opts) + if err != nil { + return nil, err + } + return &cubeSDKSandbox{sandbox: created}, nil +} + +func (s *cubeSDKService) Connect( + ctx context.Context, + id string, +) (e2bServiceSandbox, error) { + connected, err := s.client.Connect(ctx, id) + if err != nil { + return nil, err + } + return &cubeSDKSandbox{sandbox: connected}, nil +} + +type cubeSDKSandbox struct { + sandbox *cubesandbox.Sandbox +} + +func (s *cubeSDKSandbox) ID() string { return s.sandbox.SandboxID } + +func (s *cubeSDKSandbox) Exec( + ctx context.Context, + command string, + cwd string, + timeout time.Duration, +) (string, string, int, error) { + result, err := s.sandbox.Commands().Run(ctx, command, cubesandbox.CommandOptions{ + Cwd: cwd, + Timeout: timeout, + }) + if err != nil { + return "", "", -1, err + } + return result.Stdout, result.Stderr, result.ExitCode, nil +} + +func (s *cubeSDKSandbox) ReadFile( + ctx context.Context, + value string, +) ([]byte, error) { + content, err := s.sandbox.Files().Read(ctx, value) + return []byte(content), err +} + +func (s *cubeSDKSandbox) WriteFile( + ctx context.Context, + value string, + data []byte, +) error { + return s.sandbox.Files().Write(ctx, value, data) +} + +func (s *cubeSDKSandbox) Destroy(ctx context.Context) error { + return s.sandbox.Kill(ctx) +} + +func isCubeNotFound(err error) bool { + return errors.Is(err, cubesandbox.ErrSandboxNotFound) +} + +type e2bControlClient struct { + apiURL string + apiKey string + bearerAuth bool + client *http.Client +} + +func (c *e2bControlClient) List( + ctx context.Context, + metadata map[string]string, +) ([]cubesandbox.SandboxInfo, error) { + var all []cubesandbox.SandboxInfo + nextToken := "" + seenTokens := map[string]struct{}{} + for { + query := url.Values{} + query.Set("limit", "100") + query.Set("state", "running,paused") + if nextToken != "" { + query.Set("nextToken", nextToken) + } + if len(metadata) > 0 { + encodedMetadata := url.Values{} + for key, value := range metadata { + encodedMetadata.Set(key, value) + } + query.Set("metadata", encodedMetadata.Encode()) + } + var page []cubesandbox.SandboxInfo + response, err := c.doJSON( + ctx, + http.MethodGet, + "/v2/sandboxes?"+query.Encode(), + nil, + &page, + ) + if err != nil { + return nil, err + } + all = append(all, page...) + nextToken = response.Header.Get("X-Next-Token") + if nextToken == "" { + return all, nil + } + if _, exists := seenTokens[nextToken]; exists { + return nil, errors.New("sandbox: repeated sandbox-list page token") + } + seenTokens[nextToken] = struct{}{} + } +} + +func (c *e2bControlClient) Get( + ctx context.Context, + id string, +) (cubesandbox.SandboxInfo, error) { + var info cubesandbox.SandboxInfo + _, err := c.doJSON( + ctx, + http.MethodGet, + "/sandboxes/"+url.PathEscape(id), + nil, + &info, + ) + return info, err +} + +func (c *e2bControlClient) doJSON( + ctx context.Context, + method string, + requestPath string, + body io.Reader, + out any, +) (*http.Response, error) { + if c.client == nil { + return nil, errors.New("sandbox: HTTP client is required") + } + request, err := http.NewRequestWithContext( + ctx, + method, + c.apiURL+requestPath, + body, + ) + if err != nil { + return nil, err + } + if c.apiKey != "" { + if c.bearerAuth { + request.Header.Set("Authorization", "Bearer "+c.apiKey) + } else { + request.Header.Set("X-API-Key", c.apiKey) + } + } + response, err := c.client.Do(request) + if err != nil { + return nil, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode == http.StatusNotFound { + return response, cubesandbox.ErrSandboxNotFound + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + return response, fmt.Errorf( + "HTTP %d: %s", + response.StatusCode, + strings.TrimSpace(string(message)), + ) + } + if out == nil || response.StatusCode == http.StatusNoContent { + return response, nil + } + if err := json.NewDecoder(response.Body).Decode(out); err != nil { + return response, err + } + return response, nil +} + +// newE2BTransport adapts CubeSandbox's E2B-compatible Go client to E2B Cloud: +// E2B uses X-API-Key instead of Bearer auth, requires a timeout on Connect, and +// returns 201 when Connect resumes a paused sandbox. +func newE2BTransport( + baseClient *http.Client, + apiURL string, + apiKey string, + idleTimeout time.Duration, +) (*http.Client, error) { + parsed, err := url.Parse(apiURL) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("sandbox: invalid E2B_API_URL %q", apiURL) + } + base := http.DefaultTransport + if baseClient != nil && baseClient.Transport != nil { + base = baseClient.Transport + } + client := &http.Client{} + if baseClient != nil { + *client = *baseClient + } + client.Transport = &e2bRoundTripper{ + base: base, + apiHost: parsed.Host, + apiKey: apiKey, + connectTimeout: int(idleTimeout.Round(time.Second) / time.Second), + } + return client, nil +} + +type e2bRoundTripper struct { + base http.RoundTripper + apiHost string + apiKey string + connectTimeout int +} + +func (t *e2bRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.Header = req.Header.Clone() + if clone.URL.Host == t.apiHost { + clone.Header.Del("Authorization") + clone.Header.Set("X-API-Key", t.apiKey) + } + isConnect := clone.Method == http.MethodPost && + strings.HasSuffix(clone.URL.Path, "/connect") + if isConnect && clone.URL.Host == t.apiHost { + payload := []byte(fmt.Sprintf(`{"timeout":%d}`, t.connectTimeout)) + clone.Body = io.NopCloser(bytes.NewReader(payload)) + clone.ContentLength = int64(len(payload)) + clone.Header.Set("Content-Type", "application/json") + } + response, err := t.base.RoundTrip(clone) + if err != nil { + return nil, err + } + if isConnect && response.StatusCode == http.StatusCreated { + response.StatusCode = http.StatusOK + response.Status = "200 OK" + } + return response, nil +} diff --git a/internal/sandbox/e2b_test.go b/internal/sandbox/e2b_test.go new file mode 100644 index 0000000..150856d --- /dev/null +++ b/internal/sandbox/e2b_test.go @@ -0,0 +1,260 @@ +package sandbox + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + cubesandbox "github.com/tencentcloud/CubeSandbox/sdk/go" +) + +func TestE2BTransportAdaptsCloudControlPlane(t *testing.T) { + var gotAPIKey string + var gotAuthorization string + var gotTimeout int + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + gotAPIKey = request.Header.Get("X-API-Key") + gotAuthorization = request.Header.Get("Authorization") + var payload struct { + Timeout int `json:"timeout"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Errorf("decode connect payload: %v", err) + } + gotTimeout = payload.Timeout + writer.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + client, err := newE2BTransport( + server.Client(), + server.URL, + "test-api-key", + 7*time.Minute, + ) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + server.URL+"/sandboxes/sbx-1/connect", + strings.NewReader("{}"), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer should-be-replaced") + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer func() { _ = response.Body.Close() }() + _, _ = io.Copy(io.Discard, response.Body) + + if gotAPIKey != "test-api-key" { + t.Fatalf("X-API-Key = %q", gotAPIKey) + } + if gotAuthorization != "" { + t.Fatalf("Authorization was forwarded to E2B: %q", gotAuthorization) + } + if gotTimeout != 420 { + t.Fatalf("connect timeout = %d, want 420", gotTimeout) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("normalized connect status = %d, want 200", response.StatusCode) + } +} + +func TestRemoteProviderConstructorsRejectMissingRequiredConfig(t *testing.T) { + if _, err := NewE2BProvider(E2BConfig{}); err == nil { + t.Fatal("NewE2BProvider accepted an empty API key") + } + if _, err := NewCubeProvider(CubeConfig{}); err == nil { + t.Fatal("NewCubeProvider accepted an empty API URL and template") + } + if _, err := NewOpenSandboxProvider(OpenSandboxConfig{}); err == nil { + t.Fatal("NewOpenSandboxProvider accepted an empty base URL") + } + if _, err := NewDaytonaProvider(DaytonaConfig{}); err == nil { + t.Fatal("NewDaytonaProvider accepted an empty API key") + } +} + +func TestE2BControlClientListsEveryPageWithMetadataFilter(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + requests++ + if request.URL.Path != "/v2/sandboxes" { + t.Errorf("path = %q", request.URL.Path) + } + if request.URL.Query().Get("limit") != "100" { + t.Errorf("limit = %q", request.URL.Query().Get("limit")) + } + if request.URL.Query().Get("state") != "running,paused" { + t.Errorf("state = %q", request.URL.Query().Get("state")) + } + metadata, err := url.ParseQuery(request.URL.Query().Get("metadata")) + if err != nil { + t.Errorf("parse metadata: %v", err) + } + if metadata.Get(remoteManagedKey) != remoteManagedValue || + metadata.Get(remoteSessionKey) != "session-hash" { + t.Errorf("metadata = %v", metadata) + } + if request.Header.Get("X-API-Key") != "test-key" { + t.Errorf("X-API-Key = %q", request.Header.Get("X-API-Key")) + } + + writer.Header().Set("Content-Type", "application/json") + if request.URL.Query().Get("nextToken") == "" { + writer.Header().Set("X-Next-Token", "page-2") + _, _ = io.WriteString( + writer, + `[{"sandboxID":"sbx-1","metadata":{"page":"1"}}]`, + ) + return + } + if request.URL.Query().Get("nextToken") != "page-2" { + t.Errorf("nextToken = %q", request.URL.Query().Get("nextToken")) + } + _, _ = io.WriteString( + writer, + `[{"sandboxID":"sbx-2","metadata":{"page":"2"}}]`, + ) + })) + defer server.Close() + + client := &e2bControlClient{ + apiURL: server.URL, + apiKey: "test-key", + client: server.Client(), + } + items, err := client.List(context.Background(), map[string]string{ + remoteManagedKey: remoteManagedValue, + remoteSessionKey: "session-hash", + }) + if err != nil { + t.Fatal(err) + } + if requests != 2 { + t.Fatalf("requests = %d, want 2", requests) + } + if len(items) != 2 || + items[0].SandboxID != "sbx-1" || + items[1].SandboxID != "sbx-2" { + t.Fatalf("items = %+v", items) + } +} + +func TestE2BControlClientGetMapsNotFound(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + client := &e2bControlClient{ + apiURL: server.URL, + client: server.Client(), + } + _, err := client.Get(context.Background(), "missing/id") + if !errors.Is(err, cubesandbox.ErrSandboxNotFound) { + t.Fatalf("Get error = %v, want ErrSandboxNotFound", err) + } +} + +func TestE2BControlClientRejectsRepeatedPageToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + _ *http.Request, + ) { + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("X-Next-Token", "same-token") + _, _ = io.WriteString(writer, `[]`) + })) + defer server.Close() + + client := &e2bControlClient{ + apiURL: server.URL, + client: server.Client(), + } + _, err := client.List(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "repeated") { + t.Fatalf("List error = %v, want repeated-token error", err) + } +} + +func TestE2BCreateUsesDocumentedDefaultDenyField(t *testing.T) { + var payload map[string]any + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + if request.Method != http.MethodPost || request.URL.Path != "/sandboxes" { + t.Errorf("%s %s", request.Method, request.URL.Path) + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Errorf("decode create payload: %v", err) + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = io.WriteString( + writer, + `{"sandboxID":"sbx-1","templateID":"base","clientID":"client-1","envdVersion":"1.0.0"}`, + ) + })) + defer server.Close() + + client := cubesandbox.NewClient( + cubesandbox.Config{ + APIURL: server.URL, + TemplateID: defaultE2BTemplate, + }, + cubesandbox.WithHTTPClient(server.Client()), + ) + service := &cubeSDKService{ + name: E2BProviderName, + client: client, + idleTimeout: time.Minute, + } + remote, err := service.Create(context.Background(), "session-1", Spec{}) + if err != nil { + t.Fatal(err) + } + if remote.ID() != "sbx-1" { + t.Fatalf("sandbox ID = %q", remote.ID()) + } + if value, ok := payload["allow_internet_access"].(bool); !ok || value { + t.Fatalf("allow_internet_access = %#v, want false", payload["allow_internet_access"]) + } + if _, exists := payload["allowInternetAccess"]; exists { + t.Fatalf("unexpected camel-case network field in payload: %v", payload) + } +} + +func TestRemoteControlHTTPClientHasBoundedDefault(t *testing.T) { + client := remoteControlHTTPClient(nil) + if client.Timeout != remoteDefaultPeriod { + t.Fatalf("timeout = %s, want %s", client.Timeout, remoteDefaultPeriod) + } + + custom := &http.Client{Timeout: 17 * time.Second} + cloned := remoteControlHTTPClient(custom) + if cloned == custom { + t.Fatal("remoteControlHTTPClient mutated the caller-owned client") + } + if cloned.Timeout != custom.Timeout { + t.Fatalf("custom timeout = %s, want %s", cloned.Timeout, custom.Timeout) + } +} diff --git a/internal/sandbox/live_conformance_test.go b/internal/sandbox/live_conformance_test.go new file mode 100644 index 0000000..d401384 --- /dev/null +++ b/internal/sandbox/live_conformance_test.go @@ -0,0 +1,116 @@ +package sandbox_test + +import ( + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/yanpgwang/managed-agent-go/internal/sandbox" + "github.com/yanpgwang/managed-agent-go/internal/sandbox/sandboxtest" +) + +func TestE2BLiveConformance(t *testing.T) { + requireLive(t, "MANGO_LIVE_E2B") + runLiveConformance(t, func(t *testing.T) sandbox.Provider { + provider, err := sandbox.NewE2BProvider(sandbox.E2BConfig{ + APIURL: os.Getenv("E2B_API_URL"), + APIKey: os.Getenv("E2B_API_KEY"), + TemplateID: os.Getenv("E2B_TEMPLATE_ID"), + Domain: os.Getenv("E2B_DOMAIN"), + IdleTimeout: 10 * time.Minute, + }) + if err != nil { + t.Fatal(err) + } + return provider + }) +} + +func TestCubeLiveConformance(t *testing.T) { + requireLive(t, "MANGO_LIVE_CUBE") + runLiveConformance(t, func(t *testing.T) sandbox.Provider { + provider, err := sandbox.NewCubeProvider(sandbox.CubeConfig{ + APIURL: os.Getenv("CUBE_API_URL"), + APIKey: os.Getenv("CUBE_API_KEY"), + TemplateID: os.Getenv("CUBE_TEMPLATE_ID"), + Domain: os.Getenv("CUBE_SANDBOX_DOMAIN"), + ProxyNodeIP: os.Getenv("CUBE_PROXY_NODE_IP"), + ProxyPort: liveEnvInt("CUBE_PROXY_PORT_HTTP"), + ProxyScheme: os.Getenv("CUBE_PROXY_SCHEME"), + IdleTimeout: 10 * time.Minute, + }) + if err != nil { + t.Fatal(err) + } + return provider + }) +} + +func TestOpenSandboxLiveConformance(t *testing.T) { + requireLive(t, "MANGO_LIVE_OPENSANDBOX") + runLiveConformance(t, func(t *testing.T) sandbox.Provider { + provider, err := sandbox.NewOpenSandboxProvider( + sandbox.OpenSandboxConfig{ + BaseURL: os.Getenv("OPEN_SANDBOX_DOMAIN"), + APIKey: os.Getenv("OPEN_SANDBOX_API_KEY"), + Image: os.Getenv("OPEN_SANDBOX_IMAGE"), + UseProxy: liveEnvBool("OPEN_SANDBOX_USE_SERVER_PROXY"), + }, + ) + if err != nil { + t.Fatal(err) + } + return provider + }) +} + +func TestDaytonaLiveConformance(t *testing.T) { + requireLive(t, "MANGO_LIVE_DAYTONA") + runLiveConformance(t, func(t *testing.T) sandbox.Provider { + provider, err := sandbox.NewDaytonaProvider(sandbox.DaytonaConfig{ + APIURL: os.Getenv("DAYTONA_API_URL"), + APIKey: os.Getenv("DAYTONA_API_KEY"), + Target: os.Getenv("DAYTONA_TARGET"), + Snapshot: os.Getenv("DAYTONA_SNAPSHOT"), + Image: os.Getenv("DAYTONA_IMAGE"), + AutoPauseMinutes: liveEnvInt("DAYTONA_AUTO_PAUSE_MINUTES"), + }) + if err != nil { + t.Fatal(err) + } + return provider + }) +} + +func runLiveConformance(t *testing.T, factory sandboxtest.Factory) { + t.Helper() + sandboxtest.Run(t, sandboxtest.Config{ + NewProvider: factory, + // Provider lifecycle conformance does not require egress. "bridge" + // avoids requiring an optional policy sidecar in local OpenSandbox + // installations; provider-specific tests cover network policy mapping. + Spec: sandbox.Spec{Timeout: 2 * time.Minute, Network: "bridge"}, + ShellPath: "/bin/sh", + }) +} + +func requireLive(t *testing.T, name string) { + t.Helper() + if !liveEnvBool(name) { + t.Skipf("set %s=1 to run provider live conformance", name) + } +} + +func liveEnvBool(name string) bool { + value := strings.TrimSpace(os.Getenv(name)) + parsed, err := strconv.ParseBool(value) + return err == nil && parsed +} + +func liveEnvInt(name string) int { + value := strings.TrimSpace(os.Getenv(name)) + parsed, _ := strconv.Atoi(value) + return parsed +} diff --git a/internal/sandbox/opensandbox.go b/internal/sandbox/opensandbox.go new file mode 100644 index 0000000..cdeb2d3 --- /dev/null +++ b/internal/sandbox/opensandbox.go @@ -0,0 +1,494 @@ +package sandbox + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "path" + "sort" + "strings" + "time" + + opensandbox "github.com/alibaba/OpenSandbox/sdks/sandbox/go" +) + +const ( + OpenSandboxProviderName = "opensandbox" + defaultOpenSandboxImage = "python:3.12-slim" +) + +type OpenSandboxConfig struct { + BaseURL string + APIKey string + Image string + UseProxy bool + HTTPClient *http.Client +} + +type openSandboxResource struct { + id string + metadata map[string]string +} + +type openSandboxRemote interface { + ID() string + Exec(context.Context, string, string, time.Duration) (string, string, int, error) + ReadFile(context.Context, string) ([]byte, error) + WriteFile(context.Context, string, []byte) error + Destroy(context.Context) error +} + +type openSandboxService interface { + List(context.Context, map[string]string) ([]openSandboxResource, error) + Get(context.Context, string) (openSandboxResource, error) + Create(context.Context, string, Spec) (openSandboxRemote, error) + Connect(context.Context, string) (openSandboxRemote, error) +} + +type openSandboxProvider struct { + service openSandboxService + root string +} + +func NewOpenSandboxProvider(cfg OpenSandboxConfig) (Provider, error) { + baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + if baseURL == "" { + return nil, errors.New( + "sandbox: OPEN_SANDBOX_DOMAIN is required for the opensandbox provider", + ) + } + image := strings.TrimSpace(cfg.Image) + if image == "" { + image = defaultOpenSandboxImage + } + retry := opensandbox.DefaultRetryConfig() + connection := opensandbox.ConnectionConfig{ + Domain: baseURL, + APIKey: strings.TrimSpace(cfg.APIKey), + UseServerProxy: cfg.UseProxy, + RequestTimeout: remoteDefaultPeriod, + HTTPClient: cfg.HTTPClient, + Retry: &retry, + DisableMetrics: true, + } + return &openSandboxProvider{ + service: &openSandboxSDKService{ + config: connection, + manager: opensandbox.NewSandboxManager(connection), + image: image, + }, + root: remoteDefaultRoot, + }, nil +} + +func newOpenSandboxProvider(service openSandboxService, root string) Provider { + if root == "" { + root = remoteDefaultRoot + } + return &openSandboxProvider{service: service, root: root} +} + +func (p *openSandboxProvider) Name() string { return OpenSandboxProviderName } + +func (p *openSandboxProvider) Create( + ctx context.Context, + sessionKey string, + spec Spec, +) (Ref, Sandbox, error) { + if sessionKey == "" { + return Ref{}, nil, errors.New("sandbox: session key is required") + } + if err := ctx.Err(); err != nil { + return Ref{}, nil, err + } + existing, err := p.service.List(ctx, remoteMetadata(sessionKey)) + if err != nil { + return Ref{}, nil, fmt.Errorf("sandbox: opensandbox list: %w", err) + } + sort.Slice(existing, func(i, j int) bool { return existing[i].id < existing[j].id }) + if len(existing) > 0 { + return p.attachResource(ctx, sessionKey, existing[0], spec) + } + remote, err := p.service.Create(ctx, sessionKey, spec) + if err != nil { + existing, findErr := p.service.List(ctx, remoteMetadata(sessionKey)) + if findErr == nil && len(existing) > 0 { + sort.Slice(existing, func(i, j int) bool { + return existing[i].id < existing[j].id + }) + return p.attachResource(ctx, sessionKey, existing[0], spec) + } + return Ref{}, nil, fmt.Errorf("sandbox: opensandbox create: %w", err) + } + box := &openSandboxBox{ + remote: remote, + root: p.root, + timeout: spec.Timeout, + } + if err := box.ensureRoot(ctx); err != nil { + _ = remote.Destroy(context.Background()) + return Ref{}, nil, err + } + return Ref{Provider: p.Name(), ID: remote.ID()}, box, nil +} + +func (p *openSandboxProvider) Attach( + ctx context.Context, + sessionKey string, + ref Ref, + spec Spec, +) (Sandbox, error) { + if err := validateRemoteReference(p.Name(), sessionKey, ref); err != nil { + return nil, err + } + resource, err := p.service.Get(ctx, ref.ID) + if err != nil { + if isOpenSandboxNotFound(err) { + return nil, fmt.Errorf("%w: opensandbox sandbox %q", ErrNotFound, ref.ID) + } + return nil, fmt.Errorf("sandbox: opensandbox get: %w", err) + } + if err := validateRemoteOwnership( + p.Name(), + resource.id, + sessionKey, + resource.metadata, + ); err != nil { + return nil, err + } + remote, err := p.service.Connect(ctx, ref.ID) + if err != nil { + if isOpenSandboxNotFound(err) { + return nil, fmt.Errorf("%w: opensandbox sandbox %q", ErrNotFound, ref.ID) + } + return nil, fmt.Errorf("sandbox: opensandbox connect: %w", err) + } + box := &openSandboxBox{ + remote: remote, + root: p.root, + timeout: spec.Timeout, + } + if err := box.ensureRoot(ctx); err != nil { + return nil, err + } + return box, nil +} + +func (p *openSandboxProvider) attachResource( + ctx context.Context, + sessionKey string, + resource openSandboxResource, + spec Spec, +) (Ref, Sandbox, error) { + ref := Ref{Provider: p.Name(), ID: resource.id} + box, err := p.Attach(ctx, sessionKey, ref, spec) + return ref, box, err +} + +type openSandboxBox struct { + remote openSandboxRemote + root string + timeout time.Duration +} + +func (s *openSandboxBox) Root() string { return s.root } + +func (s *openSandboxBox) ensureRoot(ctx context.Context) error { + _, stderr, code, err := s.remote.Exec( + ctx, + "mkdir -p "+shellQuote(s.root), + "/", + s.timeout, + ) + if err != nil { + return fmt.Errorf("sandbox: opensandbox create workspace: %w", err) + } + if code != 0 { + return fmt.Errorf( + "sandbox: opensandbox create workspace failed (exit %d): %s", + code, + strings.TrimSpace(stderr), + ) + } + return nil +} + +func (s *openSandboxBox) Exec( + ctx context.Context, + cmd Command, +) (*Result, error) { + if cmd.Path == "" { + return nil, errors.New("sandbox: command path is required") + } + runCtx, cancel := commandTimeout(ctx, s.timeout) + defer cancel() + stdout, stderr, code, err := s.remote.Exec( + runCtx, + remoteCommandLine(cmd), + s.root, + s.timeout, + ) + if err != nil { + if runCtx.Err() != nil { + return remoteResult(runCtx, s.timeout, stdout, stderr, code) + } + return nil, fmt.Errorf("sandbox: opensandbox exec: %w", err) + } + return remoteResult(runCtx, s.timeout, stdout, stderr, code) +} + +func (s *openSandboxBox) ReadFile( + ctx context.Context, + value string, +) ([]byte, error) { + full, err := containedRemotePath(s.root, value) + if err != nil { + return nil, err + } + return s.remote.ReadFile(ctx, full) +} + +func (s *openSandboxBox) WriteFile( + ctx context.Context, + value string, + data []byte, +) error { + full, err := containedRemotePath(s.root, value) + if err != nil { + return err + } + _, stderr, code, err := s.remote.Exec( + ctx, + "mkdir -p "+shellQuote(path.Dir(full)), + s.root, + s.timeout, + ) + if err != nil { + return fmt.Errorf("sandbox: opensandbox create parent: %w", err) + } + if code != 0 { + return fmt.Errorf( + "sandbox: opensandbox create parent failed (exit %d): %s", + code, + strings.TrimSpace(stderr), + ) + } + return s.remote.WriteFile(ctx, full, data) +} + +func (s *openSandboxBox) Destroy(ctx context.Context) error { + err := s.remote.Destroy(ctx) + if isOpenSandboxNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("sandbox: opensandbox destroy: %w", err) + } + return nil +} + +type openSandboxSDKService struct { + config opensandbox.ConnectionConfig + manager *opensandbox.SandboxManager + image string +} + +func (s *openSandboxSDKService) List( + ctx context.Context, + metadata map[string]string, +) ([]openSandboxResource, error) { + const pageSize = 100 + var resources []openSandboxResource + for page := 1; ; page++ { + response, err := s.manager.ListSandboxInfos(ctx, opensandbox.ListOptions{ + Metadata: metadata, + Page: page, + PageSize: pageSize, + }) + if err != nil { + return nil, err + } + for _, item := range response.Items { + resources = append(resources, openSandboxResource{ + id: item.ID, + metadata: item.Metadata, + }) + } + if !response.Pagination.HasNextPage { + return resources, nil + } + if len(response.Items) == 0 || + (response.Pagination.Page > 0 && + response.Pagination.Page != page) || + (response.Pagination.TotalPages > 0 && + page >= response.Pagination.TotalPages) { + return nil, errors.New( + "sandbox: opensandbox returned non-advancing pagination", + ) + } + } +} + +func (s *openSandboxSDKService) Get( + ctx context.Context, + id string, +) (openSandboxResource, error) { + info, err := s.manager.GetSandboxInfo(ctx, id) + if err != nil { + return openSandboxResource{}, err + } + return openSandboxResource{id: info.ID, metadata: info.Metadata}, nil +} + +func (s *openSandboxSDKService) Create( + ctx context.Context, + sessionKey string, + spec Spec, +) (openSandboxRemote, error) { + image := s.image + if spec.Image != "" { + image = spec.Image + } + var limits opensandbox.ResourceLimits + if spec.CPUs != "" { + limits = opensandbox.ResourceLimits{} + limits["cpu"] = spec.CPUs + } + if spec.Memory != "" { + if limits == nil { + limits = opensandbox.ResourceLimits{} + } + limits["memory"] = normalizeKubernetesMemory(spec.Memory) + } + var policy *opensandbox.NetworkPolicy + if spec.Network == "" || spec.Network == "none" { + policy = &opensandbox.NetworkPolicy{DefaultAction: "deny"} + } + created, err := opensandbox.CreateSandbox( + ctx, + s.config, + opensandbox.SandboxCreateOptions{ + Image: image, + ResourceLimits: limits, + Metadata: remoteMetadata(sessionKey), + ManualCleanup: true, + NetworkPolicy: policy, + }, + ) + if err != nil { + return nil, err + } + return &openSandboxSDKRemote{sandbox: created}, nil +} + +func (s *openSandboxSDKService) Connect( + ctx context.Context, + id string, +) (openSandboxRemote, error) { + connected, err := opensandbox.ConnectSandbox(ctx, s.config, id) + if err != nil { + return nil, err + } + return &openSandboxSDKRemote{sandbox: connected}, nil +} + +type openSandboxSDKRemote struct { + sandbox *opensandbox.Sandbox +} + +func (s *openSandboxSDKRemote) ID() string { return s.sandbox.ID() } + +func (s *openSandboxSDKRemote) Exec( + ctx context.Context, + command string, + cwd string, + timeout time.Duration, +) (string, string, int, error) { + request := opensandbox.RunCommandRequest{ + Command: command, + Cwd: cwd, + } + if timeout > 0 { + request.Timeout = timeout.Milliseconds() + } + execution, err := s.sandbox.RunCommandWithOpts( + ctx, + request, + nil, + ) + if err != nil { + return "", "", -1, err + } + exitCode := 0 + if execution.ExitCode != nil { + exitCode = *execution.ExitCode + } + stdout := joinOpenSandboxOutput(execution.Stdout) + stderr := joinOpenSandboxOutput(execution.Stderr) + return stdout, stderr, exitCode, nil +} + +func (s *openSandboxSDKRemote) ReadFile( + ctx context.Context, + value string, +) (data []byte, err error) { + reader, err := s.sandbox.DownloadFile(ctx, value, "") + if err != nil { + return nil, err + } + defer func() { + if closeErr := reader.Close(); err == nil { + err = closeErr + } + }() + return io.ReadAll(reader) +} + +func (s *openSandboxSDKRemote) WriteFile( + ctx context.Context, + value string, + data []byte, +) error { + return s.sandbox.UploadFile( + ctx, + bytes.NewReader(data), + opensandbox.UploadFileOptions{ + FileName: path.Base(value), + Metadata: opensandbox.FileMetadata{ + Path: value, + // OpenSandbox's wire API uses chmod-style digits (600), not + // Go's os.FileMode integer representation (0o600 == 384). + Mode: 600, + }, + }, + ) +} + +func (s *openSandboxSDKRemote) Destroy(ctx context.Context) error { + return s.sandbox.Kill(ctx) +} + +func joinOpenSandboxOutput(messages []opensandbox.OutputMessage) string { + var builder strings.Builder + for _, message := range messages { + builder.WriteString(message.Text) + } + return builder.String() +} + +func normalizeKubernetesMemory(value string) string { + lower := strings.ToLower(strings.TrimSpace(value)) + if strings.HasSuffix(lower, "m") && + !strings.HasSuffix(lower, "mi") { + return strings.TrimSuffix(lower, "m") + "Mi" + } + return value +} + +func isOpenSandboxNotFound(err error) bool { + var apiErr *opensandbox.APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound +} diff --git a/internal/sandbox/remote.go b/internal/sandbox/remote.go new file mode 100644 index 0000000..8d67e7d --- /dev/null +++ b/internal/sandbox/remote.go @@ -0,0 +1,162 @@ +package sandbox + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/http" + "path" + "strings" + "time" +) + +const ( + remoteManagedKey = "io.mango.managed" + remoteSessionKey = "io.mango.session_key" + remoteManagedValue = "true" + remoteDefaultRoot = "/workspace" + remoteDefaultPeriod = 30 * time.Second +) + +func validateRemoteReference(providerName, sessionKey string, ref Ref) error { + if sessionKey == "" { + return Permanent(errors.New("sandbox: session key is required")) + } + if err := ref.validate(); err != nil { + return Permanent(err) + } + if ref.Provider != providerName { + return Permanent(fmt.Errorf( + "sandbox: %s provider cannot attach reference for %q", + providerName, + ref.Provider, + )) + } + return nil +} + +func remoteMetadata(sessionKey string) map[string]string { + return map[string]string{ + remoteManagedKey: remoteManagedValue, + remoteSessionKey: remoteSessionIdentity(sessionKey), + } +} + +func validateRemoteOwnership( + providerName string, + resourceID string, + sessionKey string, + metadata map[string]string, +) error { + if metadata[remoteManagedKey] != remoteManagedValue { + return Permanent(fmt.Errorf( + "sandbox: refusing to attach unmanaged %s sandbox %q", + providerName, + resourceID, + )) + } + if metadata[remoteSessionKey] != remoteSessionIdentity(sessionKey) { + return Permanent(fmt.Errorf( + "sandbox: %s sandbox %q belongs to another session", + providerName, + resourceID, + )) + } + return nil +} + +func remoteSessionIdentity(sessionKey string) string { + sum := sha256.Sum256([]byte(sessionKey)) + return fmt.Sprintf("%x", sum[:16]) +} + +func remoteControlHTTPClient(base *http.Client) *http.Client { + client := &http.Client{} + if base != nil { + *client = *base + } + if client.Timeout <= 0 { + client.Timeout = remoteDefaultPeriod + } + return client +} + +func deterministicRemoteName(providerName, sessionKey string) string { + sum := sha256.Sum256([]byte(providerName + "\x00" + sessionKey)) + return fmt.Sprintf("mango-%x", sum[:16]) +} + +// containedRemotePath confines a relative tool path to a POSIX workspace. +// Remote services receive the resulting absolute path; no host filesystem is +// consulted. +func containedRemotePath(root, value string) (string, error) { + if root == "" { + root = remoteDefaultRoot + } + clean := path.Clean(path.Join(root, value)) + if clean != root && !strings.HasPrefix(clean+"/", root+"/") { + return "", fmt.Errorf("sandbox: path %q escapes root", value) + } + return clean, nil +} + +func commandTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout <= 0 { + return ctx, func() {} + } + return context.WithTimeout(ctx, timeout) +} + +func remoteCommandLine(cmd Command) string { + parts := make([]string, 0, 1+len(cmd.Args)) + parts = append(parts, shellQuote(cmd.Path)) + for _, arg := range cmd.Args { + parts = append(parts, shellQuote(arg)) + } + command := "exec " + strings.Join(parts, " ") + if len(cmd.Stdin) == 0 { + return command + } + encoded := base64.StdEncoding.EncodeToString(cmd.Stdin) + return "printf %s " + shellQuote(encoded) + " | base64 -d | " + command +} + +func shellQuote(value string) string { + if value == "" { + return "''" + } + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func capRemoteOutput(value string) []byte { + raw := []byte(value) + if len(raw) > maxOutput { + raw = raw[:maxOutput] + } + return append([]byte(nil), raw...) +} + +func remoteResult( + ctx context.Context, + timeout time.Duration, + stdout string, + stderr string, + exitCode int, +) (*Result, error) { + result := &Result{ + Stdout: capRemoteOutput(stdout), + Stderr: capRemoteOutput(stderr), + ExitCode: exitCode, + } + if errors.Is(ctx.Err(), context.DeadlineExceeded) && timeout > 0 { + result.TimedOut = true + result.ExitCode = -1 + return result, nil + } + if err := ctx.Err(); err != nil { + return result, err + } + return result, nil +} diff --git a/internal/sandbox/remote_conformance_test.go b/internal/sandbox/remote_conformance_test.go new file mode 100644 index 0000000..baee8f9 --- /dev/null +++ b/internal/sandbox/remote_conformance_test.go @@ -0,0 +1,644 @@ +package sandbox + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + opensandbox "github.com/alibaba/OpenSandbox/sdks/sandbox/go" + daytonaerrors "github.com/daytona/clients/sdk-go/pkg/errors" + cubesandbox "github.com/tencentcloud/CubeSandbox/sdk/go" +) + +func TestRemoteProviderConformance(t *testing.T) { + tests := []struct { + name string + open func(*fakeRemoteStore) Provider + }{ + { + name: E2BProviderName, + open: func(store *fakeRemoteStore) Provider { + return newE2BLikeProvider( + E2BProviderName, + &fakeE2BService{store: store}, + remoteDefaultRoot, + ) + }, + }, + { + name: CubeProviderName, + open: func(store *fakeRemoteStore) Provider { + return newE2BLikeProvider( + CubeProviderName, + &fakeE2BService{store: store}, + remoteDefaultRoot, + ) + }, + }, + { + name: OpenSandboxProviderName, + open: func(store *fakeRemoteStore) Provider { + return newOpenSandboxProvider( + &fakeOpenSandboxService{store: store}, + remoteDefaultRoot, + ) + }, + }, + { + name: DaytonaProviderName, + open: func(store *fakeRemoteStore) Provider { + return newDaytonaProvider( + &fakeDaytonaService{store: store}, + defaultDaytonaRoot, + ) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store := newFakeRemoteStore(t.TempDir()) + runFakeRemoteContract(t, func() Provider { return test.open(store) }) + }) + } +} + +func TestDaytonaResourcesAcceptDocumentedSpecFormat(t *testing.T) { + resources := daytonaResources(Spec{CPUs: "1.0", Memory: "512m"}) + if resources == nil || resources.CPU != 1 || resources.Memory != 512 { + t.Fatalf("resources = %+v, want CPU=1 Memory=512", resources) + } +} + +func runFakeRemoteContract(t *testing.T, open func() Provider) { + t.Helper() + ctx := context.Background() + spec := Spec{Timeout: 5 * time.Second} + sessionKey := "sesn-" + strings.NewReplacer("/", "-", " ", "-"). + Replace(strings.ToLower(t.Name())) + + firstProvider := open() + ref, first, err := firstProvider.Create(ctx, sessionKey, spec) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = first.Destroy(context.Background()) }) + if ref.Provider != firstProvider.Name() || ref.ID == "" { + t.Fatalf("invalid durable reference: %+v", ref) + } + content := []byte{'d', 'u', 'r', 'a', 'b', 'l', 'e', 0, '\n'} + if err := first.WriteFile(ctx, "nested/state.bin", content); err != nil { + t.Fatal(err) + } + got, err := first.ReadFile(ctx, "nested/state.bin") + if err != nil || !bytes.Equal(got, content) { + t.Fatalf("file round trip = %q, %v", got, err) + } + result, err := first.Exec(ctx, Command{ + Path: "/bin/sh", + Args: []string{"-c", "printf conformance-exec"}, + }) + if err != nil || result.ExitCode != 0 || + string(result.Stdout) != "conformance-exec" { + t.Fatalf("Exec result = %+v, %v", result, err) + } + result, err = first.Exec(ctx, Command{ + Path: "/bin/sh", + Args: []string{"-c", "exit 7"}, + }) + if err != nil || result.ExitCode != 7 { + t.Fatalf("non-zero Exec result = %+v, %v", result, err) + } + if _, err := first.ReadFile(ctx, "../escape"); err == nil { + t.Fatal("ReadFile accepted a path outside the workspace") + } + if err := first.WriteFile(ctx, "../escape", []byte("x")); err == nil { + t.Fatal("WriteFile accepted a path outside the workspace") + } + cancelled, cancel := context.WithCancel(ctx) + cancel() + if _, err := first.Exec(cancelled, Command{ + Path: "/bin/sh", + Args: []string{"-c", "true"}, + }); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled Exec = %v, want context.Canceled", err) + } + + restarted := open() + sameRef, same, err := restarted.Create(ctx, sessionKey, spec) + if err != nil { + t.Fatal(err) + } + if sameRef != ref { + t.Fatalf("repeated Create ref = %+v, want %+v", sameRef, ref) + } + got, err = same.ReadFile(ctx, "nested/state.bin") + if err != nil || !bytes.Equal(got, content) { + t.Fatalf("repeated Create lost workspace: %q, %v", got, err) + } + attached, err := restarted.Attach(ctx, sessionKey, ref, spec) + if err != nil { + t.Fatal(err) + } + got, err = attached.ReadFile(ctx, "nested/state.bin") + if err != nil || !bytes.Equal(got, content) { + t.Fatalf("Attach lost workspace: %q, %v", got, err) + } + if _, err := restarted.Attach( + ctx, + sessionKey+"-other", + ref, + spec, + ); err == nil || !IsPermanent(err) { + t.Fatalf("cross-session Attach = %v, want permanent error", err) + } + if _, err := restarted.Attach( + ctx, + sessionKey, + Ref{Provider: "wrong-provider", ID: ref.ID}, + spec, + ); err == nil || !IsPermanent(err) { + t.Fatalf("wrong-provider Attach = %v, want permanent error", err) + } + if err := first.Destroy(ctx); err != nil { + t.Fatal(err) + } + if err := first.Destroy(ctx); err != nil { + t.Fatalf("repeated Destroy: %v", err) + } + if _, err := open().Attach( + ctx, + sessionKey, + ref, + spec, + ); !errors.Is(err, ErrNotFound) { + t.Fatalf("Attach after Destroy = %v, want ErrNotFound", err) + } +} + +type fakeRemoteStore struct { + mu sync.Mutex + baseDir string + nextID int + resources map[string]*fakeRemoteResource + names map[string]string +} + +type fakeRemoteResource struct { + id string + name string + metadata map[string]string + root string + destroyed bool +} + +func newFakeRemoteStore(baseDir string) *fakeRemoteStore { + return &fakeRemoteStore{ + baseDir: baseDir, + resources: map[string]*fakeRemoteResource{}, + names: map[string]string{}, + } +} + +func (s *fakeRemoteStore) create( + name string, + metadata map[string]string, +) *fakeRemoteResource { + s.mu.Lock() + defer s.mu.Unlock() + s.nextID++ + id := fmt.Sprintf("sbx-%04d", s.nextID) + root := filepath.Join(s.baseDir, id) + if err := os.MkdirAll(root, 0o700); err != nil { + panic(err) + } + resource := &fakeRemoteResource{ + id: id, + name: name, + metadata: cloneStringMap(metadata), + root: root, + } + s.resources[id] = resource + if name != "" { + s.names[name] = id + } + return resource +} + +func (s *fakeRemoteStore) list() []*fakeRemoteResource { + s.mu.Lock() + defer s.mu.Unlock() + items := make([]*fakeRemoteResource, 0, len(s.resources)) + for _, resource := range s.resources { + if !resource.destroyed { + items = append(items, resource) + } + } + return items +} + +func (s *fakeRemoteStore) get(idOrName string) (*fakeRemoteResource, error) { + s.mu.Lock() + defer s.mu.Unlock() + if id, ok := s.names[idOrName]; ok { + idOrName = id + } + resource, ok := s.resources[idOrName] + if !ok || resource.destroyed { + return nil, errFakeRemoteNotFound + } + return resource, nil +} + +func (s *fakeRemoteStore) destroy(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + resource, ok := s.resources[id] + if !ok || resource.destroyed { + return errFakeRemoteNotFound + } + resource.destroyed = true + return os.RemoveAll(resource.root) +} + +var errFakeRemoteNotFound = errors.New("fake remote not found") + +func cloneStringMap(value map[string]string) map[string]string { + cloned := make(map[string]string, len(value)) + for key, item := range value { + cloned[key] = item + } + return cloned +} + +type fakeRemoteHandle struct { + store *fakeRemoteStore + resource *fakeRemoteResource +} + +func (h *fakeRemoteHandle) ID() string { return h.resource.id } + +func (h *fakeRemoteHandle) exec( + ctx context.Context, + command string, +) (string, string, int, error) { + if err := ctx.Err(); err != nil { + return "", "", -1, err + } + if strings.HasPrefix(command, "mkdir -p ") { + return "", "", 0, nil + } + process := exec.CommandContext(ctx, "/bin/sh", "-c", command) + process.Dir = h.resource.root + process.Env = []string{"PATH=/usr/bin:/bin"} + var stdout bytes.Buffer + var stderr bytes.Buffer + process.Stdout = &stdout + process.Stderr = &stderr + err := process.Run() + if ctx.Err() != nil { + return stdout.String(), stderr.String(), -1, ctx.Err() + } + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return stdout.String(), stderr.String(), exitErr.ExitCode(), nil + } + return stdout.String(), stderr.String(), -1, err + } + return stdout.String(), stderr.String(), process.ProcessState.ExitCode(), nil +} + +func (h *fakeRemoteHandle) readFile(value string) ([]byte, error) { + relative, err := fakeRelativePath(value) + if err != nil { + return nil, err + } + return os.ReadFile(filepath.Join(h.resource.root, filepath.FromSlash(relative))) +} + +func (h *fakeRemoteHandle) writeFile(value string, data []byte) error { + relative, err := fakeRelativePath(value) + if err != nil { + return err + } + full := filepath.Join(h.resource.root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { + return err + } + return os.WriteFile(full, data, 0o600) +} + +func fakeRelativePath(value string) (string, error) { + for _, root := range []string{remoteDefaultRoot, defaultDaytonaRoot} { + if value == root { + return ".", nil + } + if strings.HasPrefix(value, root+"/") { + return strings.TrimPrefix(value, root+"/"), nil + } + } + return "", fmt.Errorf("unexpected fake remote path %q", value) +} + +type fakeE2BService struct { + store *fakeRemoteStore +} + +func (s *fakeE2BService) List( + _ context.Context, + metadata map[string]string, +) ([]e2bResource, error) { + items := s.store.list() + resources := make([]e2bResource, 0, len(items)) + for _, item := range items { + matches := true + for key, value := range metadata { + if item.metadata[key] != value { + matches = false + break + } + } + if !matches { + continue + } + resources = append(resources, e2bResource{ + id: item.id, + metadata: cloneStringMap(item.metadata), + }) + } + return resources, nil +} + +func (s *fakeE2BService) Get( + _ context.Context, + id string, +) (e2bResource, error) { + resource, err := s.store.get(id) + if err != nil { + if errors.Is(err, errFakeRemoteNotFound) { + return e2bResource{}, cubesandbox.ErrSandboxNotFound + } + return e2bResource{}, err + } + return e2bResource{ + id: resource.id, + metadata: cloneStringMap(resource.metadata), + }, nil +} + +func (s *fakeE2BService) Create( + _ context.Context, + sessionKey string, + _ Spec, +) (e2bServiceSandbox, error) { + resource := s.store.create("", remoteMetadata(sessionKey)) + return &fakeE2BRemote{ + fakeRemoteHandle: fakeRemoteHandle{store: s.store, resource: resource}, + }, nil +} + +func (s *fakeE2BService) Connect( + _ context.Context, + id string, +) (e2bServiceSandbox, error) { + resource, err := s.store.get(id) + if err != nil { + return nil, err + } + return &fakeE2BRemote{ + fakeRemoteHandle: fakeRemoteHandle{store: s.store, resource: resource}, + }, nil +} + +type fakeE2BRemote struct { + fakeRemoteHandle +} + +func (s *fakeE2BRemote) Exec( + ctx context.Context, + command string, + _ string, + _ time.Duration, +) (string, string, int, error) { + return s.exec(ctx, command) +} + +func (s *fakeE2BRemote) ReadFile( + _ context.Context, + value string, +) ([]byte, error) { + return s.readFile(value) +} + +func (s *fakeE2BRemote) WriteFile( + _ context.Context, + value string, + data []byte, +) error { + return s.writeFile(value, data) +} + +func (s *fakeE2BRemote) Destroy(context.Context) error { + err := s.store.destroy(s.resource.id) + if errors.Is(err, errFakeRemoteNotFound) { + return cubesandbox.ErrSandboxNotFound + } + return err +} + +type fakeOpenSandboxService struct { + store *fakeRemoteStore +} + +func (s *fakeOpenSandboxService) List( + _ context.Context, + metadata map[string]string, +) ([]openSandboxResource, error) { + items := s.store.list() + resources := make([]openSandboxResource, 0, len(items)) + for _, item := range items { + matches := true + for key, value := range metadata { + if item.metadata[key] != value { + matches = false + } + } + if matches { + resources = append(resources, openSandboxResource{ + id: item.id, + metadata: cloneStringMap(item.metadata), + }) + } + } + return resources, nil +} + +func (s *fakeOpenSandboxService) Get( + _ context.Context, + id string, +) (openSandboxResource, error) { + resource, err := s.store.get(id) + if err != nil { + if errors.Is(err, errFakeRemoteNotFound) { + return openSandboxResource{}, &opensandbox.APIError{ + StatusCode: 404, + } + } + return openSandboxResource{}, err + } + return openSandboxResource{ + id: resource.id, + metadata: cloneStringMap(resource.metadata), + }, nil +} + +func (s *fakeOpenSandboxService) Create( + _ context.Context, + sessionKey string, + _ Spec, +) (openSandboxRemote, error) { + resource := s.store.create("", remoteMetadata(sessionKey)) + return &fakeOpenSandboxRemote{ + fakeRemoteHandle: fakeRemoteHandle{store: s.store, resource: resource}, + }, nil +} + +func (s *fakeOpenSandboxService) Connect( + _ context.Context, + id string, +) (openSandboxRemote, error) { + resource, err := s.store.get(id) + if err != nil { + return nil, err + } + return &fakeOpenSandboxRemote{ + fakeRemoteHandle: fakeRemoteHandle{store: s.store, resource: resource}, + }, nil +} + +type fakeOpenSandboxRemote struct { + fakeRemoteHandle +} + +func (s *fakeOpenSandboxRemote) Exec( + ctx context.Context, + command string, + _ string, + _ time.Duration, +) (string, string, int, error) { + return s.exec(ctx, command) +} + +func (s *fakeOpenSandboxRemote) ReadFile( + _ context.Context, + value string, +) ([]byte, error) { + return s.readFile(value) +} + +func (s *fakeOpenSandboxRemote) WriteFile( + _ context.Context, + value string, + data []byte, +) error { + return s.writeFile(value, data) +} + +func (s *fakeOpenSandboxRemote) Destroy(context.Context) error { + err := s.store.destroy(s.resource.id) + if errors.Is(err, errFakeRemoteNotFound) { + return &opensandbox.APIError{StatusCode: 404} + } + return err +} + +type fakeDaytonaService struct { + store *fakeRemoteStore +} + +func (s *fakeDaytonaService) Get( + _ context.Context, + idOrName string, +) (daytonaResource, error) { + resource, err := s.store.get(idOrName) + if err != nil { + if errors.Is(err, errFakeRemoteNotFound) { + return daytonaResource{}, daytonaerrors.ErrNotFound + } + return daytonaResource{}, err + } + return fakeDaytonaResource(s.store, resource), nil +} + +func (s *fakeDaytonaService) Create( + _ context.Context, + name string, + sessionKey string, + _ Spec, +) (daytonaResource, error) { + resource := s.store.create(name, remoteMetadata(sessionKey)) + return fakeDaytonaResource(s.store, resource), nil +} + +func fakeDaytonaResource( + store *fakeRemoteStore, + resource *fakeRemoteResource, +) daytonaResource { + return daytonaResource{ + id: resource.id, + labels: cloneStringMap(resource.metadata), + remote: &fakeDaytonaRemote{ + fakeRemoteHandle: fakeRemoteHandle{store: store, resource: resource}, + }, + } +} + +type fakeDaytonaRemote struct { + fakeRemoteHandle +} + +func (s *fakeDaytonaRemote) Exec( + ctx context.Context, + command string, + _ string, + _ time.Duration, +) (string, string, int, error) { + return s.exec(ctx, command) +} + +func (s *fakeDaytonaRemote) ReadFile( + _ context.Context, + value string, +) ([]byte, error) { + return s.readFile(value) +} + +func (s *fakeDaytonaRemote) WriteFile( + _ context.Context, + value string, + data []byte, +) error { + return s.writeFile(value, data) +} + +func (s *fakeDaytonaRemote) Start(context.Context) error { + _, err := s.store.get(s.resource.id) + return err +} + +func (s *fakeDaytonaRemote) Destroy(context.Context) error { + err := s.store.destroy(s.resource.id) + if errors.Is(err, errFakeRemoteNotFound) { + return daytonaerrors.ErrNotFound + } + return err +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 192509e..cf93a20 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -3,8 +3,8 @@ // The local provider is a DEV-GRADE GUARDRAIL, NOT A SECURITY BOUNDARY: it // confines file paths to a working directory, clears the environment, applies a // timeout, and caps output — but it shares the host kernel and filesystem -// namespace. Do NOT run untrusted code with it. Real isolation (Docker/gVisor) -// is a later slice behind the same interface. +// namespace. Do NOT run untrusted code with it. Use an isolated Docker or remote +// provider for untrusted workloads. package sandbox import ( @@ -110,13 +110,14 @@ type Sandbox interface { Destroy(ctx context.Context) error } -// Provider owns sandbox resources outside the agent loop. Create must be -// idempotent for one sessionKey: after a lost response, repeating Create with -// the same key must resolve the same logical resource rather than leak a second -// sandbox. Attach reconstructs a client from a persisted Ref after a worker -// restart and must verify that the provider resource still belongs to the given -// sessionKey. Destroy remains on Sandbox so execution and teardown use the same -// authenticated provider client. +// Provider owns sandbox resources outside the agent loop. Create must expose a +// stable provider-side lookup key so a retry after a lost response resolves the +// same logical resource. When a provider cannot atomically create-if-absent, +// SessionManager's durable binding election destroys the losing resource from +// concurrent successful creates. Attach reconstructs a client from a persisted +// Ref after a worker restart and must verify that the provider resource still +// belongs to the given sessionKey. Destroy remains on Sandbox so execution and +// teardown use the same authenticated provider client. type Provider interface { Name() string Create(ctx context.Context, sessionKey string, spec Spec) (Ref, Sandbox, error) diff --git a/internal/sandbox/session.go b/internal/sandbox/session.go index 1e83190..e295cb0 100644 --- a/internal/sandbox/session.go +++ b/internal/sandbox/session.go @@ -33,9 +33,10 @@ type BindingStore interface { // ownership source of truth, and Provider.Attach reconstructs a client from the // persisted external reference. // -// Operations for one session are serialized locally. Provider.Create must also -// be idempotent by session key because separate worker processes can race before -// either has committed the binding. +// Operations for one session are serialized locally. Provider-side identity +// lookup recovers lost create responses; if separate worker processes both +// create successfully before either commits, BindingStore elects one durable +// winner and the losing resource is destroyed. type SessionManager struct { provider Provider bindings BindingStore diff --git a/scripts/with-dev-env b/scripts/with-dev-env new file mode 100755 index 0000000..a1c2c61 --- /dev/null +++ b/scripts/with-dev-env @@ -0,0 +1,51 @@ +#!/bin/sh +set -eu + +if [ "$#" -eq 0 ]; then + echo "usage: scripts/with-dev-env [args...]" >&2 + exit 2 +fi + +config_home=${XDG_CONFIG_HOME:-"$HOME/.config"} +env_file=${MANGO_ENV_FILE:-"$config_home/mango/dev.env"} + +if [ ! -f "$env_file" ]; then + echo "Mango development environment not found: $env_file" >&2 + echo "Create it from config/dev.env.example and set mode 0600." >&2 + exit 1 +fi + +permissions=$(stat -f '%Lp' "$env_file" 2>/dev/null || stat -c '%a' "$env_file") +case "$permissions" in + 600|0600) ;; + *) + echo "Refusing to load $env_file: permissions are $permissions, want 0600." >&2 + exit 1 + ;; +esac + +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ""|\#*) continue ;; + esac + case "$line" in + *=*) ;; + *) + echo "Invalid development environment line: expected NAME=VALUE." >&2 + exit 1 + ;; + esac + name=${line%%=*} + value=${line#*=} + case "$name" in + ""|[0-9]*|*[!A-Za-z0-9_]*) + echo "Invalid development environment variable name: $name" >&2 + exit 1 + ;; + esac + # Quoting the complete assignment makes the value literal: no shell + # expansion or command substitution is evaluated from the credentials file. + export "$name=$value" +done <"$env_file" + +exec "$@"