Agent Sandbox Registry

python-sdk

07 - Client mirror: Python ServerClient and the Node CLI

docs/en/archive/server-sdk-v2/07-client-mirror.md

07 - Client mirror: Python ServerClient and the Node CLI

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 R5. Supersedes client-server-alignment.md sections on naming/errors (its gap analysis of envd vs server remains valid input). v2.1: ONE client error range (E3100-E3111, E9xxx is dropped, in-flight class names kept), a wire-code -> exception table, Authorization vs X-Access-Token precedence, a machine-checkable parity ledger covering every op (including the 18 ops, browser.* and ext.* the feasibility review found missing), Sandbox.server versus the declarative sandbox.server.start namespace. Findings: IF-07, IF-10, IF-11, IF-17, IF-26, IF-27, IF-28, N-3, N-8.

1. Principle

The client does not hand-write a method per endpoint. Both clients are generated from the server catalog (operations.json, 02 section 7.1), have the same namespace tree as the server, and are checked by contract tests that replay every catalog operation against a real server.

sb = await Sandbox.connect(sandbox_id)          # existing
srv = sb.server                                  # ServerClient bound to the sandbox's port 9000 (gateway headers added)

await srv.files.list("/tmp")                     # op files.list
data = await srv.files.download("/tmp/a.txt")    # bytes (decoded); op files.download
await srv.commands.run("ls /tmp")                # op commands.run -> CommandResult-like
await srv.agents.codex.run("fix the failing test")           # op agents.codex.run
sess = await srv.agents.codex.sessions.create(environment={"type": "self_hosted", "id": "env_local", "remote_url": srv.base_url},
                                              input="fix the failing test")      # same shape as the official SDK (05 section 5); no `workspace=` shorthand
await srv.custom.grade(submission="...")         # user-defined op, dynamic tree from GET /ops
await srv.call("custom.grade", submission="...") # generic escape hatch (also takes ops.* nodes)

2. Generation and drift control

server registry --(agent-sandbox-server gen-client --lang python)--> client-sdk/src/agent_sandbox/api/server/_generated.py
                --(--lang ts)------------------------------------> cli/src/server/generated/catalog.ts, client.ts
                --(--lang openapi)-------------------------------> catalog/openapi.json (shipped, also a release asset)
                --(describe --markdown)--------------------------> tables in 04-builtin-modules.md

3. Capability awareness (no hidden traffic)

caps = await srv.discover()                 # explicit GET /capabilities (never implicit)
caps.can(ops.files.download)                # bool, for this principal
caps.limit(ops.files.download, "max_bytes") # effective limit or None
caps.paths(ops.files.download)              # PathScope summary (roots, readonly) if the server exposes scopes
caps.require(ops.files.upload)              # raises OperationNotEnabledError (E3103) before any network call
await caps.check(ops.files.download, path="/etc/passwd")   # server-side dry-run: core.check
caps.legacy                                 # True for pre-v2 servers (no `ops` key): groups mapped through the golden lists in 04

4. Errors: ONE range, E3100-E3111 (v2.1, IF-10)

The repository has three schemes today: the working tree uses E9000-E9005 (models/errors.py, header "E9xxx = In-sandbox server API errors"), the in-flight capability design proposed E8xxx, and v2.0 proposed E3100-E3111. Decision (D29): the server plane uses E3100-E3111 only, next to the execution family (E3000-E3006); E8000-E8030 stays exclusively with the providers package (providers/), E9xxx is removed from every plan and document.

Code Class (existing in-flight name kept) Wire code / status Also subclasses (compat) Replaces
E3100 ServerAPIError (base), ServerArgumentError(ServerAPIError, ValueError) any unmapped, args.* 400 SandboxError E9000
E3101 ServerAuthError auth.* 401, 429 auth.rate_limited, auth.conflicting_credentials AuthenticationError E9001
E3102 PolicyDeniedError, FilePolicyDeniedError policy.* 403 (incl. policy.scope_required, policy.path_*, policy.command_*, policy.url_*), request.host_denied, request.origin_denied PermissionDeniedError (file variant) E9002
E3103 EndpointNotAvailableError (alias OperationNotEnabledError) op.not_enabled 404, op.unknown_route, agents.not_found on unmounted adapters CapabilityNotSupportedError (E3004) E9003
E3104 PayloadTooLargeError limit.max_bytes, limit.request_too_large 413 E9004
E3105 RateLimitedError limit.rate, limit.concurrency 429 (retry_after) E9005
E3106 ServerInstallError install.* (phase, retry_after, job_id; 202 is a result, not an error) new
E3107 ServerAgentError agents.* new
E3108 ServerUnavailableError server.unavailable, request.busy, connection errors, 502/503 without code ConnectionError_ new
E3109 ServerInternalError server.internal 500 new
E3110 ContractMismatchError raised client-side: op unknown to the server's catalog while the generated client has it new
E3111 ServerConfigError raised client-side: strict-mode misuse, ambiguous credentials configured locally, bad ServerClient(...) arguments ValueError new

