Agent Sandbox

Custom Environment

Create a Sandbox with custom environment variables.

Executing Commands with Custom Environment Variables

In many agentic workflows, you need to execute isolated commands inside the sandbox with specific environment variables (such as ephemeral API keys, testing flags, or dynamic paths) without permanently altering the global state of the container.

By extending the sandbox’s FastAPI runtime, you can accept a dynamic env dictionary per request and merge it seamlessly into the specific execution context of that process.

Prerequisites

  • A running Kubernetes cluster with the Agent Sandbox Controller installed.
  • The Sandbox Router deployed in your cluster.
  • A SandboxWarmPool named simple-sandbox-pool (backed by a SandboxTemplate named simple-sandbox-template) applied to your cluster, configured to use your custom FastAPI server as its entrypoint. The matching SandboxTemplate lives in this page’s source folder; pair it with a SandboxWarmPool whose spec.sandboxTemplateRef.name is simple-sandbox-template.
  • The Python SDK installed: pip install k8s-agent-sandbox.

1. The Custom Sandbox Runtime (Server-Side)

This code runs inside the sandbox pod. The ExecuteRequest model is extended to accept an optional dictionary of environment variables. When a command is triggered, it safely clones the system’s current environment variables, merges the incoming ones, and injects them into the subprocess.run call.


import os
import shlex
import string
import subprocess
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class ExecuteRequest(BaseModel):
    command: dict[str, dict[str, str]]

@app.get("/", summary="Health Check")
async def health_check():
    """A simple health check endpoint to confirm the server is running."""
    return {"status": "ok", "message": "Sandbox Runtime is active."}

@app.post("/execute")
def execute_command(req: ExecuteRequest):
    try:
        current_env = os.environ.copy()

        if "env" in req.command:
            current_env.update(req.command["env"])

        raw_command = req.command["command"]["content"]
        expanded_string = string.Template(raw_command).safe_substitute(current_env)
        safe_command = shlex.split(expanded_string)

        result = subprocess.run(
            safe_command,
            capture_output=True,
            text=True,
            timeout=120,
            env=current_env,
        )

        # Return the exact schema the SDK expects
        return {
            "stdout": result.stdout,
            "stderr": result.stderr,
            "exitCode": result.returncode
        }
    except Exception as e:
        return {
            "stdout": "",
            "stderr": str(e),
            "exitCode": 1
        }
  

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"os"
	"os/exec"
	"strings"
	"time"
)

// --- Request / Response types (mirrors Pydantic models) ---

type CommandContent struct {
	Content string `json:"content"`
}

type ExecuteRequest struct {
	Command struct {
		Command CommandContent    `json:"command"`
		Env     map[string]string `json:"env"`
	} `json:"command"`
}

type ExecuteResponse struct {
	Stdout   string `json:"stdout"`
	Stderr   string `json:"stderr"`
	ExitCode int    `json:"exitCode"`
}

// --- Handlers ---

func healthCheck(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{
		"status":  "ok",
		"message": "Sandbox Runtime is active.",
	})
}

func executeCommand(w http.ResponseWriter, r *http.Request) {
	var req ExecuteRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusOK, ExecuteResponse{Stderr: err.Error(), ExitCode: 1})
		return
	}

	// os.environ.copy() + update with req.Command.Env
	env := envMap()
	for k, v := range req.Command.Env {
		env[k] = v
	}

	// string.Template.safe_substitute — os.Expand leaves unrecognised vars intact
	expanded := os.Expand(req.Command.Command.Content, func(key string) string {
		if v, ok := env[key]; ok {
			return v
		}
		return "$" + key // safe_substitute behaviour: leave unknown vars as-is
	})

	// shlex.split — split on whitespace, respecting quoted tokens
	args, err := shellSplit(expanded)
	if err != nil {
		writeJSON(w, http.StatusOK, ExecuteResponse{Stderr: "invalid command: " + err.Error(), ExitCode: 1})
		return
	}
	if len(args) == 0 {
		writeJSON(w, http.StatusOK, ExecuteResponse{Stderr: "invalid command: empty command", ExitCode: 1})
		return
	}

	// subprocess.run(..., timeout=120)
	ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
	defer cancel()

	cmd := exec.CommandContext(ctx, args[0], args[1:]...)
	cmd.Env = envSlice(env)

	var stdout, stderr strings.Builder
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	exitCode := 0
	if err := cmd.Run(); err != nil {
		var exitErr *exec.ExitError
		if ok := isExitError(err, &exitErr); ok {
			exitCode = exitErr.ExitCode()
		} else {
			exitCode = 1
			stderr.WriteString(err.Error())
		}
	}

	writeJSON(w, http.StatusOK, ExecuteResponse{
		Stdout:   stdout.String(),
		Stderr:   stderr.String(),
		ExitCode: exitCode,
	})
}

// --- shlex.split (quoted-token aware, no external dependency) ---

