Complete API reference for the container-side HTTP Server (
agent_sandbox.server).All endpoint methods, paths, parameters, request body fields, and response formats in this document are read directly from the implementation code under
src/agent_sandbox/server/, staying consistent with the source code. Where the code does not explicitly define certain behavior, this document faithfully notes it rather than speculating.
The container-side HTTP Server is a lightweight HTTP service that relies solely on the Python standard library (stdlib-only, zero third-party dependencies). It runs inside a Sandbox container and exposes file, process, terminal, system information, development tools, and browser automation capabilities to external consumers (SDK / CLI / Agent).
- Built on
http.server.ThreadingHTTPServerwith a customBaseHTTPRequestHandlersubclass. - Request dispatching is handled by a data-driven
RouteTable, not a hard-codedif/elifchain. - Each route is an endpoint with one id, declared once in
endpoints.py. A Capability Group is a name for a set of endpoint ids. - Request/response objects are stdlib
dataclasses (types.py), not Pydantic models.
Server endpoint-policy groups are independent from specification 5 template startup and Provider operations. Launch exposes all declared services; actual discovery, policy and token scopes still control each API call. See Template System.
Convenience function —
start():
from agent_sandbox.server import start
start(9000) # Listen on 0.0.0.0:9000, blockingClass interface —
SandboxServer (accepts custom registry / route_table):
from agent_sandbox.server import SandboxServer, CommandRegistry
registry = CommandRegistry()
# ... register custom commands ...
server = SandboxServer(host="0.0.0.0", registry=registry)
server.serve(port=9000) # Blocks until KeyboardInterrupt / shutdown()serve() calls registry.freeze() at startup to freeze the command registry, then begins accepting requests.
The Server uses two ports:
| Port | Protocol | Purpose | Default | Override Variable |
|---|---|---|---|---|
| HTTP Port | HTTP/1.1 | All REST / SSE endpoints | 9000 (serve(port=...) parameter) |
— |
| PTY Port | WebSocket | Interactive terminal (PTY) | 9001 |
ABX_PTY_PORT |
The PTY WebSocket service starts in a background daemon thread only when the TERMINAL capability group is enabled (see
SandboxServer.serve). TERMINAL is enabled by default, so the PTY service starts alongside the main service by default.
Every route is one endpoint with one id (see Fine-grained capabilities); the table is endpoints.py. A capability group is a name for a set of endpoint ids. There is one switch, the endpoint rule. A group name expands to the ids it contains, in every place it is accepted (groups: in the policy, enable_group() in code, ABX_SERVER_ENABLED_GROUPS in the environment). There is no second gate in front of the endpoint rules.
| Capability Group | Enum Value | Endpoints | Default State | Description |
|---|---|---|---|---|
CORE |
core |
3 | Always enabled (cannot be disabled) | Health check, capability query and check |
COMMANDS |
commands |
2 | Enabled | Named command listing and execution |
FILE_OPS |
file_ops |
12 | Enabled | File upload/download/listing/search/archive etc. |
PROCESS |
process |
8 | Enabled | Shell execution, SSE streaming shell, background process management |
TERMINAL |
terminal |
5 REST + WS | Enabled | PTY session management + WebSocket interactive terminal |
SYSTEM |
system |
6 | Enabled | System info, environment variables, ports, packages, metrics |
DEV_TOOLS |
dev_tools |
3 | Opt-in (off by default) | Code execution, Git inspection |
BROWSER |
browser |
8 | Opt-in (off by default) | Playwright browser automation |
AGENTS |
agents |
70 | Opt-in (off by default) | The Agents API (/v1/agents/..., /v1/vaults, /v1/responses, ...) |
TERMINAL is enabled by default, so the PTY WebSocket service starts with the main service. It runs in a background daemon thread and only when pty_connect is enabled.
Rules for one endpoint come from five layers, merged per endpoint: default (the opt-in groups start off), code (enable_group / disable_group on the route table), preset, policy (groups, defaults, endpoints), and environment (ABX_SERVER_ENABLED_GROUPS, which can only turn groups on). The more specific selector wins (exact id, then glob, then group:, then @defaults); at equal specificity the later layer wins; inside one layer a single enabled: false turns the endpoint off. CORE (health, capabilities, capabilities_check) cannot be disabled: disable_group(CapabilityGroup.CORE) raises ValueError.
Every denial names the rule that denied it: the 404 body carries code: endpoint_disabled and rule (policy:endpoints.shell, preset:read-only, code:enable_group:file_ops, default:opt-in:browser).
ABX_SERVER_ENABLED_GROUPS is a comma-separated list of group names (agents,dev_tools). It turns groups on. An unknown name stops the process with a did-you-mean message. There is no variable for turning groups off; write groups: {process: false} or an endpoints rule in the policy.
RouteTable has two code-level switches, both of which edit the code layer of the endpoint rules:
| Method | Description |
|---|---|
enable_group(group) |
Turn on every endpoint of the group (no-op for CORE) |
disable_group(group) |
Turn off every endpoint of the group (raises ValueError for CORE) |
Template author example:
from agent_sandbox.server import CapabilityGroup, default_table
table = default_table()
table.enable_group(CapabilityGroup.DEV_TOOLS)GET /capabilities reports the state per endpoint and derives groups and group_summary from it.
Authentication is step 2 of the pipeline in SandboxRequestHandler._pipeline (app.py).
- The Server reads the environment variable
ABX_SERVER_TOKENat startup (os.environ.get("ABX_SERVER_TOKEN") or None). - Token not set: local mode, all requests bypass authentication. The caller is the identity
anonymous. - Token set: requests must carry the token in
X-Access-TokenorAuthorization: Bearer <token>(both accepted on every route), compared withhmac.compare_digest(constant-time comparison). Missing or mismatched is401, body{"error": "unauthorized", "type": "AuthError"}. Two differentX-Access-Tokenvalues are400ambiguous_credentials.
Only GET /health is registered with auth_required=False (GET /capabilities and POST /capabilities/check always require authentication):
| Endpoint | Capability Group |
|---|---|
GET /health |
CORE |
Example request with token:
curl -H "X-Access-Token: $ABX_SERVER_TOKEN" http://localhost:9000/system/infoThere is one pipeline and every route goes through it, in this order:
- Path normalization:
urlparsefollowed bypath.rstrip("/") or "/"(strip trailing slash). RouteTable.match(method, path): no match is404 {"error": "Not found: <path>", "type": "ValueError"}.- Authenticate (only when
route.auth_required): failure is401. - Admission by the policy engine, for the matched endpoint: enabled (else
404 endpoint_disabledwithrule), scopes (403 missing_scope), rate and concurrency limits (429). - Duplicate query parameters (
400 duplicate_parameter) andTransfer-Encodingrejection. - For
POST/PUT/PATCH/DELETE, read the JSON request body, bounded bymax_request_bytes(413); a parse failure is400. - Invoke the handler (path, command, URL and parameter rules apply here);
streaming=Truetakes the SSE dispatch path. - Write the audit entry.
Only
do_GET/do_POST/do_DELETEare implemented.PUT/PATCHhave no corresponding entry points; stdlib returns501.
Route handler inputs and outputs use stdlib dataclass definitions (not Pydantic), located in
types.py.
The request object passed to handlers after parsing by _dispatch.
| Field | Type | Default | Description |
|---|---|---|---|
method |
str |
— | HTTP method (uppercase, e.g., "GET", "POST") |
path |
str |
— | URL path (e.g., "/commands/greet") |
query |
dict[str, list[str]] |
{} |
Query parameters, each key maps to a value list (supports duplicate keys) |
headers |
dict[str, str] |
{} |
Request headers (single-value) |
body |
dict[str, Any] | None |
None |
Parsed JSON request body; None when absent or non-JSON |
path_params |
dict[str, str] |
{} |
Path parameters extracted from {param} route templates |
context |
dict[str, Any] |
{} |
Request context injected by the dispatcher (e.g., CommandRegistry under the "registry" key) |
Return type for normal (non-streaming) handlers.
| Field | Type | Default | Description |
|---|---|---|---|
status |
int |
— | HTTP status code (e.g., 200, 404) |
body |
dict[str, Any] |
{} |
JSON-serializable response body |
headers |
dict[str, str] |
{} |
Additional response headers |
Factory methods:
| Method | Returns | Description |
|---|---|---|
ServerResponse.ok(data) |
ServerResponse(200, data) |
Success response |
ServerResponse.error(status, message, error_type="ValueError") |
ServerResponse(status, {"error": message, "type": error_type}) |
Error response |
Return type for SSE streaming handlers (streaming=True routes).
| Field | Type | Default | Description |
|---|---|---|---|
status |
int |
200 |
HTTP status code |
headers |
dict[str, str] |
{"Content-Type": "text/event-stream"} |
Response headers |
event_iterator |
Any |
None |
Iterator yielding SSE event payloads (str or bytes), flushed to the client one by one |
Response bodies are uniformly JSON. Success generally returns 200; error bodies follow the format
{"error": "<message>", "type": "<ExceptionName>"} (see
ServerResponse.error).
Health check, always returns 200, no authentication required.
Response:
{"status": "ok"}curl http://localhost:9000/healthLists all capability groups and their enabled status (authentication required when a token is configured). Servers that run a policy (or any v2 server) add api_version, server_version, identity, policy and endpoints to this document, see section 10.4; the groups object is unchanged.
Response (groups is {enum_value: bool}):
{
"groups": {
"core": true,
"commands": true,
"file_ops": true,
"process": true,
"terminal": true,
"system": true,
"dev_tools": false,
"browser": false
}
}Lists visible (non-hidden) commands in the registry along with their parameter definitions.
Response:
{
"commands": [
{
"name": "hello",
"args": [
{"name": "name", "type": "string", "required": false, "default": "World", "description": ""}
]
}
]
}Parameter fields from
handle_list_commands:
name / type / required / default / description.
Execute a registered command. The request body contains the command's keyword arguments (kwargs).
Request body example:
{"name": "Qoder"}Processing flow (see
handle_run_command):
- Command does not exist →
404 {"error": "Unknown command '<name>'; available: ...", "type": "ValueError"}. - Parameter validation/type coercion failure →
400(missing required parameter, unknown parameter, type conversion failure). - Command function raises exception →
500 {"error": "...", "type": "<ExceptionName>"}. - Success →
200 {"result": <return_value>}.
Type coercion supports string / integer / float / boolean; boolean accepts
true/false/yes/no/1/0 (see
_parse_boolean).
curl -X POST http://localhost:9000/commands/hello \
-H "Content-Type: application/json" \
-d '{"name": "Qoder"}'
# -> {"result": "Hello, Qoder!"}File operations are registered in routes_files.py. Upload and download use /files/upload-stream and /files/download-stream. All paths pass through the path guard (see §8.1).
Lists directory contents. Query parameters:
| Parameter | Default | Description |
|---|---|---|
path |
(required) | Directory path |
recursive |
false |
true/yes/1 for recursive; max recursive depth 3 (_DEFAULT_MAX_DEPTH) |
max_entries |
1000 |
Upper limit clamped to 1000 (_DEFAULT_MAX_ENTRIES) |
Response:
{
"entries": [
{"name": "a.txt", "type": "file", "size": 12, "modified": "2026-09-21T00:00:00+00:00"}
]
}type is file or directory; modified is ISO 8601 UTC. Symlinks pointing outside the base directory are skipped.
Directory not found → 404.
Returns metadata for a single file/directory. When the file does not exist, still returns 200 with exists=false:
{"name": "x", "path": "/home/user/x", "type": "unknown", "size": 0, "permissions": "", "modified": "", "exists": false}When present: type is file/directory/symlink, permissions is the last three octal digits (e.g., "644"),
modified is ISO 8601 UTC, exists=true.
Creates a directory (including parents, exist_ok=True). Request body {"path": "..."};
success {"path": "...", "created": true}.
Deletes a file or directory (directories use shutil.rmtree). Success {"path": "...", "deleted": true};
not found → 404.
Move/rename. Request body:
{"source": "/home/user/a.txt", "destination": "/home/user/b.txt"}Response {"source": "...", "destination": "..."}; source not found → 404; source/destination
path escape errors are prefixed with source: / destination: respectively.
Searches files by glob pattern (fnmatch). Request body:
| Field | Default | Description |
|---|---|---|
path |
(required) | Search root directory |
pattern |
(required) | Glob pattern, e.g., *.py |
max_depth |
5 (_SEARCH_MAX_DEPTH) |
Maximum recursion depth |
max_results |
100 (_SEARCH_MAX_RESULTS) |
Maximum result count |
Timeout upper limit 10 seconds (_SEARCH_TIMEOUT_SECONDS, implemented via a timer thread). Response:
{"results": [{"name": "app.py", "path": "/home/user/app.py"}], "timed_out": false}Chunked (64 KB) base64 upload with size limit. Request body
{"path": "...", "content_base64": "..."}. Exceeding the limit (default 100 MB, overridable via
ABX_MAX_UPLOAD_SIZE) → 413. Success {"path": "...", "bytes": N}.
Chunked read returning base64, with an additional bytes field:
{"path": "...", "content_base64": "<base64>", "bytes": 1024}File not found → 404.
Archives multiple paths and returns base64. Request body:
{"paths": ["/home/user/dir", "/home/user/a.txt"], "format": "tar.gz"}format supports tar.gz (default) or zip; other values → 400. Limits: total file count
10000 (_ARCHIVE_MAX_FILES), archive size 100 MB (_ARCHIVE_MAX_SIZE, exceeding → 413).
Path not found → 404. Response:
{"content_base64": "<base64>", "format": "tar.gz", "bytes": 2048}Includes /shell (registered in routes.py) and SSE streaming shell with background process management (registered in
routes_process.py).
Synchronous shell command execution (shlex.split + shell=False), 300-second timeout. Request body:
{"command": "ls -la /tmp"}Response:
{"stdout": "...", "stderr": "...", "exit_code": 0}Missing/invalid command → 400; timeout → 500 {"type": "TimeoutError"}.
Executes a command and streams output via SSE (streaming=True). Request body:
| Field | Default | Description |
|---|---|---|
command |
(required) | Command string |
timeout |
300 |
Seconds; <=0 or invalid falls back to 300 |
cwd |
(empty) | Working directory; empty means None |
SSE event format described in §6. Missing/invalid command or shlex parsing failure → 400 (plain JSON response).
Starts a background process (subprocess.Popen). Request body {"command": "...", "cwd": "..."}.
Process limit 100 (_MAX_PROCESSES, exceeding → 429 {"type": "ResourceError"}). Success:
{"pid": 12345, "command": "sleep 60"}Lists managed background processes (cleans up exited processes before listing). Response:
{"processes": [{"pid": 12345, "command": "sleep 60", "state": "running", "started_at": 1758412800.0}]}state is running or exited.
Single process details. pid not an integer → 400; unmanaged → 404 {"type": "NotFoundError"}. Response:
{"pid": 12345, "command": "sleep 60", "state": "exited", "started_at": 1758412800.0, "exit_code": 0}state=running has exit_code as null.
Sends a signal to a managed process. Request body {"signal": 15}.
- Allowed signal whitelist
_ALLOWED_SIGNALS = {2, 9, 15, 18, 20}(SIGINT/SIGKILL/SIGTERM/SIGCONT/SIGTSTP); others →400. - Sending signals to
pid == 1or the Server's own process is denied →403 {"type": "PermissionError"}. pidnot an integer or missingsignal→400; unmanaged →404.
Success {"pid": 12345, "signal": 15}.
PTY session management endpoints (registered in routes_pty.py). WebSocket interaction protocol described in §5.
PTY depends on the Unix
ptymodule; creating sessions on non-Unix platforms raisesOSError(→500). Session limitMAX_SESSIONS = 10, idle timeoutIDLE_TIMEOUT = 1800seconds.
Creates a PTY session. Request body (all optional):
| Field | Default | Description |
|---|---|---|
shell |
/bin/bash |
Shell executable |
cols |
80 |
Terminal columns |
rows |
24 |
Terminal rows |
env |
null |
Additional environment variable mapping |
Response:
{"session_id": "a1b2c3d4e5f6", "ws_url": "ws://localhost:9001/pty?session_id=a1b2c3d4e5f6", "pid": 4321}cols/rows not integer → 400; session limit reached → 429 {"type": "RuntimeError"}; PTY creation failure →
500 {"type": "OSError"}. The port in ws_url comes from ABX_PTY_PORT (default 9001).
Lists active sessions (reaps dead sessions first). Response:
{
"sessions": [
{"id": "a1b2c3d4e5f6", "pid": 4321, "created_at": 1758412800.0, "cols": 80, "rows": 24, "alive": true}
]
}Closes and destroys a session. Success {"status": "closed", "session_id": "<id>"}; missing id → 400;
not found → 404.
System information endpoints (registered in
routes_system.py). All implemented using only the standard library (no psutil).
OS / CPU / memory / disk / Python version:
{
"os": "Linux",
"arch": "x86_64",
"cpu_count": 4,
"memory_total_mb": 8192,
"memory_available_mb": 4096,
"disk_total_gb": 40.0,
"disk_free_gb": 25.5,
"python_version": "3.11.5",
"hostname": "sandbox-01"
}Memory is preferentially parsed from /proc/meminfo; falls back to the resource module on non-Linux (less precise).
Lists environment variables. Query parameter filter is a comma-separated whitelist (returns only those variables). Variables whose names contain sensitive tokens (TOKEN/SECRET/KEY/PASSWORD/CREDENTIAL) are always excluded (see §8.3).
{"variables": {"LANG": "C.UTF-8", "PWD": "/home/user"}}Sets environment variables. Request body {"vars": {"KEY": "value"}}. Protected variables
(PATH/HOME/USER/SHELL/ABX_SERVER_TOKEN) cannot be overwritten → 403 {"type": "PermissionError"};
vars not an object → 400. Success {"updated": ["KEY1", "KEY2"]} (sorted).
Lists listening TCP ports. Preferentially parses /proc/net/tcp and /proc/net/tcp6 (state 0A = LISTEN),
falls back to ss -tlnp when empty. Response:
{"listening": [{"port": 8080, "protocol": "tcp", "address": "0.0.0.0", "pid": null}]}
pidis usuallynullwithout root privileges.
Lists installed packages. Query parameter manager is pip (default) or npm; other values → 400.
When the command is not found (FileNotFoundError), returns an empty list instead of an error; timeout → 500 {"type": "TimeoutError"}.
{"manager": "pip", "packages": [{"name": "requests", "version": "2.32.0"}]}Real-time resource metrics:
{
"cpu_load_1m": 0.15, "cpu_load_5m": 0.10, "cpu_load_15m": 0.05,
"memory_used_mb": 2048, "memory_total_mb": 8192, "memory_percent": 25.0,
"disk_used_gb": 14.5, "disk_total_gb": 40.0, "disk_percent": 36.3,
"uptime_seconds": 123.4
}uptime_seconds is relative to the process import time (_BOOT_TIME).
Code execution and Git inspection (registered in
routes_devtools.py). Must
default_table().enable_group(CapabilityGroup.DEV_TOOLS) before use, otherwise requests return 404 (group disabled).
Executes code in a subprocess. Request body:
| Field | Default | Description |
|---|---|---|
code |
(required) | Source code string |
language |
python |
python / node / bash; others → 400 |
timeout |
30 |
Seconds; upper limit 300 (_MAX_TIMEOUT), <=0 falls back to 30 |
Language mapping: python→python3 -c, node→node -e, bash→bash -c. Response:
{"stdout": "hello\n", "stderr": "", "exit_code": 0, "language": "python", "execution_time_ms": 42.5}Timeout → 408 {"type": "TimeoutError"}; runtime not found → 500 {"type": "FileNotFoundError"}.
Parses git status --porcelain -b. Query parameter path (default .). Path containing .. segment → 400.
git not installed → 500; timeout → 408; git returns non-zero → 400 {"type": "GitError"}. Response:
{"branch": "main", "clean": false, "files": [{"path": "app.py", "status": "modified"}]}status values: modified/added/deleted/renamed/untracked/copied/unmerged.
Returns git diff text and statistics. Query parameters:
| Parameter | Default | Description |
|---|---|---|
path |
. |
Working directory (containing .. → 400) |
staged |
false |
Uses --staged when true |
file |
(empty) | Restrict to a single file (containing .. → 400) |
Diff text exceeding 1 MB (_MAX_DIFF_BYTES) is truncated with ... (truncated at 1MB) appended. Response:
{"diff": "diff --git ...", "stats": {"files_changed": 3, "insertions": 10, "deletions": 2}}Playwright headless browser control (registered in
routes_browser.py). Must
default_table().enable_group(CapabilityGroup.BROWSER) before use.
Playwright lazy loading and 503 degradation: Playwright is imported and started only on first use (
_get_playwright). Ifplaywrightis not installed, all browser endpoints return503 {"error": "Playwright is not installed. ...", "type": "RuntimeError"}. The page is a process-wide shared singleton (samepage), and operations are serialized by_browser_lock.
Navigates to a URL. Request body:
| Field | Default | Description |
|---|---|---|
url |
(required) | Target URL |
wait_until |
networkidle |
Playwright wait condition |
timeout |
30000 |
Milliseconds; <=0 falls back to 30000 |
Response {"url": "...", "title": "...", "status": 200}.
Takes a screenshot. Request body:
| Field | Default | Description |
|---|---|---|
selector |
null |
If specified, captures that element; element not found → 404 |
full_page |
true |
Full-page screenshot (when no selector) |
format |
png |
png or jpeg (others fall back to png) |
Response {"image_base64": "...", "width": 1280, "height": 720}.
Gets page content. Query parameter type: html (default, page.content()) or text
(page.inner_text("body")). Response {"content": "...", "url": "...", "title": "..."}.
Clicks an element. Request body {"selector": "#btn", "timeout": 5000} (timeout in milliseconds, <=0 falls back to 5000).
Response {"clicked": true, "selector": "#btn"}.
Types text into an element. Request body:
| Field | Default | Description |
|---|---|---|
selector |
(required) | Target selector |
text |
(required) | Input text |
delay |
50 |
Per-character delay (milliseconds), <0 falls back to 50 |
clear |
false |
Whether to clear before typing (page.fill("")) |
Response {"typed": true, "selector": "...", "text": "..."}.
Executes JavaScript. Request body {"script": "document.title", "timeout": 10000}
(timeout in milliseconds, used as default timeout, <=0 falls back to 10000). Response {"result": <JS_return_value>}.
Generates a PDF. Request body {"format": "A4", "landscape": false}. Response
{"pdf_base64": "...", "pages": 1} (pages is a rough count based on /Type /Page occurrences, at least 1).
Returns collected console logs and clears the buffer. Response:
{"logs": [{"type": "log", "text": "hello", "timestamp": 1758412800.0}]}Log buffer limit 1000 entries (_MAX_CONSOLE_LOGS).
Implementation in
pty_ws_handler
and start_pty_server.
The WebSocket service depends on the websockets library and runs on a separate port (default 9001).
ws://<host>:<pty_port>/pty?session_id=<session_id>
session_id must come from a prior POST /pty/sessions. Missing session_id → server closes with code
1008 and message "Missing session_id parameter"; unknown session → 1008 + "Unknown session: <id>".
type |
Fields | Description |
|---|---|---|
input |
data (string) |
Input to write to the shell (UTF-8 encoded) |
resize |
cols, rows (integers) |
Resize terminal; non-integer values cause the message to be ignored |
signal |
signal (string) |
Send signal to the shell process group |
signal value mapping (_SIGNAL_MAP): SIGINT / SIGTERM / SIGKILL / SIGHUP / SIGQUIT;
unrecognized signal names are ignored. Invalid JSON frames are silently discarded.
{"type": "input", "data": "ls -la\n"}
{"type": "resize", "cols": 120, "rows": 40}
{"type": "signal", "signal": "SIGINT"}type |
Fields | Description |
|---|---|---|
output |
data (string) |
PTY output (UTF-8, errors="replace") |
event |
event, session_id, pid? |
Lifecycle events |
event values:
started: Sent immediately after connection, includessession_idandpid.exited: Sent after shell process ends, includessession_id.
{"type": "output", "data": "total 0\n"}
{"type": "event", "event": "started", "session_id": "a1b2c3d4e5f6", "pid": 4321}
{"type": "event", "event": "exited", "session_id": "a1b2c3d4e5f6"}POST /pty/sessionscreates a session, returningsession_idandws_url.- Connect to
ws_url; server pushesstartedevent, then the background read loop continuously forwardsoutput. - Client sends
input/resize/signalmessages. - Shell exit triggers
exitedpush; or callDELETE /pty/sessions/{id}to actively destroy.
Endpoint: POST /shell/stream (PROCESS group). Implementation in
_handle_shell_stream.
Response Content-Type: text/event-stream (SSEResponse),
events flushed one by one as standard SSE frames event: <name>\ndata: <json>\n\n.
event |
data Structure |
Trigger |
|---|---|---|
stdout |
{"data": "<line_text>"} |
Each line read from standard output (trailing \n stripped) |
stderr |
{"data": "<line_text>"} |
After stdout is fully read, lines from standard error |
error |
{"error": "<message>"} |
Process startup failure, or timeout ({"error": "timeout"}) |
exit |
{"exit_code": <int>} |
Process ended (an exit event is also sent after timeout) |
Implementation note: stdout and stderr are read sequentially — all stdout lines first, then stderr — not interleaved in real time. Timeout detection is based on
time.monotonic()within the stdout read loop.
curl -N -X POST http://localhost:9000/shell/stream \
-H "Content-Type: application/json" \
-d '{"command": "echo hello && echo err >&2", "timeout": 30}'event: stdout
data: {"data": "hello"}
event: stderr
data: {"data": "err"}
event: exit
data: {"exit_code": 0}
The Server's routes and commands can be extended by template commands.py without modifying SDK source code. See the complete example at
examples/templates/python-hello/commands.py.
Use the RouteTable.route() decorator (or register()).
Path templates support {param} placeholders, compiled to named capture groups (?P<param>[^/]+), accessible via
request.path_params:
from agent_sandbox.server import CapabilityGroup, ServerResponse, default_table
table = default_table()
@table.route("GET", "/hello/{name}", group=CapabilityGroup.COMMANDS)
def hello_route(request) -> ServerResponse:
name = getattr(request, "path_params", {}).get("name", "World")
return ServerResponse.ok({"greeting": f"Hello, {name}!"})route() / register() keyword arguments: group (required), auth_required (default True),
streaming (default False), name (default "<METHOD> <path>"). Routes match in registration order.
When a route is registered with streaming=True, the handler should return an
SSEResponse whose
event_iterator yields standard SSE frame format strings (refer to the implementation in
routes_process.py):
import json
from agent_sandbox.server import CapabilityGroup, default_table
from agent_sandbox.server.types import SSEResponse, ServerResponse
table = default_table()
@table.route("POST", "/my/stream", group=CapabilityGroup.COMMANDS, streaming=True)
def my_stream(request) -> SSEResponse | ServerResponse:
body = request.body or {}
count = body.get("count", 3)
if not isinstance(count, int) or count <= 0:
return ServerResponse.error(400, "Invalid 'count'", "ValueError")
def event_gen():
for i in range(count):
yield f"event: progress\ndata: {json.dumps({'step': i + 1})}\n\n"
yield f"event: done\ndata: {json.dumps({'total': count})}\n\n"
return SSEResponse(event_iterator=event_gen())The client will receive a Content-Type: text/event-stream response with events flushed one by one.
Setting auth_required=False makes an endpoint bypass authentication even when ABX_SERVER_TOKEN is configured (the built-in
GET /health uses this mode):
from agent_sandbox.server import CapabilityGroup, ServerResponse, default_table
table = default_table()
@table.route("GET", "/public/status", group=CapabilityGroup.COMMANDS, auth_required=False)
def public_status(request) -> ServerResponse:
return ServerResponse.ok({"status": "running", "public": True})Note: Auth-exempt endpoints are still subject to capability group gating; if their capability group is disabled, they return
404.
A named command has one mechanism: register a function with the module-level command decorator. GET /commands lists it and POST /commands/{name} runs it. When args is not explicitly provided, parameter types are inferred from the function signature (int→integer, float→float, bool→boolean, others→string) along with required status (no default value means required):
from agent_sandbox.server import command, start
@command("hello", description="Say hello.")
def hello(name: str = "World") -> str:
return f"Hello, {name}!"
@command(hidden=True) # Executable but not listed in GET /commands
def _debug_info() -> dict:
import platform
return {"python": platform.python_version()}
start(9000)hidden=True: The command can still be executed viaPOST /commands/{name}, but is excluded from theGET /commandslisting.scopes=(...): scopes the caller must hold; a caller without them neither sees nor runs the command.- The registry is frozen when the server starts; register at import time.
- A private
CommandRegistry()can be passed toSandboxServer(registry=...)for tests.
CommandArg's type must be one of string/integer/float/boolean, otherwise the constructor raises ValueError.
Every endpoint that takes a path (file routes, the cwd of /shell and /process/start, git path) resolves it through one PathGuard (guard.py), built from the endpoint's paths rule. The default boundary is ABX_SERVER_BASE_DIR (default /home/user); a policy can set base_dir or per-endpoint paths.allow, paths.deny, paths.read_only and paths.symlinks. The guard realpaths the path, always denies /proc, /sys and /dev, refuses a root of /, and opens files with O_NOFOLLOW on the final component. A violation is 403 {"code": "path_not_allowed"} (or path_denied, read_only, symlink_denied); there is no existence oracle.
/files/list, search and archive skip entries the guard denies and symlinks pointing outside the boundary.
POST /process/{pid}/signalonly allows_ALLOWED_SIGNALS = {2, 9, 15, 18, 20}; and denies signals topid == 1or the Server's own process (403).- WebSocket PTY
signalmessages only accept_SIGNAL_MAPentries:SIGINT/SIGTERM/SIGKILL/SIGHUP/SIGQUIT, targeting the shell process group.
- Redaction (
GET /env): Variables whose names contain_ENV_BLACKLIST_TOKENS(TOKEN/SECRET/KEY/PASSWORD/CREDENTIAL, case-insensitive) are always excluded from the response. - Protection (
POST /env):_PROTECTED_ENV_VARS(PATH/HOME/USER/SHELL/ABX_SERVER_TOKEN) cannot be overwritten; matching triggers403. - Policy mode: the server's own secrets (
ABX_SERVER_*and every configuredtoken_envname) are also removed from the environment of child processes (shell,process_start,code_run, git, PTY) and refused byPOST /env.
| Limit | Value | Location |
|---|---|---|
| shell / code / git commands | shell=False + argument vector execution |
Various handlers |
/shell timeout |
300s | routes.py |
/code/run timeout |
Default 30s, max 300s | routes_devtools.py |
| Upload size limit | 100 MB (ABX_MAX_UPLOAD_SIZE adjustable) |
routes_files.py |
| Archive file count/size | 10000 / 100 MB | routes_files.py |
| Background process limit | 100 | routes_process.py |
| PTY session limit / idle timeout | 10 / 1800s | routes_pty.py |
| Authentication comparison | hmac.compare_digest (constant-time) |
app.py |
| Environment Variable | Default | Purpose | Read Location |
|---|---|---|---|
ABX_SERVER_TOKEN |
(unset = no auth) | Authentication token; when set, requires matching X-Access-Token header |
app.py#L43 |
ABX_SERVER_BASE_DIR |
/home/user |
File endpoint path safety root directory | routes.py#L54-L55 |
ABX_SERVER_ENABLED_GROUPS |
(empty) | Comma-separated groups to turn on (agents,dev_tools); the only group variable |
policy.py |
ABX_PTY_PORT |
9001 |
PTY WebSocket port, also written into ws_url |
app.py#L247 / routes_pty.py#L362 |
ABX_MAX_UPLOAD_SIZE |
104857600 (100 MB) |
/files/upload-stream upload byte limit |
routes_files.py#L39 |
The HTTP port is not controlled by an environment variable; it is a
start(port=...)/SandboxServer.serve(port=...)parameter (default9000).
Design and rationale: fine-grained-capabilities.md; review log: fine-grained-capabilities-review.md. An unconfigured server runs the same engine with the default policy (opt-in groups off, paths under the base directory).
First match wins: SandboxServer(policy=...) / serve --policy FILE --preset NAME ->
ABX_SERVER_POLICY (inline JSON) -> ABX_SERVER_POLICY_FILE ->
/etc/agent-sandbox/server-policy.json -> none (the empty policy; the engine still runs). The template.yaml server: block is the
same document, compiled to JSON by tooling. Unknown keys and unknown endpoint ids are startup
errors with a did-you-mean hint. Validate with agent-sandbox-server check-config --policy FILE [--strict] [--json] [--explain ENDPOINT --path P --scopes S --op read].
server:
preset: transfer-only # default | transfer-only | read-only | hardened | locked
base_dir: /home/user
endpoints:
files_download_stream:
paths: { allow: [/home/user/out], deny: ["**/.git/**"] }
limits: { max_bytes: 5MB, rate_per_minute: 60 }
files_upload_stream:
paths: { allow: [/home/user/in] }
params: { overwrite: false, deny_extensions: [.sh, .so] }
limits: { max_bytes: 20MB }
auth:
tokens:
- { id: ci, token_env: CI_TOKEN, scopes: ["files:read"] }
- { id: admin, token_sha256: "<hex>", scopes: ["*"] }Rule keys per endpoint (or in defaults): enabled, scopes, paths{allow, deny, read_only, symlinks}, limits{max_bytes, max_request_bytes, max_entries, max_duration_seconds, max_output_bytes, timeout_seconds, rate_per_minute, max_concurrency}, params{overwrite, allow_extensions, deny_extensions, allow_commands, deny_commands, languages, allowed_signals, url_allow, url_deny, block_private_networks, shells, env_deny, allow_vars, deny_vars}, audit
(off|deny|all). Sizes accept 5MB, 1GiB.
Identities hold scopes (files:read, files:write, files:delete, process:run,
process:manage, terminal:use, system:read, system:write, devtools:run, browser:use,
commands:run, commands:run:<name>, core:read (implicit), *). Each endpoint requires a
documented scope (files_move requires files:write and files:delete); a rule's scopes
overrides it. If no explicit token list is configured, ABX_SERVER_TOKEN supplies a current policy token with identity server and scope *.
Bodies keep {"error", "type"} and add code (+ endpoint, scope, limit, retry_after):
| HTTP | type |
code |
|---|---|---|
| 400 | ValueError |
ambiguous_credentials, duplicate_parameter, unsupported_encoding (a malformed Content-Length is a plain 400 without code) |
| 401 | AuthError |
unauthorized |
| 403 | PolicyDenied |
missing_scope, path_not_allowed, path_denied, read_only, symlink_denied, command_not_allowed, language_not_allowed, signal_not_allowed, shell_not_allowed, url_denied, extension_not_allowed, env_var_not_allowed, env_var_denied, process_denied |
| 404 | ValueError |
endpoint_disabled (identical to "unknown route" for callers lacking discovery rights) |
| 409 | FileExistsError |
file_exists (upload with overwrite: false) |
| 413 | ValueError |
payload_too_large (+ limit) |
| 429 | RateLimited |
rate_limited, concurrency_limited (+ Retry-After) |
A disabled endpoint always carries code: endpoint_disabled and rule, whether or not a policy file is loaded.
GET /capabilities (v2 fields): api_version: 2, server_version, identity {id, scopes},
policy {preset, revision}, groups and group_summary (derived), endpoints{name -> {method, path, group, summary, streaming, enabled, allowed, reason, rule, scopes, limits, paths, params}}, warnings. POST /capabilities/check with
{"endpoint", "path"?, "op"?, "paths"?} returns the policy decision for a hypothetical call; it is
policy-only and never touches the filesystem (no existence oracle).
| Endpoint | Group | Notes |
|---|---|---|
GET /files/watch?path=&recursive=&interval=&timeout= |
FILE_OPS | SSE: ready, change{type: created|modified|deleted, path, is_dir}, end; polling, bounded by limits.max_duration_seconds |
POST /process/{pid}/stdin |
PROCESS | body {data | data_base64, eof}; process must be started with stdin: true (409 otherwise) |
GET /process/{pid}/output?since=&wait= |
PROCESS | cursor-based events {seq, stream, data}, next, state, exit_code, dropped; needs capture_output: true |
GET /pty/sessions/{id} |
TERMINAL | session metadata |
POST /pty/sessions/{id}/resize |
TERMINAL | {cols, rows} (1..1000) |
POST /capabilities/check |
CORE | see 10.4 |
POST /shell, /shell/stream |
PROCESS | additive cwd, env, timeout parameters |
POST /process/start |
PROCESS | additive env, capture_output, stdin |
The full list, with the 70 Agents routes, is in the appendix.
agent-sandbox-server serve [--host --port --pty-port --commands FILE|MODULE --policy FILE --preset NAME --base-dir DIR], healthcheck [--host --port --timeout] (exit 0/1, for ready_cmd and Docker
HEALTHCHECK), check-config, version. Base image contract:
base-image-and-server-sdk.md.
The declaration table endpoints.py has 118 endpoints: 117 HTTP routes and the PTY WebSocket (pty_connect, ws://host:9001/pty?session_id=...).
| Capability Group | Endpoints | Endpoint List |
|---|---|---|
| CORE | 3 | GET /health, GET /capabilities, POST /capabilities/check |
| COMMANDS | 2 | GET /commands, POST /commands/{name} |
| FILE_OPS | 12 | POST /upload, GET /download, GET /files/list, GET /files/stat, POST /files/mkdir, DELETE /files, POST /files/move, POST /files/search, POST /files/upload-stream, GET /files/download-stream, POST /files/archive, GET /files/watch |
| PROCESS | 8 | POST /shell, POST /shell/stream, POST /process/start, GET /process/list, GET /process/{pid}, POST /process/{pid}/signal, POST /process/{pid}/stdin, GET /process/{pid}/output |
| TERMINAL | 6 | POST /pty/sessions, GET /pty/sessions, GET /pty/sessions/{id}, POST /pty/sessions/{id}/resize, DELETE /pty/sessions/{id}, WS /pty |
| SYSTEM | 6 | GET /system/info, GET /env, POST /env, GET /ports, GET /packages, GET /system/metrics |
| DEV_TOOLS | 3 | POST /code/run, GET /git/status, GET /git/diff |
| BROWSER | 8 | POST /browser/navigate, POST /browser/screenshot, GET /browser/content, POST /browser/click, POST /browser/type, POST /browser/evaluate, POST /browser/pdf, GET /browser/console |
| AGENTS | 70 | 70 routes under /v1/agents/..., /v1/vaults, /v1/responses, /v1/models, /v1/files, /v1/webhook_endpoints; see the Agents API reference |