Attributes (kept + extended). The working tree's ServerAPIError(status_code, error_type, error_code, endpoint) is kept unchanged (existing user code reads them); v2.1 adds op (the op id; equals endpoint for in-flight servers), request_id, details (dict), status (alias of status_code). The attribute .code remains the E-code ("E3102"), never the wire code; the wire code is .error_code. This removes the v2.0 clash of .code meaning two things.

Wire code -> exception (N-8). Most specific first:

Wire code (03 section 11) HTTP Exception E-code
auth.required, auth.invalid, auth.conflicting_credentials 401 ServerAuthError E3101
auth.rate_limited, limit.rate, limit.concurrency 429 RateLimitedError (retry_after) E3105 / E3101 for auth.rate_limited
op.not_enabled, op.unknown_route 404 EndpointNotAvailableError E3103
args.* 400 ServerArgumentError E3100
policy.scope_required, policy.denied_by_rule, policy.denied_by_hook, policy.hook_error, policy.hook_bad_return, policy.not_permitted 403 PolicyDeniedError E3102
policy.path_*, policy.symlink_denied, policy.hardlink_denied, policy.not_regular_file, policy.read_only 403 FilePolicyDeniedError E3102
policy.command_*, policy.executable_not_allowed, policy.shell_not_allowed, policy.env_var_denied, policy.url_* 403 PolicyDeniedError E3102
request.host_denied, request.origin_denied, request.bad_target, request.bad_content_type 403 / 400 / 415 PolicyDeniedError (403) / ServerArgumentError E3102 / E3100
limit.max_bytes, limit.request_too_large 413 PayloadTooLargeError (limit) E3104
install.* (non-202) 424 / 502 / 503 ServerInstallError E3106
agents.*, OpenAI-wire error.type 4xx/5xx ServerAgentError E3107
request.busy, server.unavailable, server.shutting_down, transport failure 503 ServerUnavailableError E3108
server.internal 500 ServerInternalError E3109
legacy v1 body without code any by status: 401 auth, 403 policy, 404 endpoint_disabled -> E3103, 413, 429, else base by status

What in-flight code must change (hand-over list): 1. client-sdk/src/agent_sandbox/models/errors.py: header comment E9xxx -> E31xx; code = "E9000..E9005" -> "E3100..E3105"; add ServerInstallError, ServerAgentError, ServerUnavailableError, ServerInternalError, ContractMismatchError, ServerConfigError, ServerArgumentError; add op, request_id, details, status; export in models/__init__.py. 2. client-sdk/src/agent_sandbox/api/server_client.py (error_from_response): map by the table above (prefers the wire code, falls back to the legacy type/status rules). 3. Tests: tests/test_api/server_e2e/test_server_client_models.py (2 hits), test_server_client_policy.py (1 hit): E9xxx -> E31xx. 4. Docs (en and zh pairs): reference/error-codes.md (9), reference/api-reference.md (4), design/fine-grained-capabilities.md (E8xxx text, 5), design/client-server-alignment.md, design/cli-followups.md, root CHANGELOG.md entry. 5. CLI (/workspace/cli): exit-code mapping uses the wire family, not E-codes; nothing to rename. 6. fine-grained-capabilities.md section 7 proposes E8000-E8005: superseded banner; the providers keep E8000-E8030 untouched. A repository test (tests/test_error_codes_unique.py) asserts that no two classes share an E-code except documented subclass pairs and that no class outside providers/ uses E8xxx and none uses E9xxx.

Specific legacy mappings are preserved: files.* 404 -> FileNotFoundError_; commands.run timeout -> CommandTimeoutError (E3001); unknown named command -> CommandNotFoundError (E3005, also EndpointNotAvailableError); non-zero exit remains a result, not an exception (as in ProcessError only when the caller asks check=True). 202 install.job is a result: the generated client polls it with srv.agents.<a>.install.wait(job) or surfaces InstallPending(job_id, retry_after) when wait=False.

5. Sandbox integration

6. Parity: client counterpart of every server op (v2.1, IF-27)

Rule: every op has a counterpart on the generated ServerClient tree (same dotted path; R5). Where the experience differs (stream, raw bytes, admin guard, dynamic, reserved, refused) the row says so in Kind and, where it matters, carries an asymmetry id (A1..A7, listed below). The ledger would be tests/contract/client_parity.json, generated by make gen and checked by a drift test. The 18 ops the feasibility review found without a client (files.search, files.upload_stream, files.download_stream, files.archive, process.get, process.output, terminal.list, terminal.get, system.info, system.env.get, system.env.set, system.ports, system.packages, devtools.git.status, devtools.git.diff, core.openapi, core.reload, core.describe), all of browser.* and ext.* are rows below.

