Agent Sandbox Registry

python-sdk

02 - Operation model

docs/en/archive/server-sdk-v2/02-operation-model.md

02 - Operation model

Status: archive. Not the current specification.

Status: target design, not implemented. The shipped server uses endpoint ids and one policy engine; see README section 1 for what exists and section 2 for what does not.

Part of Server SDK v2. Rule R1/R2/R5 of 01-principles.md. v2.1: grammar widened to five segments, default effects admin, one 404 body, new pipeline order, ops/ package renamed builtin/, legacy name mangling table. Changes are marked (v2.1); see README section 4a.

1. Summary

Every capability of the server is an operation registered in one Registry under a dotted ID. An operation is a Python callable plus metadata (effects, parameter schema derived from type annotations, declared path/command parameters, limits, HTTP bindings, install requirements). The registry is the single source for dispatch, policy, discovery, OpenAPI, generated clients and docs.

from agent_sandbox.server import ops                  # attribute tree, IDs as attributes

ops.files.list              # -> files.list              (autocomplete via generated _ops.pyi)
ops.agents.codex.run        # -> agents.codex.run
ops.custom["format"]        # -> custom.format           (item access always works)
str(ops.files.list)         # 'files.list'
ops.files.list == "files.list"   # True; hashable; usable wherever a selector is expected

ops nodes are not str subclasses: a str subclass would shadow verbs that are str methods (ops.custom.format, .split, .index, .replace, .count would return bound methods). The spike found this bug in its first design (review round 1, F1.1); OpRef fixes it.

2. ID grammar and namespace rules

op_id   := segment ("." segment){1,4}      # (v2.1) was {1,3}; the official subagent routes need five segments
segment := [a-z][a-z0-9_]*          # lowercase, digits, underscore; no hyphen, no dot, no uppercase

3. The @op decorator

from agent_sandbox.server import op

@op("custom.grade",                       # id (required, validated)
    effects="exec",                       # str | tuple[str]; subset of read write delete exec net admin. OMITTED = admin (v2.1)
    summary="Grade a submission",         # default: first docstring line
    http="POST /grade",                   # str | list[str]; optional REST binding(s); RPC always exists
    paths=("workdir",),                   # params that are filesystem paths  -> path policy applies
    command=None,                         # the param that holds a command line/argv -> command policy
    limits=("timeout", "max_output_bytes"),   # limit keys this op honours (documented in catalog)
    group=None,                           # optional legacy group membership
    install=(),                           # install requirement names (manifests), see 06
    stream="none",                        # none | sse | ndjson | bytes | ws
    since="2.0",
    aliases=())
def grade(ctx, submission: str, workdir: str = "/workspace", timeout: int = 60) -> dict:
    """Grade a submission with the course grader."""
    ...

Rules:

ctx (what a handler may rely on)

Attribute Meaning
ctx.op the Operation; it compares equal to its ID string (ctx.op == "files.upload") and str(ctx.op) is the ID
ctx.principal Principal(id, roles, claims)
ctx.paths {param: canonical_path} for declared path params (policy-approved; always defined, see scope-required above)
ctx.server, ctx.meta, ctx.env_overlay (v2.1) the server, request metadata (headers already normalised, request_id, peer) and the per-server environment overlay that system.env.set writes (never os.environ)
ctx.scope the admitted PathScope (for open_beneath, ctx.recheck(p) on listings)
ctx.argv, ctx.executable, ctx.env, ctx.cwd admitted command invocation (for command ops)
ctx.limits effective Limits for this call (min over op default, rule, hook narrowing, operator layer)
ctx.request_id, ctx.deadline tracing id; absolute deadline derived from limits.timeout
ctx.open(path, mode) race-free open of an admitted path (no symlink following beneath the scope root)
ctx.out stream writer for stream != "none" ops; enforces max_bytes/max_output_bytes
ctx.log, ctx.audit(**extra) structured logging / extra audit fields
ctx.run(argv, timeout=, cwd=) the sanctioned subprocess helper for custom ops: no shell, scrubbed env, rlimits and process-group handling from the same launcher as commands.run; returns exit_code, stdout, stderr
ctx.host server-wide services (kv, otel, installer, registry)

4. Registry

class Registry:
    def op(...)                     # the decorator (same arguments as module-level `op`)
    def get(self, id) -> Operation
    def resolve(self, name) -> Operation | None      # id or alias
    def all(self) -> dict[str, Operation]
    def namespaces(self) -> dict[str, list[str]]
    def describe(self, *selectors, principal=None, policy=None) -> list[dict]
    def freeze(self)                # after start: registration raises RegistryFrozen
    def catalog(self) -> dict       # the operations.json document
    def openapi(self) -> dict

5. Bindings: how HTTP method and path are derived

An operation has zero or more REST bindings and always has the RPC binding:

Binding Shape Source
RPC (always) POST /ops/{id}; JSON body = arguments; result = JSON (or the op's stream/bytes) derived: nothing to configure
REST (optional) GET /files/list?path=... etc. @op(http=...); every built-in lists its legacy binding in the table of 04
Wire alias (agents) /v1/agents/sessions/{id}/events etc. resolver functions in agents/wire_*.py, see 05

Rules:

  1. Method/path is derived from the registry, never typed twice. The only place a REST path is written is the http= of the @op that owns it. The route table, OpenAPI, the generated clients' http metadata and the tables in 04 are generated. A CI drift test regenerates catalog/operations.json, catalog/openapi.json, catalog/_ops.pyi and 04-builtin-modules.md tables and fails on diff.
  2. Argument placement for REST bindings is mechanical: {name} path variables first; then for GET, DELETE all other parameters come from the query string (typed by the schema: ?recursive=true, ?max_entries=100, repeated keys for arrays); for POST, PUT, PATCH they come from a JSON body (the few binary-upload ops declare stream="bytes" and put non-body parameters in the query string).
  3. Conflict detection. Registration builds a trie router. Two bindings that can match the same request (GET /process/{pid} vs GET /process/list) are resolved by literal-before-variable specificity; two bindings that are equally specific are a registration error. This replaces the registration-order linear scan (and its "register the literal route first" hazard) of the legacy RouteTable.
  4. Method not allowed is 405 with an Allow header; unknown path 404 op.unknown_route.
  5. Aliases at the routing layer (v2.1, C-3). The agents wire formats route the generic OpenAI paths (POST /v1/agents/sessions, ...) to the generic ops (agents.sessions.create, ...), which then also require the adapter op agents.<a>.sessions.create (two-level gate, 05 section 4). The adapter <a> is chosen explicitly, in this order: (1) path prefix /agents/<a>/v1/... (unknown adapter: 404); (2) header X-ABX-Adapter; (3) a model string that equals an adapter name (codex, claude_code, claude-code); (4) config agents.default; (5) the only adapter mounted for the principal; (6) otherwise 400 agents.adapter_required (or 404 op.not_enabled when none is mounted). Real model names (gpt-5-codex, codex-mini) never select an adapter and never 404: they pass through as the upstream LLM model. The resolver runs after authentication (never on unauthenticated bytes), so authorization always happens on concrete op IDs. A selected but unmounted adapter is 404 op.not_enabled (adapters cannot be enumerated).
  6. Streaming and binary are properties of the op: stream in none | sse | ndjson | bytes | ws. SSE frames use event:/data: with JSON data and id: for resumable streams. ws ops (terminal) are bound to the PTY WebSocket listener (port 9001) with the same authentication and the same pipeline (the upgrade is the policed call; the policy decision is cached for the socket's lifetime).

6. The request pipeline

The only code path from bytes to handler (core/dispatch.py). Steps 0-10 are normative (reordered in review round 4: authentication precedes alias resolution and every 404; v2.1 adds step 0 and the transport order from the independent security review); each has a test in README.

 0  transport    (HTTP listener, before the pipeline; order is fixed)
                 a. socket header/body read timeout 3 s per read, bounded pre-auth connection pool (503 request.busy; core.health has a reserved slot)  [IS-A3, IF-19]
                 b. request target taken from the raw request line: any of `//`, `/./`, `/../`, `%2f`, `%2e`, `%5c`, backslash, NUL, control chars -> 400 request.bad_target  [IS-A8]
                 c. Host must be loopback or in allowed_hosts; Origin (when present) must be in allowed_origins -> 403 request.host_denied / request.origin_denied  [IS-A2]
 1  parse        method, path, query, headers; size cap via Content-Length BEFORE reading (413 limit.request_too_large); a body needs
                 `Content-Type: application/json` (415 request.bad_content_type) so a cross-origin "simple" POST cannot reach a handler;  body read
 2  route        trie lookup on method+path only -> candidate (op_id, args_from_path) | alias family | "no route"
 3  authenticate credentials -> Principal. `Authorization: Bearer` wins, `X-Access-Token` is the legacy alias; both present and different -> 401
                 auth.conflicting_credentials; failed attempts are rate limited per credential fingerprint (429 auth.rate_limited). 401 comes BEFORE any
                 404/405 so unauthenticated callers cannot probe routes (exception: core.health is anonymous by default)
 4  resolve      alias resolvers may now peek at the (size-capped) body; select the adapter (rule 5 above); "no route" or unknown id -> 404 op.not_enabled
 5  mounted?     any allow rule matching (principal, op)?  else 404 op.not_enabled   [no oracle: the same bytes as for an unknown id; the call is audited]
 6  args         JSON decode, validate against schema, coerce query types, `limit`-style args must be int >= 1   (400 args.*; `command` xor `argv` -> args.invalid)
 7  policy       deny rules (every selector, incl. explicit-only ops, canonical paths, relative args checked against every anchor) -> allow statements
                 (scope required; paths/commands/urls/agent scopes; limits) -> hooks (veto/narrow only)      (403 policy.* / 413 / 429)
 8  admit        concurrency/rate tokens, effective limits, deadline; lazy-install `ensure` for declared requirements (06) under the install policy
 9  run          handler(ctx, **args)    # ctx.paths/ctx.argv are the *canonical, admitted* values
10  audit+render audit record (always, including unmounted and denied calls), wire renderer (legacy | openai) -> response

Notes that matter:

7. Discovery (all generated from the registry)

Surface Content
GET /capabilities (v2.1, IF-26) v2 keys api, catalog_version, revision (policy hash), principal, ops (the ops effective for the caller with limits/scopes), policy (preset names, lint messages if the caller is admin) plus a compat projection for two minors: the in-flight keys api_version, endpoints{}, identity, groups, warnings, generated from the same data (no second source). The client detects, in order: ops -> endpoints -> groups (07 section 3)
GET /ops and POST /ops/core.describe the full catalog for the caller (?all=1 includes unmounted ops with the reason; admin only); each entry as in 7.1
GET /openapi.json OpenAPI 3.1; one path item per REST binding; operationId = op ID; x-abx-effects, x-abx-limits; plus the RPC paths POST /ops/{id}
python -m agent_sandbox.server describe [--json|--markdown|--ids] / server.describe() same data offline, no server needed (introspection mode, see 06)
agent_sandbox/server/catalog/_ops.pyi ops attribute tree with every built-in ID, so editors autocomplete ops.files.<TAB>
POST /ops/core.check "would this call be allowed?" without running it: {"op": "files.download", "args": {"path": "/etc/passwd"}} -> {"allowed": false, "code": "policy.path_not_allowed"} (never reveals rule contents to non-admins; replaces POST /capabilities/check)

7.1 Catalog entry (operations.json)

{
  "id": "files.download",
  "summary": "Download a file.",
  "effects": ["read"],
  "group": "file_ops",
  "http": [{"method": "GET", "path": "/files/download-stream", "legacy": true},
           {"method": "GET", "path": "/download", "legacy": true}],
  "rpc": "/ops/files.download",
  "params": [{"name": "path", "type": "string", "required": true, "kind": "path", "doc": "File to read"}],
  "result": {"kind": "bytes", "content_type": "application/octet-stream"},
  "limits": ["max_bytes", "rate", "concurrency"],
  "stream": "bytes",
  "install": [],
  "since": "1.0",
  "aliases": ["files_download_stream", "download"],
  "deprecated": null
}

operations.json is committed under server-sdk/src/agent_sandbox/server/catalog/, shipped in the wheel, and copied (generated) into the client wheel and the Node CLI (see 07).

8. Custom operations: the full answer

  1. Python decorator (above). Registered into the server's registry; visible in /capabilities, OpenAPI, describe, and addressable by policy exactly like built-ins (allow(ops.custom.grade, ...) or allow("grader.*", principals=["role:grader"])).
  2. Schema comes from annotations; return annotation (TypedDict/dataclass) becomes result schema.
  3. Policy for a custom op is the same engine. Because the engine cannot see inside a handler, the author declares paths=/command=/effects=; the handler then receives admitted values. For anything the engine cannot express, write an authorize hook (03 section 8) or check inside the handler and raise OpError.
  4. Client stub generation.
  5. Zero-step: ServerClient builds its attribute tree dynamically from GET /ops (client.custom.grade(...), client.call("custom.grade", submission=...)), with type checking at call time against the fetched schema.
  6. One step: agent-sandbox-server gen-client --lang python --module my_template_ops writes a typed module (TypedDict results, keyword-only parameters, docstrings) importable by the application; --lang ts writes a TypeScript module for the Node CLI/JS apps; --lang openapi writes the OpenAPI file. Generation reads the registry offline (introspection mode), so it works in CI without a running sandbox.
  7. Legacy extension forms keep working and are mapped to operations:
Legacy New identity Notes
registry.command("name", ...) / CommandRegistry custom.<name>; POST /commands/{name} resolves to it; GET /commands = custom.list command names list and describe are reserved
table.route(method, path, group=...) ext.<handler_name> (de-duplicated with _2), binding = the given method/path group becomes group: membership; effects default to {"admin"} unless effects= is passed (fail-closed default for unknown code)
CapabilityGroup.X group:x selector golden list in 04
  1. Third-party packages can ship ops (load:), including their own lazy-install requirements (install=("my-tool",) referencing a manifest file shipped by the package).

9. Versioning and compatibility of the identity space

10. Implementation notes