Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.

# Checks for pull requests and main: the Go build, vet and tests, gofmt, the
# license headers, and the Python client's unit tests. Release publishing lives in
# license headers, the Python client's unit tests, and the integrations' tests. Release publishing lives in
# release.yaml. Actions are pinned by commit SHA (version in the trailing
# comment), as the organization's workflow security scan requires.
name: ci
Expand Down Expand Up @@ -63,3 +63,19 @@ jobs:
- run: python -m pytest internal/benchmarking/swebench/trajectories -q
- run: ruff check clients/python
- run: python -m pytest integrations/nemo-gym/tests -q

openhands:
# A job of its own: openhands-sdk and the agent-server the tests start pull
# in a large dependency set that the other Python checks do not need.
name: OpenHands integration
runs-on: ubuntu-24.04 # pinned: ubuntu-latest moves to 26.04 from 2026-10-19; bump deliberately
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- run: python -m pip install -e 'clients/python[dev]' -e 'integrations/openhands[test]'
- run: python -m pytest integrations/openhands/tests -q
- run: ruff check integrations/openhands
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ while this project adds the environment-shaped API on top.
- **`clients/go`** — The Go client library to manage environments, run commands, and perform file operations.
- **`clients/python`** — Python client for environment lifecycle management and batch sandbox orchestration ([README](clients/python/README.md)).
- **`integrations/nemo-gym`** — A [NeMo Gym](https://github.com/NVIDIA-NeMo/Gym) sandbox provider that runs rollout sandboxes as environments, built on the Python client ([README](integrations/nemo-gym/README.md)).
- **`integrations/openhands`** — An [OpenHands](https://github.com/OpenHands/software-agent-sdk) workspace, `AteEnvWorkspace`, that runs the agent-server in any task image and mirrors OpenHands' `AgentSandboxWorkspace` ([README](integrations/openhands/README.md)).

## Installation

Expand Down Expand Up @@ -412,7 +413,7 @@ For complete runnable Go programs:
- **MCP**: See [mcp](examples/mcp/main.go) to connect to an environment's MCP endpoint, discover tools, and execute tool calls.
- **Guest Daemon**: See [guest-daemon](examples/guest-daemon/main.go) to run a standalone in-actor gRPC service for asynchronous process execution and chunked file transfer.

For using environments from a framework, see [integrations/nemo-gym](integrations/nemo-gym/README.md): a NeMo Gym sandbox provider registered through the `nemo_gym.sandbox_providers` entry point.
For using environments from a framework, see [integrations/nemo-gym](integrations/nemo-gym/README.md), a NeMo Gym sandbox provider registered through the `nemo_gym.sandbox_providers` entry point, and [integrations/openhands](integrations/openhands/README.md), an OpenHands SDK workspace with the agent-server mounted into any task image.

## Cleanup

Expand Down
45 changes: 45 additions & 0 deletions cmd/ate-env/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"io"
"regexp"
"strings"

"github.com/agent-substrate/env/internal/apiservice"
Expand Down Expand Up @@ -107,6 +108,10 @@ type templateConfig struct {
// Empty means defaultActorCPU and defaultActorMemory.
cpu string
memory string
// env are extra container environment variables, each NAME=VALUE. The
// guest passes its environment to sidecars, so this is how a runtime
// layer's daemon is configured.
env []string
}

// runtimeLayer is a parsed --layer value.
Expand Down Expand Up @@ -191,6 +196,12 @@ func (c *templateConfig) validateResources() error {
return nil
}

// validateEnv checks the --env flags before anything is built.
func (c *templateConfig) validateEnv() error {
_, err := parseEnv(c.env)
return err
}

func (c *templateConfig) resolveImages() error {
if c.guestImage == "" {
return errors.New(`--guest-image is required when ate-env is built from source; release
Expand Down Expand Up @@ -293,6 +304,9 @@ It prints YAML to stdout without touching the cluster.`,
if err := tCfg.validateResources(); err != nil {
return err
}
if err := tCfg.validateEnv(); err != nil {
return err
}
tmpl, err := buildTemplate(tCfg)
if err != nil {
return err
Expand All @@ -313,6 +327,7 @@ It prints YAML to stdout without touching the cluster.`,
cmd.Flags().StringArrayVar(&tCfg.sidecarReadyz, "sidecar-readyz", nil, "URL the actor's readiness waits for, e.g. http://127.0.0.1:44772/ready (repeatable)")
cmd.Flags().StringVar(&tCfg.cpu, "cpu", defaultActorCPU, "CPU limit of each actor, a Kubernetes quantity such as 2 or 500m; Substrate requires one")
cmd.Flags().StringVar(&tCfg.memory, "memory", defaultActorMemory, "memory limit of each actor, a Kubernetes quantity such as 4Gi; Substrate requires one")
cmd.Flags().StringArrayVar(&tCfg.env, "env", nil, "environment variable for the actor's container as NAME=VALUE (repeatable); the guest passes it to sidecars, e.g. a layer daemon's API key")

return cmd
}
Expand Down Expand Up @@ -513,6 +528,11 @@ func orDefault(v, def string) string {
func buildTemplate(cfg templateConfig) (*ateapipb.ActorTemplate, error) {
tmpl := buildActorTemplate(cfg)
c := tmpl.Containers[0]
env, err := parseEnv(cfg.env)
if err != nil {
return nil, err
}
c.Env = append(c.Env, env...)
command := append([]string(nil), c.Command...)
if cfg.workspace != "" {
command = append(command, "-workspace", cfg.workspace)
Expand Down Expand Up @@ -626,3 +646,28 @@ func buildAPIService(cfg manifestConfig) *corev1.Service {
},
}
}

// envName is a POSIX environment variable name.
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

// parseEnv turns NAME=VALUE flags into container env vars. PORT is the guest's
// own and cannot be overridden; a name given twice is an error.
func parseEnv(flags []string) ([]*ateapipb.EnvVar, error) {
var out []*ateapipb.EnvVar
seen := map[string]bool{}
for _, f := range flags {
name, value, ok := strings.Cut(f, "=")
if !ok || !envName.MatchString(name) {
return nil, fmt.Errorf("--env %q: want NAME=VALUE with NAME of letters, digits and '_', not starting with a digit", f)
}
if name == "PORT" {
return nil, errors.New("--env PORT: PORT is the guest's listen port and is set by the template")
}
if seen[name] {
return nil, fmt.Errorf("--env %s is given twice", name)
}
seen[name] = true
out = append(out, &ateapipb.EnvVar{Name: name, Value: value})
}
return out, nil
}
89 changes: 89 additions & 0 deletions cmd/ate-env/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package main

import (
"bytes"
"io"
"strings"
"testing"

Expand Down Expand Up @@ -531,3 +532,91 @@ func TestImageFlagsDefaultToReleaseImages(t *testing.T) {
t.Errorf("missing --worker-image should point at the Substrate repo, got %v", err)
}
}

func envMap(c *ateapipb.Container) (map[string]string, []string) {
m := map[string]string{}
var order []string
for _, e := range c.GetEnv() {
m[e.GetName()] = e.GetValue()
order = append(order, e.GetName())
}
return m, order
}

func TestBuildTemplateEnv(t *testing.T) {
cfg := testTemplateConfig()
cfg.env = []string{"OH_SESSION_API_KEYS_0=s3cret", "EMPTY=", "URL=http://x/?a=b"}
tmpl, err := buildTemplate(cfg)
if err != nil {
t.Fatal(err)
}
got, order := envMap(tmpl.Containers[0])
if strings.Join(order, ",") != "PORT,OH_SESSION_API_KEYS_0,EMPTY,URL" {
t.Errorf("env order = %v, want PORT first, then the flags in order", order)
}
if got["PORT"] != "80" || got["OH_SESSION_API_KEYS_0"] != "s3cret" || got["EMPTY"] != "" || got["URL"] != "http://x/?a=b" {
t.Errorf("env = %v", got)
}

// A rootfs-image template keeps the variables: the sidecars that read them
// run there.
cfg.rootfsImage = "docker.io/library/python@sha256:" + testHex64
cfg.guestImage = "example.com/guest@sha256:" + testHex64
tmpl, err = buildTemplate(cfg)
if err != nil {
t.Fatal(err)
}
if got, _ := envMap(tmpl.Containers[0]); got["OH_SESSION_API_KEYS_0"] != "s3cret" {
t.Errorf("rootfs-image template env = %v, want the --env values", got)
}
}

func TestTemplateConfigRejectsBadEnv(t *testing.T) {
for _, env := range [][]string{
{"NOEQUALS"},
{"1BAD=x"},
{"BAD-NAME=x"},
{"=x"},
{"PORT=81"},
{"A=1", "A=2"},
} {
cfg := testTemplateConfig()
cfg.env = env
if err := cfg.validateEnv(); err == nil {
t.Errorf("env %q: want error, got nil", env)
}
}
}

func TestManifestTemplateCommandEnv(t *testing.T) {
cmd := newManifestCommand()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetArgs([]string{
"template",
"--guest-image=example.com/guest@sha256:aaaa",
"--snapshots-bucket=gs://bucket/prefix/",
"--env=OH_SESSION_API_KEYS_0=k",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if out := buf.String(); !strings.Contains(out, "name: OH_SESSION_API_KEYS_0\n value: k") {
t.Errorf("output lacks the --env variable:\n%s", out)
}
}

func TestManifestTemplateCommandRejectsBadEnv(t *testing.T) {
cmd := newManifestCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{
"template",
"--guest-image=example.com/guest@sha256:aaaa",
"--snapshots-bucket=gs://bucket/prefix/",
"--env=PORT=81",
})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--env PORT") {
t.Errorf("execute with --env PORT=81: want the --env error, got %v", err)
}
}
14 changes: 12 additions & 2 deletions docs/rootfs-images/RUNTIMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ A runtime layer is three things:
| the binary | an image volume on the ActorTemplate (`--layer name=image@sha256:...=/mount`) | mounts the runtime's image read-only at a path of your choice, next to the guest at `/ate` |
| the process | a guest flag (`--sidecar '/mount/binary args...'`) | the guest starts it after it is listening, logs its output with a prefix, and restarts it with backoff if it exits |
| its readiness | a guest flag (`--sidecar-readyz http://127.0.0.1:PORT/health`) | the guest's `/readyz`, which is the actor's wakeup probe, answers 503 until every such URL has answered 2xx once |
| its configuration | a template flag (`--env NAME=VALUE`) | sets a variable on the container; the guest passes its environment to sidecars |

The guest is the container's only process by contract, which is why it is the
one that supervises the others. Sidecars run without a shell or `PATH` lookup,
so the binary is an absolute path into its mount, and the rootfs image needs
nothing for it. They inherit the container's user, environment and filesystem.
nothing for it beyond, for a binary that is not statically linked, a
compatible C library (the OpenHands layer needs glibc 2.31 or newer). They inherit the container's user, environment and filesystem.

Template derivation keeps all of it. A base with layers yields derived
templates with the same layers and the same guest flags, so environments
Expand Down Expand Up @@ -76,7 +78,11 @@ file endpoints.
A verified stand-in for the mechanics, which needs nothing from any registry
but Docker Hub, is in [`examples/runtime-layers`](../../examples/runtime-layers):
the static `busybox:1.37-musl` mounted as the layer and its `httpd` as the
sidecar, run under two different rootfs images.
sidecar, run under two different rootfs images. A real daemon, the OpenHands agent-server in
any image with glibc 2.31 or newer, configured by `--env`, is the
[`integrations/openhands`](../../integrations/openhands) package: an OpenHands SDK workspace
(`AteEnvWorkspace`, compatible with OpenHands' `AgentSandboxWorkspace`), the layer, and examples
that drive it with OpenHands' own CodeAct agent.

## Who can reach the second runtime

Expand All @@ -99,6 +105,10 @@ sidecar, run under two different rootfs images.
curl localhost:7777/v1alpha/envs/py1/ports/44772/ready # the same, for clients without headers
```

The runtime must listen on all interfaces, not 127.0.0.1: the router
delivers to the actor's address, and a loopback-only listener answers
`502 bad gateway`.

The path form, `/v1alpha/envs/{id}/ports/{port}/...` (or
`/v1alpha/atespaces/{atespace}/envs/...`), is for clients that cannot set
headers: the prefix is stripped, so it works as a base URL. The tunnel
Expand Down
6 changes: 6 additions & 0 deletions integrations/openhands/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*.egg-info/
__pycache__/
.pytest_cache/
.venv/
build/
dist/
Loading
Loading