6.1 Documented asymmetries

ID Asymmetry Reason
A1 core.openapi returns raw JSON, not a typed result documentation asset
A2 core.reload, system.env.set, process.signal, terminal.create, agents.<a>.install, agents.<a>.sessions.create are explicit-only on the server; the client exposes them but a call to an unmounted one is the normal EndpointNotAvailableError policy, not client, decides
A3 system.* has no envd twin; envd metrics is the platform plane planes stay separate (no hidden fallback)
A4 devtools.code.run is a different engine than code.run on port 49999 (no contexts) documented in 04
A5 agents.environments.files.* reserved ids: no client method in 0.3.0 reserved for policy addressing only (T-4)
A6 official subagents.{retrieve,list_items,turns.*}: the client method raises NotSupportedError locally, the server answers 404 not_found_error tier 3 refusal (05 section 4)
A7 custom.<name>, ext.<handler>: no generated method; reached through the dynamic tree from GET /ops or srv.call(id, **args) ids are decided by the template author

6.2 Plane map (envd vs server)

The two planes keep their own method names (sandbox.files.make_dir on envd, sandbox.server.files.mkdir on the server plane, the latter always equal to the op ID verb); the envd twin column is the map. Applications choose the plane explicitly; there is no transparent fallback between planes (hidden fallbacks would make "was this call policy-checked?" unanswerable). Not mirrored on the server by design: network, pause/resume, timeout, templates (platform plane), envd signed file URLs (envd only).

6.3 Ledger (generated; do not edit by hand)