func shellSplit(s string) ([]string, error) {
	var args []string
	var current strings.Builder
	inQuote := rune(0)

	for _, ch := range s {
		switch {
		case inQuote != 0:
			if ch == inQuote {
				inQuote = 0
			} else {
				current.WriteRune(ch)
			}
		case ch == '\'' || ch == '"':
			inQuote = ch
		case ch == ' ' || ch == '\t':
			if current.Len() > 0 {
				args = append(args, current.String())
				current.Reset()
			}
		default:
			current.WriteRune(ch)
		}
	}
	if current.Len() > 0 {
		args = append(args, current.String())
	}
	return args, nil
}

// --- env helpers ---

func envMap() map[string]string {
	m := make(map[string]string)
	for _, kv := range os.Environ() {
		if i := strings.IndexByte(kv, '='); i >= 0 {
			m[kv[:i]] = kv[i+1:]
		}
	}
	return m
}

func envSlice(m map[string]string) []string {
	out := make([]string, 0, len(m))
	for k, v := range m {
		out = append(out, k+"="+v)
	}
	return out
}

// --- helpers ---

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func isExitError(err error, target **exec.ExitError) bool {
	e, ok := err.(*exec.ExitError)
	if ok {
		*target = e
	}
	return ok
}

// --- main ---

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /", healthCheck)
	mux.HandleFunc("POST /execute", executeCommand)

	log.Println("Sandbox Runtime listening on :8888")
	if err := http.ListenAndServe(":8888", mux); err != nil {
		log.Fatal(err)
	}
}
  

Note: the rest of the Sandbox Docker image lives in the custom-environment example source folder.

2. Client Execution Workflow

Unlike standard command executions, this client script sends a raw JSON payload directly through the SDK’s run command to pass both the command and the env dictionary to the FastAPI server.

The following example demonstrates creating a sandbox, sending a command that requires a custom environment variable (TEST=True), and printing the modified output.


from k8s_agent_sandbox import SandboxClient

# 1. Initialize the client
client = SandboxClient()

# 2. Create the sandbox using your custom runtime warm pool
sandbox = client.create_sandbox("simple-sandbox-pool")

# 3. Run a command and inject environment variables via the payload
# The FastAPI server parses this into the ExecuteRequest model
payload = {
    "command": {"content": "echo $TEST"},
    "env": {"TEST": "True"}
}
response = sandbox.commands.run(payload)

# 4. Verify the output
# Because of our custom runtime logic, this will print:
# True
print(response)
  

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"

	"sigs.k8s.io/agent-sandbox/clients/go/sandbox"
)

// CommandPayload mirrors the ExecuteRequest model from the custom runtime
// server above: Command.Content maps to {"command": {"content": "..."}}, and
// Env injects environment variables: {"env": {"KEY": "VALUE"}}.
type CommandPayload struct {
	Command struct {
		Content string `json:"content"`
	} `json:"command"`
	Env map[string]string `json:"env,omitempty"`
}

type CommandResult struct {
	Stdout   string `json:"stdout"`
	Stderr   string `json:"stderr"`
	ExitCode int    `json:"exitCode"`
}

// runWithEnv calls the custom runtime's /execute endpoint directly. The
// published SDK's Commands().Run(ctx, cmd string) only accepts a plain shell
// string, with no field for injecting per-call environment variables, so this
// goes through the Sandbox Router (see Prerequisites) the same way the SDK's
// own tunnel/gateway strategies do: the router proxies any request carrying
// X-Sandbox-ID/X-Sandbox-Namespace headers straight to that sandbox's pod.
func runWithEnv(ctx context.Context, sb *sandbox.Sandbox, namespace string, payload CommandPayload) (*CommandResult, error) {
	body, err := json.Marshal(map[string]any{"command": payload})
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		"http://sandbox-router-svc.default.svc.cluster.local:8080/execute", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Sandbox-ID", sb.SandboxName())
	req.Header.Set("X-Sandbox-Namespace", namespace)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("run command: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		respBody, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("run command: server returned status %d: %s", resp.StatusCode, respBody)
	}

	var result CommandResult
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return &result, nil
}

func main() {
	ctx := context.Background()
	namespace := "default"

	// 1. Initialize the client. WarmPoolName must be set here too to satisfy
	// Options.validate(); CreateSandbox's own argument below is what
	// actually gets used.
	client, err := sandbox.NewClient(ctx, sandbox.Options{Namespace: namespace, WarmPoolName: "simple-sandbox-pool"})
	if err != nil {
		log.Fatal(err)
	}
	defer client.DeleteAll(ctx)

	// 2. Create the sandbox using your custom runtime warm pool
	sb, err := client.CreateSandbox(ctx, "simple-sandbox-pool", namespace)
	if err != nil {
		log.Fatal(err)
	}

	// 3. Run a command and inject environment variables via the payload
	var payload CommandPayload
	payload.Command.Content = "echo $TEST"
	payload.Env = map[string]string{"TEST": "True"}

	result, err := runWithEnv(ctx, sb, namespace, payload)
	if err != nil {
		log.Fatal(err)
	}

	// 4. Verify the output — prints: True
	fmt.Println(result.Stdout)
}
  
Last modified July 8, 2026: Docs codeboxes to golang (#716) (17c4f25)