Environment Variables in Sandboxes
Note: The
abxcommands shown on this page are provided by the Node.js CLI in theclirepository (npm install -g @agent-sandbox/cli). CLI-specific documentation (CLI design, tutorial, reference) lives there too; this repository documents the Python SDK (agent-sandbox) and the container-side runtime (agent-sandbox-server).
This guide explains how environment variables work inside Agent Sandbox, covering the two distinct process scopes, injection methods, the direct-exec execution model, and recommended patterns.
Design decision: There is no
sandbox.set_env()API. The envd process tree and the SandboxServeros.environare two separate, non-unifiable scopes. Attempting to provide a single "set env" abstraction would be misleading. Use the scope-specific injection methods described below instead.
Two Process Scopes
Inside every sandbox there are two independent process trees, each with its own environment:
┌─────────────────────────────────────────────────────┐
│ Sandbox Container │
│ │
│ ┌────────────────────┐ ┌───────────────────────┐ │
│ │ envd (PID 1) │ │ SandboxServer │ │
│ │ ├─ commands.run() │ │ (optional, port 9000)│ │
│ │ ├─ run_code() │ │ ├─ GET/POST /env │ │
│ │ └─ terminal PTY │ │ ├─ POST /commands/* │ │
│ │ │ │ └─ child processes │ │
│ └────────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────┘
| Scope | Process owner | How env vars propagate |
|---|---|---|
| envd scope | The envd daemon (PID 1) |
Variables are set at sandbox creation or passed per-call; every process started via commands.run(), run_code(), or a PTY terminal inherits from envd. |
| Server scope | The SandboxServer Python process |
POST /env modifies os.environ of the Server process; only child processes spawned by the Server (e.g. POST /commands/{name} handlers) inherit those changes. |
Key boundary: POST /env on the SandboxServer does not affect processes started through the SDK's commands.run() or run_code() — those go through envd, which has its own environment. The two scopes are completely independent.
envd Scope — SDK & CLI Injection
At Creation Time
Variables passed at creation are injected into the envd root process and inherited by all subsequent commands:
SDK:
sandbox = await Sandbox.create(
template="base",
envs={"DATABASE_URL": "postgres://...", "APP_ENV": "staging"},
)
CLI:
abx create --template base --env DATABASE_URL=postgres://... --env APP_ENV=staging
@sandbox decorator:
@sandbox(template="base", envs={"DATABASE_URL": "postgres://..."})
def my_task():
import os
return os.environ["DATABASE_URL"]
Per-Execution Override
Each execution call can pass additional env vars that apply only to that invocation:
# commands.run — use `env=` (singular, dict)
result = await sandbox.commands.run(
"printenv MY_VAR",
env={"MY_VAR": "hello"},
)
# run_code — use `envs=` (plural, dict)
result = await sandbox.run_code(
"import os; print(os.environ['MY_VAR'])",
envs={"MY_VAR": "hello"},
)
Note the parameter naming difference:
commands.run()usesenv=(singular);run_code()usesenvs=(plural). This follows the E2B naming convention.
Direct-Exec Semantics
Commands sent through envd are executed via direct exec by default. However, commands.run(), commands.stream(), and commands.start() automatically detect unquoted shell operators (|, ;, &&, ||, >, <, (...), $(...)) and transparently wrap the command in sh -c.
Automatic Shell-Operator Wrapping
Pipes, redirects, semicolons, and logical operators now work without manual sh -c:
# ✅ Works — pipe is auto-detected and wrapped
result = await sandbox.commands.run("ls /tmp | grep log")
# ✅ Works — redirect is auto-detected and wrapped
result = await sandbox.commands.run("echo hello > /tmp/hello.txt")
# ✅ Works — && is auto-detected and wrapped
result = await sandbox.commands.run("cd /app && python main.py")
# ✅ Works — semicolon is auto-detected and wrapped
result = await sandbox.commands.run("mkdir -p /tmp/out; cp file.txt /tmp/out/")
Variable Expansion Still Requires Manual Wrapping
Shell variable expansion ($VAR, ${VAR}) is not auto-detected because $ is not a shell operator character. Use explicit sh -c:
# ❌ WRONG — $HOME is passed as a literal string (no auto-wrap triggered)
result = await sandbox.commands.run("echo $HOME")
# stdout: "$HOME" (literal)
# ✅ CORRECT — use sh -c for variable expansion
result = await sandbox.commands.run("sh -c 'echo $HOME'")
# stdout: "/root"
# ✅ CORRECT — use printenv to read a single variable
result = await sandbox.commands.run("printenv HOME")
# stdout: "/root"
Glob Expansion Still Requires Manual Wrapping
# ❌ WRONG — * is passed literally (no auto-wrap triggered)
result = await sandbox.commands.run("ls *.py")
# ✅ CORRECT
result = await sandbox.commands.run("sh -c 'ls *.py'")
Rule of Thumb
Automatically wrapped (no manual sh -c needed):
- Pipes (|)
- Redirects (>, >>, <)
- Logical operators (&&, ||)
- Command separators (;)
- Subshells ((...))
- Command substitution ($(...))
Still require explicit sh -c '...':
- Variable expansion ($VAR, ${VAR})
- Backtick substitution (`cmd`)
- Glob patterns (*, ?, [...])
If you only need to read a single environment variable's value, prefer printenv VAR — it is simpler and avoids shell quoting issues.
Server Scope — GET/POST /env
When a SandboxServer is running inside the sandbox (typically on port 9000), the /env endpoints manage the Server process's os.environ:
Read Variables
import httpx
url = sandbox.network.get_url(9000)
headers = sandbox.network.get_access_headers()
resp = httpx.get(f"{url}/env", headers=headers)
print(resp.json()["variables"])
# Only non-sensitive variables are returned (names containing
# TOKEN, SECRET, KEY, PASSWORD, CREDENTIAL are redacted)
Set Variables
resp = httpx.post(
f"{url}/env",
headers={**headers, "Content-Type": "application/json"},
json={"vars": {"MY_CONFIG": "new_value"}},
)
print(resp.json()["updated"]) # ["MY_CONFIG"]
Server Scope Boundaries
POST /envmodifies onlyos.environof the SandboxServer process.- Child processes spawned by the Server (e.g. custom command handlers via
POST /commands/{name}) inherit these changes. - Processes started via
commands.run(),run_code(), or PTY terminals are not affected — they run under envd, which has a separate environment.
Protected Variables
The Server rejects attempts to overwrite:
- System variables:
PATH,HOME,USER,SHELL - SDK internals: any variable starting with
ABX_(includingABX_SERVER_TOKEN)
Recommended Patterns
Pattern 1: Inject at Creation (Most Common)
Best for variables that should be available throughout the sandbox's lifetime:
sandbox = await Sandbox.create(
template="base",
envs={
"DATABASE_URL": db_url,
"API_KEY": api_key,
"APP_ENV": "production",
},
)
# All subsequent commands see these variables
result = await sandbox.commands.run("printenv DATABASE_URL")
Pattern 2: Per-Call env Dict (Scoped Overrides)
Best for variables that vary between invocations:
env = {"BATCH_ID": "batch-001", "DRY_RUN": "true"}
result = await sandbox.commands.run("sh -c 'python process.py'", env=env)
Pattern 3: Reuse an env Dict Object
Keep a shared dict and pass it to each call:
shared_env = {"DATABASE_URL": db_url, "LOG_LEVEL": "DEBUG"}
await sandbox.commands.run("sh -c 'python migrate.py'", env=shared_env)
await sandbox.commands.run("sh -c 'python seed.py'", env=shared_env)
await sandbox.run_code("import os; print(os.environ['LOG_LEVEL'])", envs=shared_env)
Pattern 4: Server-Scoped Config (SandboxServer Only)
When using a SandboxServer with custom commands, use POST /env for runtime config that only the Server needs:
import httpx
url = sandbox.network.get_url(9000)
headers = sandbox.network.get_access_headers()
httpx.post(
f"{url}/env",
headers={**headers, "Content-Type": "application/json"},
json={"vars": {"FEATURE_FLAG_X": "enabled"}},
)
# This variable is now visible to Server command handlers:
result = await sandbox.run("my_handler")
Client Configuration Variables Are Not Sandbox Variables
The variables that configure the SDK and CLI on your machine (ABX_SANDBOX_API_KEY, ABX_PROVIDER, ALIBABA_CLOUD_REGION_ID, ...) are read by the client and are never copied into a sandbox. To make a value visible inside a sandbox, pass it explicitly with envs= / --env. Each client setting has one variable. Names and precedence are in the Configuration Reference.
Two consequences:
- Inside the Server, every name starting with
ABX_is protected (see above). Do not useABX_*for your own sandbox variables. - Provider plugins do not inherit your shell environment either. A plugin process starts with a scrubbed environment; the configuration values it needs are sent to it in its
initializerequest.
Security Considerations
-
Secrets in envs: Environment variables passed via
envs=at creation time are transmitted to the platform API. Ensure your transport uses HTTPS (the default). Avoid logging env dicts that contain secrets. -
GET /envredaction: The Server'sGET /envendpoint automatically redacts variables whose names containTOKEN,SECRET,KEY,PASSWORD, orCREDENTIAL. -
Protected Server variables:
POST /envblocks writes toPATH,HOME,USER,SHELL, and allABX_*variables to prevent accidental breakage. -
No cross-scope leakage: Variables set via
POST /envon the Server are invisible to envd processes, and vice versa. This is a security feature, not a bug. -
Prefer
printenvoverecho $VAR: Since envd uses direct exec,echo $VARprints the literal string$VAR. Useprintenv VARorsh -c 'echo $VAR'instead.
Quick Reference
| Method | Parameter | Scope | Lifetime |
|---|---|---|---|
Sandbox.create(envs=) |
envs |
envd | Sandbox lifetime |
commands.run(env=) |
env |
envd | Single command |
run_code(envs=) |
envs |
envd | Single execution |
@sandbox(envs=) |
envs |
envd | Decorator call |
CLI --env KEY=VAL |
--env |
envd | Sandbox lifetime |
POST /env |
vars |
Server | Server process lifetime |
See Also
- SDK Usage Guide —
commands.run()andrun_code()usage - Declarative Usage —
@sandbox(envs=)decorator - CLI Tutorial —
abx create --env - Configuration Reference — SDK/CLI environment variables (one name per key)
- Providers — selecting and installing sandbox providers