Op id Client call (generated, same tree) envd twin Kind Note / asymmetry
agents.codex.install srv.agents.codex.install() - admin explicit-only; 202 + job
agents.codex.run srv.agents.codex.run(prompt, ...) - typed AgentScope required
agents.codex.sessions.create srv.agents.codex.sessions.create(...) - typed exec-effect op; AgentScope required
agents.codex.sessions.send_event srv.agents.codex.sessions.send_event(id, event) - typed exec-effect op
agents.definitions.create srv.agents.definitions.create(...) - typed official agents.create
agents.definitions.delete srv.agents.definitions.delete(id) - typed
agents.definitions.get srv.agents.definitions.get(id) - typed
agents.definitions.list srv.agents.definitions.list(**page) - typed list envelope; auto-paging iterator
agents.definitions.update srv.agents.definitions.update(id, ...) - typed
agents.environments.create srv.agents.environments.create(...) - typed
agents.environments.files.create - - reserved reserved id (T-4); not implemented in 0.3.0 (asymmetry A5)
agents.environments.files.list - - reserved reserved id (T-4); not implemented in 0.3.0 (asymmetry A5)
agents.environments.get srv.agents.environments.get(id) - typed
agents.environments.list srv.agents.environments.list(**page) - typed
agents.models.list srv.agents.models.list() - typed
agents.runtimes.doctor srv.agents.runtimes.doctor() - typed
agents.runtimes.list srv.agents.runtimes.list() - typed was agents.list (N-1); flags experimental adapters
agents.sessions.artifacts.content srv.agents.sessions.artifacts.content(id, aid) - raw bytes
agents.sessions.artifacts.delete srv.agents.sessions.artifacts.delete(id, aid) - typed
agents.sessions.artifacts.get srv.agents.sessions.artifacts.get(id, aid) - typed
agents.sessions.artifacts.list srv.agents.sessions.artifacts.list(id) - typed
agents.sessions.create srv.agents.sessions.create(...) - typed two-level gate with agents..sessions.create
agents.sessions.delete srv.agents.sessions.delete(id) - typed
agents.sessions.get srv.agents.sessions.get(id) - typed
agents.sessions.list srv.agents.sessions.list(**page) - typed
agents.sessions.list_items srv.agents.sessions.list_items(id, **page) - typed
agents.sessions.send_event srv.agents.sessions.send_event(id, event) - typed official events.create (202, N-2)
agents.sessions.stream_events srv.agents.sessions.stream_events(id) - stream SSE; idle-open
agents.sessions.subagents.get raises NotSupportedError - refused server 404 not_found_error (asymmetry A6)
agents.sessions.subagents.list srv.agents.sessions.subagents.list(id) - typed empty list envelope
agents.sessions.subagents.list_items raises NotSupportedError - refused asymmetry A6
agents.sessions.subagents.turns.get raises NotSupportedError - refused asymmetry A6
agents.sessions.subagents.turns.list raises NotSupportedError - refused asymmetry A6
agents.sessions.subagents.turns.list_items raises NotSupportedError - refused asymmetry A6
agents.sessions.traces.list srv.agents.sessions.traces.list(id) - typed empty list envelope in 0.3.0
agents.sessions.turns.get srv.agents.sessions.turns.get(id, turn_id) - typed
agents.sessions.turns.list srv.agents.sessions.turns.list(id) - typed
agents.sessions.turns.list_items srv.agents.sessions.turns.list_items(id, turn_id) - typed
agents.sessions.update srv.agents.sessions.update(id, ...) - typed
browser.click srv.browser.click(selector) - typed
browser.console srv.browser.console() - typed
browser.content srv.browser.content(type) - typed
browser.evaluate srv.browser.evaluate(script) - typed
browser.navigate srv.browser.navigate(url) - typed UrlScope-checked
browser.pdf srv.browser.pdf() - raw bytes
browser.screenshot srv.browser.screenshot() - raw bytes
browser.type srv.browser.type(selector, text) - typed
commands.run srv.commands.run(command|argv) commands.run typed in-flight alias srv.process.shell is a deprecated alias
commands.stream srv.commands.stream(command|argv) commands.stream stream in-flight alias srv.process.shell_stream
core.capabilities srv.discover() / srv.capabilities() - typed never implicit (07 section 3)
core.check caps.check(op, **args) - typed dry-run; explain is the offline CLI twin
core.describe srv.describe(), srv.ops - typed
core.health srv.core.health() - typed anonymous; used by readiness
core.openapi srv.core.openapi() - raw documentation asset; no typed result (asymmetry A1)
core.reload srv.core.reload() - admin explicit-only admin op; exposed but never in a convenience group (asymmetry A2)
custom.list srv.custom.list() list_commands() typed
devtools.code.run srv.devtools.code.run(language, code) code.run typed different engine than port 49999 (asymmetry A4)
devtools.git.diff srv.devtools.git.diff(path) - typed
devtools.git.status srv.devtools.git.status(path) - typed
files.archive srv.files.archive(paths) - typed server-only
files.delete srv.files.delete(path) files.remove typed
files.download srv.files.download(path) files.read_bytes typed
files.download_stream srv.files.download_file(remote, local) files.download_url (signed) stream chunked body
files.list srv.files.list(path) files.list typed
files.mkdir srv.files.mkdir(path) files.make_dir typed
files.move srv.files.move(source, destination) files.rename typed
files.search srv.files.search(root, pattern) - typed server-only convenience; envd has no search
files.stat srv.files.stat(path), exists() files.get_info/exists typed
files.upload srv.files.upload(path, data) files.write typed base64-JSON; large files are better on envd
files.upload_stream srv.files.upload_file(local, remote) files.upload_url (signed) stream chunked body; envd uses signed URLs
files.watch srv.files.watch(path) files.watch stream SSE
process.get srv.process.get(pid) - typed server-only; envd uses a handle
process.list srv.process.list() commands.list typed
process.output srv.process.output(pid, since) - stream long-poll; follow() helper
process.signal srv.process.signal(pid, sig) commands.kill typed explicit-only op
process.start srv.process.start(...) commands.start typed
process.stdin srv.process.stdin(pid, data) commands.send_stdin typed
system.env.get srv.system.env.get(names) - typed
system.env.set srv.system.env.set(vars) - admin explicit-only; writes the server overlay only
system.info srv.system.info() - typed envd metrics are platform plane (asymmetry A3)
system.metrics srv.system.metrics() metrics (platform) typed
system.packages srv.system.packages(manager) - typed
system.ports srv.system.ports() - typed
terminal.connect srv.terminal.connect(id) -> PtyConnection terminal handle stream WebSocket on 9001; policed on upgrade
terminal.create srv.terminal.create() get_terminal typed explicit-only op
terminal.delete srv.terminal.delete(id) terminal handle typed
terminal.get srv.terminal.get(id) - typed server-only
terminal.list srv.terminal.list() - typed server-only
terminal.resize srv.terminal.resize(id, cols, rows) terminal handle typed
custom.<name> srv.custom.<name>(**kw) / srv.call('custom.<name>') Sandbox.custom(name) dynamic tree built from GET /ops (asymmetry A7)
ext.<handler> srv.call('ext.<handler>') - dynamic arbitrary legacy RouteTable routes; dynamic tree only (asymmetry A7)

Per-adapter rows are identical for codex, claude_code, qwen_code, cursor, qoder (the table shows codex; the ledger test expands all five).

7. Node CLI (abx) mirror

The CLI (/workspace/cli, TypeScript, ESM) is a first-class consumer:

8. Versioning and compatibility