# Python Sync SDK Core

> Synchronous Python client surface: SandboxClient, Sandbox, connector, command executor, filesystem helpers, and the k8s helper layer.

- Repository: kubernetes-sigs/agent-sandbox
- GitHub: https://github.com/kubernetes-sigs/agent-sandbox
- Human wiki: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a
- Complete Markdown: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a/llms-full.txt

## Source Files

- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py`
- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox.py`
- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/connector.py`
- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/k8s_helper.py`
- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/command_executor.py`
- `clients/python/agentic-sandbox-client/k8s_agent_sandbox/files/filesystem.py`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/connector.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/connector.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/k8s_helper.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/k8s_helper.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/command_executor.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/command_executor.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/files/filesystem.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/files/filesystem.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/models.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/models.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/constants.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/constants.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/utils.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/utils.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/exceptions.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/exceptions.py)
- [clients/python/agentic-sandbox-client/k8s_agent_sandbox/__init__.py](clients/python/agentic-sandbox-client/k8s_agent_sandbox/__init__.py)
</details>

# Python Sync SDK Core

This page documents the synchronous Python client surface of `k8s_agent_sandbox`, the package that ships under `clients/python/agentic-sandbox-client`. The synchronous core is composed of five collaborating layers: `SandboxClient` (a registry that creates, attaches to, and tears down `SandboxClaim` custom resources), `Sandbox` (a per-instance handle), `SandboxConnector` plus its strategy classes (HTTP transport with router / port-forward / gateway / in-cluster variants), `CommandExecutor` and `Filesystem` (the two user-facing engines), and `K8sHelper` (the Kubernetes API wrapper that watches CRDs and resolves status).

The five layers are deliberately flat: a `SandboxClient` instance owns one `K8sHelper`, one connection configuration, and a dictionary of active `Sandbox` handles keyed by `(namespace, claim_name)`. Each `Sandbox` owns its own `SandboxConnector` and exposes the engines via the `.commands` and `.files` properties. The async variants (`AsyncSandboxClient`, `async_connector`, `async_filesystem`, `async_command_executor`, `async_k8s_helper`) mirror this structure and are imported lazily — `__init__.py` falls back to a stub that instructs the user to install the `async` extras when their dependencies are missing.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/__init__.py:15-36](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py:48-92](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox.py:32-80]()

## Architecture Overview

The diagram below names every module-backed class in the synchronous core and shows the ownership and call boundaries that the constructors and properties establish. Solid arrows are owns/creates; dashed arrows are HTTP/Kubernetes API egress.

```mermaid
flowchart TB
    subgraph User["User code"]
        App["import k8s_agent_sandbox"]
    end

    subgraph Client["sandbox_client.py"]
        SC["SandboxClient[T]"]
        ACT["_active_connection_sandboxes:<br/>Dict[(ns, claim_name), Sandbox]"]
    end

    subgraph Handle["sandbox.py"]
        SB["Sandbox"]
        CMD["CommandExecutor<br/>(commands/command_executor.py)"]
        FS["Filesystem<br/>(files/filesystem.py)"]
    end

    subgraph Net["connector.py"]
        CN["SandboxConnector"]
        DS["DirectConnectionStrategy"]
        GW["GatewayConnectionStrategy"]
        LT["LocalTunnelConnectionStrategy"]
        IC["InClusterConnectionStrategy"]
        SESS["requests.Session + Retry"]
    end

    subgraph K8s["k8s_helper.py"]
        KH["K8sHelper"]
        CO["CustomObjectsApi"]
        CV["CoreV1Api"]
    end

    subgraph External["External"]
        API["Kubernetes API server"]
        Router["sandbox-router-svc :8080"]
        Pod["Sandbox pod :8888"]
    end

    App --> SC
    SC --> ACT
    SC --> KH
    SC -->|create_sandbox / get_sandbox| SB
    SB --> CMD
    SB --> FS
    SB --> CN
    CN --> SESS
    CN -->|polymorphic| DS
    CN -->|polymorphic| GW
    CN -->|polymorphic| LT
    CN -->|polymorphic| IC
    KH --> CO
    KH --> CV
    CO -.->|watch/list/create/delete| API
    CV -.-> API
    LT -.->|kubectl port-forward subprocess| Router
    DS -.-> Router
    GW -.-> Router
    Router -.-> Pod
    IC -.->|svc DNS or pod IP| Pod
```

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py:56-92](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox.py:42-80](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/connector.py:38-304]()

## `SandboxClient` — registry and lifecycle

`SandboxClient` is generic in the handle type (`T = TypeVar('T', bound=Sandbox)`) so subclasses can swap in a richer `sandbox_class` (used by `SandboxWithSnapshotSupport` in `gke_extensions`). Construction wires three things: the connection config (defaulting to `SandboxLocalTunnelConnectionConfig`), an optional tracer manager from `trace_manager.create_tracer_manager`, and a fresh `K8sHelper`. When `cleanup=True`, `atexit.register(self.delete_all)` arranges best-effort teardown of every tracked sandbox at process exit.

The active-handle registry is `_active_connection_sandboxes: Dict[Tuple[str, str], T]`. Keys are `(namespace, claim_name)` tuples, and every public method that mutates state — `create_sandbox`, `get_sandbox`, `delete_sandbox`, `delete_all`, `list_active_sandboxes` — manipulates this dictionary directly. `list_active_sandboxes` also lazily evicts handles whose `is_active` property has flipped to `False`.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py:46-92](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py:215-287]()

### `create_sandbox` lifecycle

`create_sandbox` is the canonical happy path. Its steps are deterministic and the error path is precisely scoped: if anything between claim creation and the engine handshake fails, the partially created claim is deleted before the exception propagates.

| Step | Method called | Source |
| --- | --- | --- |
| Validate input | `_validate_labels`, `construct_sandbox_claim_lifecycle_spec` | `sandbox_client.py:117-122`, `utils.py:18-45` |
| Mint claim name | `f"sandbox-claim-{uuid.uuid4().hex[:8]}"` | `sandbox_client.py:124` |
| Create CRD | `_create_claim` → `k8s_helper.create_sandbox_claim` | `sandbox_client.py:336-352`, `k8s_helper.py:43-75` |
| Resolve sandbox name from claim status | `k8s_helper.resolve_sandbox_name` (watch) | `k8s_helper.py:77-129` |
| Wait until `Ready` condition is `True` | `_wait_for_sandbox_ready` → `k8s_helper.wait_for_sandbox_ready` | `sandbox_client.py:354-357`, `k8s_helper.py:131-168` |
| Instantiate handle | `self.sandbox_class(...)` | `sandbox_client.py:140-147` |
| Roll back on failure | `_delete_claim` in `except` | `sandbox_client.py:148-151` |

The `shutdown_after_seconds` argument is converted by `construct_sandbox_claim_lifecycle_spec` into a `{"shutdownTime": "<UTC ISO8601>", "shutdownPolicy": "Delete"}` block that the controller honors as a TTL. The helper validates that the argument is a positive `int` and rejects `bool` (because `type(x) is not int`) and `OverflowError` cases explicitly.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py:94-154](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/utils.py:18-45]()

### Label validation

`SandboxClient` enforces Kubernetes label rules client-side before submitting the claim. `_LABEL_NAME_RE`, `_LABEL_PREFIX_RE`, `_LABEL_NAME_MAX_LENGTH` (63) and `_LABEL_PREFIX_MAX_LENGTH` (253) mirror the upstream constraints. Keys with a `prefix/name` form are split and each segment is validated independently; values may be empty but, when non-empty, must satisfy the same name regex. Errors raise `ValueError` before any API call is issued.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py:289-334]()

### `get_sandbox`, `delete_sandbox`, `delete_all`

`get_sandbox` re-attaches to an existing claim. It always issues `resolve_sandbox_name` + `get_sandbox` against the K8s API even when a registry entry exists, so a stale handle for a deleted claim is detected and evicted before the call returns `SandboxNotFoundError`. If the registry entry is active and the underlying object is still present, the cached handle is returned; otherwise a fresh `sandbox_class(...)` is constructed and inserted.

`delete_sandbox` prefers terminating the in-memory handle (which calls `connector.close()` and then `k8s_helper.delete_sandbox_claim`); if no handle is tracked, it falls through to `_delete_claim`. `delete_all` iterates a snapshot of `_active_connection_sandboxes.items()` and logs but does not re-raise individual failures, so cleanup is best-effort.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py:156-213](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py:250-287]()

## `Sandbox` — per-instance handle

`Sandbox` is the resource handle the user actually calls into. Each instance owns a `SandboxConnector`, a `CommandExecutor`, and a `Filesystem`, and caches two derived values that are expensive to look up:

- `_pod_name`: pulled from the `agents.x-k8s.io/pod-name` annotation on the Sandbox object, falling back to `sandbox_id` when the annotation is absent.
- `_sandbox_name_hash`: parsed from `status.selector` when it matches the `agents.x-k8s.io/sandbox-name-hash=<value>` label form.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox.py:42-112](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/constants.py:28-30]()

### Pod IP resolution

`Sandbox.get_pod_ip` is passed by reference into both `SandboxConnector` and `InClusterConnectionStrategy` as the `get_pod_ip` callable. It deliberately **does not** cache: the docstring notes that the IP can change after a pod restart (e.g. when `spec.replicas` is scaled to 0 and back), so the function always queries the K8s API and reads `status.podIPs[0]`. Callers (the connector) layer their own caching on top with explicit invalidation hooks.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox.py:114-123]()

### `status`, `close_connection`, `terminate`

`status()` reads the Sandbox object's `conditions` and returns one of three string tags: `"SandboxReady"`, `"SandboxNotReady"`, or `"SandboxNotFound"`, paired with the condition message.

```text
                                     ┌──────────────────────┐
   create_sandbox / get_sandbox ────▶│  Sandbox (active)    │
                                     │  _is_closed = False  │
                                     └──────────┬───────────┘
                                                │
                  close_connection() ───────────┤
                  (local cleanup only)          │
                                                ▼
                                     ┌──────────────────────┐
                                     │  Sandbox (closed)    │◀── idempotent
                                     │  _commands = None    │
                                     │  _files = None       │
                                     │  remote claim alive  │
                                     └──────────┬───────────┘
                                                │
                  terminate() ──────────────────┤
                  (close + delete claim)        │
                                                ▼
                                     ┌──────────────────────┐
                                     │  Sandbox (terminated)│
                                     │  claim_name = None   │
                                     │  remote claim gone   │
                                     └──────────────────────┘
```

`close_connection` is the local-only teardown: it calls `connector.close()`, nulls `_commands` and `_files` (so any further `sandbox.commands.run(...)` raises `AttributeError`), and ends the OpenTelemetry lifecycle span. `terminate` calls `close_connection` first and then `k8s_helper.delete_sandbox_claim`; a `SandboxNotFoundError` from the delete is swallowed so the method is safely idempotent. After a successful delete, `claim_name` is cleared so retries cannot 404 against an empty name.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox.py:125-214]()

## `SandboxConnector` and connection strategies

`SandboxConnector` centralizes HTTP transport. It always builds a `requests.Session` with a 5-attempt `Retry` backing off on `500/502/503/504` for `GET/POST/PUT/DELETE`, and selects a `ConnectionStrategy` based on the runtime type of the `connection_config`.

```mermaid
classDiagram
    class ConnectionStrategy {
        <<abstract>>
        +connect() str
        +close()
        +verify_connection()
        +should_inject_router_headers() bool
    }
    class DirectConnectionStrategy {
        +config: SandboxDirectConnectionConfig
        +connect() str
    }
    class GatewayConnectionStrategy {
        +config: SandboxGatewayConnectionConfig
        +k8s_helper: K8sHelper
        +base_url: str
    }
    class LocalTunnelConnectionStrategy {
        +sandbox_id: str
        +port_forward_process: Popen
        +base_url: str
        -_get_free_port()
        -_is_port_open(port)
    }
    class InClusterConnectionStrategy {
        +_dns_url: str
        +_cached_pod_ip_url: str
        +_get_pod_ip: Callable
    }
    class SandboxConnector {
        +id: str
        +namespace: str
        +session: requests.Session
        +strategy: ConnectionStrategy
        +send_request(method, endpoint, **kwargs)
    }

    ConnectionStrategy <|.. DirectConnectionStrategy
    ConnectionStrategy <|.. GatewayConnectionStrategy
    ConnectionStrategy <|.. LocalTunnelConnectionStrategy
    ConnectionStrategy <|.. InClusterConnectionStrategy
    SandboxConnector --> ConnectionStrategy : delegates
```

The factory is a plain `isinstance` chain in `_connection_strategy`. An unrecognized config raises `ValueError("Unknown connection configuration type")`.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/connector.py:40-304]()

### Strategy comparison

| Strategy | `connect()` produces | Router headers? | Notes |
| --- | --- | --- | --- |
| `DirectConnectionStrategy` | `config.api_url` verbatim | Yes | Caller supplies the router URL. No verification or teardown needed. |
| `GatewayConnectionStrategy` | `http://{ip}` from `k8s_helper.wait_for_gateway_ip` | Yes | Watches a `gateway.networking.k8s.io/v1 Gateway` object; reports `sandbox_client_discovery_latency_ms{mode="gateway"}`. |
| `LocalTunnelConnectionStrategy` | `http://127.0.0.1:{local_port}` | Yes | Spawns `kubectl port-forward svc/sandbox-router-svc <port>:8080`. Polls a TCP probe every 0.5 s until `port_forward_ready_timeout`. `verify_connection` raises `SandboxPortForwardError` if `Popen.poll()` shows the child died. |
| `InClusterConnectionStrategy` | `http://{pod_ip}:{port}` or fallback `http://{sandbox_id}.{namespace}.svc.cluster.local:{port}` | **No** | Bypasses the router; resolves to pod IP only when the `get_pod_ip` callable returns a value, then caches it. |

The local tunnel strategy is the default because `SandboxClient.__init__` defaults `connection_config` to `SandboxLocalTunnelConnectionConfig()`.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/connector.py:63-255](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py:76]()

### Request path and header injection

`SandboxConnector.send_request` is the single chokepoint for every engine call.

```mermaid
sequenceDiagram
    participant Engine as CommandExecutor / Filesystem
    participant Conn as SandboxConnector
    participant Strat as ConnectionStrategy
    participant K8s as K8sHelper / get_pod_ip
    participant Srv as Router / Pod

    Engine->>Conn: send_request(method, endpoint, **kwargs)
    Conn->>Strat: connect()
    Strat-->>Conn: base_url
    Conn->>Strat: verify_connection()
    alt should_inject_router_headers
        Conn->>Conn: headers["X-Sandbox-ID"] / Namespace / Port
        opt _get_pod_ip and not auth_failed
            Conn->>K8s: get_pod_ip()
            K8s-->>Conn: pod_ip or 401/403
            Conn->>Conn: cache pod_ip or set _pod_ip_auth_failed
        end
        Conn->>Conn: headers["X-Sandbox-Pod-IP"] = pod_ip
    end
    Conn->>Srv: session.request(method, url, headers, ...)
    Srv-->>Conn: Response
    Conn->>Conn: raise_for_status
    Conn-->>Engine: Response
```

Three behaviors deserve highlighting:

1. **Auth backoff for pod-IP routing.** When `_get_pod_ip` raises with HTTP `401`/`403`, the connector flips `_pod_ip_auth_failed = True` and stops asking; transient errors are logged at debug and re-tried on the next request. Once the pod IP has been resolved, it is cached in `_pod_ip` and reused for the header.
2. **Crash-induced reconnect.** `SandboxPortForwardError` triggers `self.close()` and is re-raised, so the next `send_request` will rebuild the `kubectl port-forward` child.
3. **Failure wrapping.** Any `requests.exceptions.RequestException` is caught, the connector is closed, and a `SandboxRequestError(message, status_code, response)` is raised — preserving the HTTP status and raw response on the exception for callers.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/connector.py:319-372](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/exceptions.py:42-59]()

## `CommandExecutor` — `commands.run`

`CommandExecutor` is intentionally thin: one method, `run(command, timeout=60)`, that POSTs `{"command": command}` to the router endpoint `execute`. The response is parsed first into a Python dict and then validated through the `ExecutionResult` pydantic model, which guarantees the three fields `stdout`, `stderr`, and `exit_code`. If the JSON cannot be decoded or the shape is wrong, the method raises `RuntimeError` with the original payload preserved as `__cause__`.

The `@trace_span("run")` decorator and the `set_attribute("sandbox.command", ...)` / `set_attribute("sandbox.exit_code", ...)` calls integrate the executor into the OpenTelemetry lifecycle started by the tracer manager.

```python
# clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/command_executor.py
@trace_span("run")
def run(self, command: str, timeout: int = 60) -> ExecutionResult:
    payload = {"command": command}
    response = self.connector.send_request(
        "POST", "execute", json=payload, timeout=timeout)
    response_data = response.json()
    result = ExecutionResult(**response_data)
    return result
```

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/commands/command_executor.py:20-50](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/models.py:18-22]()

## `Filesystem` — `files.write` / `read` / `list` / `exists`

`Filesystem` wraps four router endpoints — `upload`, `download/<path>`, `list/<path>`, and `exists/<path>`. All path arguments other than `write/read` are passed through `urllib.parse.quote(path, safe='')`, so slashes and other reserved characters are percent-encoded into a single URL segment.

| Method | HTTP | Endpoint | Body / params | Returns |
| --- | --- | --- | --- | --- |
| `write(path, content, timeout=60, allow_unsafe_paths=False)` | `POST` | `upload` | `multipart` field `file` with name `path` | None |
| `read(path, timeout=60, allow_unsafe_paths=False)` | `GET` | `download/<quoted-path>` | — | `bytes` |
| `list(path, timeout=60)` | `GET` | `list/<quoted-path>` | — | `List[FileEntry]` |
| `exists(path, timeout=60)` | `GET` | `exists/<quoted-path>` | — | `bool` |

### Path-traversal hardening

`write` and `read` route their `path` arguments through `Filesystem._safe_upload_path` unless the caller opts out with `allow_unsafe_paths=True`. The check is deliberately stricter than `os.path.normpath`:

1. Rejects any character with `ord(c) < 0x20` or `0x7F` — including embedded `\x00`, which would otherwise survive `normpath` and truncate at the server's C/syscall layer.
2. Strips whitespace, rejects empty input, then `posixpath.normpath` and `lstrip('/')`.
3. Rejects the bare `"."` result and any path segment equal to `".."`.

The docstring calls out the exact attack the NUL check defends against: `foo\x00../etc/passwd` would otherwise normalize to `foo` on Linux while being inspected as something else by Python. `list` and `exists` do **not** call this guard — only `write` and `read`, which actually traverse the filesystem on the server.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/files/filesystem.py:34-161](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/models.py:24-29]()

## `K8sHelper` — Kubernetes API layer

`K8sHelper` is the only module that imports `kubernetes`. Its constructor tries `config.load_incluster_config()` first and falls back to `config.load_kube_config()`, which makes the same client work in both in-cluster pods and developer laptops.

It exposes a small, purposeful API:

| Method | Resource | Behavior |
| --- | --- | --- |
| `create_sandbox_claim(name, template, namespace, annotations, labels, lifecycle, warmpool)` | `SandboxClaim` (`extensions.agents.x-k8s.io/v1beta1`) | Builds the manifest and calls `create_namespaced_custom_object`. `lifecycle` and `warmpool` are inserted into `spec` only when present. |
| `resolve_sandbox_name(claim_name, namespace, timeout)` | `SandboxClaim` | Opens a `watch.Watch().stream(...)` against a field-selected list. Reads `status.sandbox.name` (or legacy `status.sandbox.Name`). Raises `SandboxTemplateNotFoundError` on `Ready=False/reason=TemplateNotFound` and `SandboxMetadataError` on `DELETED`. |
| `wait_for_sandbox_ready(name, namespace, timeout)` | `Sandbox` (`agents.x-k8s.io/v1beta1`) | Watches for `condition Ready=True` and returns `status.podIPs[0]` when present. Raises `TimeoutError` on deadline, `SandboxNotFoundError` on `DELETED`. |
| `delete_sandbox_claim(name, namespace)` | `SandboxClaim` | Treats `ApiException.status == 404` as success — re-raises wrapped as `SandboxNotFoundError` otherwise. |
| `get_sandbox(name, namespace)` / `get_sandbox_claim(name, namespace)` | `Sandbox` / `SandboxClaim` | Returns `None` on 404 instead of raising. |
| `list_sandbox_claims(namespace, label_selector=None)` | `SandboxClaim` | Returns the list of claim names; passes `label_selector` through when provided. |
| `wait_for_gateway_ip(gateway_name, namespace, timeout)` | `Gateway` (`gateway.networking.k8s.io/v1`) | Watches `status.addresses[0].value`. |

All CRD coordinates are centralized in `constants.py` so changes to API group/version flow through one file: `CLAIM_API_GROUP`, `CLAIM_API_VERSION`, `CLAIM_PLURAL_NAME`, `SANDBOX_API_GROUP`, `SANDBOX_API_VERSION`, `SANDBOX_PLURAL_NAME`, `GATEWAY_API_GROUP`, `GATEWAY_API_VERSION`, `GATEWAY_PLURAL`, and the annotation/label keys `POD_NAME_ANNOTATION` and `SANDBOX_NAME_HASH_LABEL`.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/k8s_helper.py:32-273](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/constants.py:15-37]()

### Watch loop pattern

Both `resolve_sandbox_name` and `wait_for_sandbox_ready` follow the same structure: compute a `deadline = time.monotonic() + timeout`, enter a `while True` loop, open a fresh `watch.Watch()` per outer iteration with `timeout_seconds=remaining`, and call `w.stop()` immediately before returning or raising. This pattern means a transient API-server disconnect causes the watch to end gracefully (the inner `for` exits), the outer loop recomputes the remaining budget, and a new watch is opened — no busy-loop, no swallowed deadline.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/k8s_helper.py:77-168]()

## Exception hierarchy

All errors derive from `SandboxError(RuntimeError)`, which makes broad `except RuntimeError` clauses still catch sandbox-specific failures while letting callers narrow when they want to.

| Exception | Raised by | Meaning |
| --- | --- | --- |
| `SandboxNotReadyError` | (reserved for callers / extensions) | Sandbox not yet ready for communication. |
| `SandboxNotFoundError` | `k8s_helper.delete_sandbox_claim`, `SandboxClient.get_sandbox`, `Sandbox.terminate` | Claim or sandbox missing or deleted. |
| `SandboxTemplateNotFoundError` | `k8s_helper.resolve_sandbox_name` | `Ready=False/TemplateNotFound`. |
| `SandboxPortForwardError` | `LocalTunnelConnectionStrategy.connect` / `verify_connection` | `kubectl port-forward` crashed. |
| `SandboxMetadataError` | `k8s_helper.resolve_sandbox_name` | Claim deleted mid-resolution. |
| `SandboxRequestError(message, status_code, response)` | `SandboxConnector.send_request` | HTTP request to the sandbox failed; preserves HTTP status and raw response. |

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/exceptions.py:18-59]()

## End-to-end flow

The diagram below walks a typical synchronous session end-to-end, naming each module that participates.

```mermaid
sequenceDiagram
    participant User
    participant SC as SandboxClient
    participant KH as K8sHelper
    participant SB as Sandbox
    participant CN as SandboxConnector
    participant Router as sandbox-router-svc

    User->>SC: create_sandbox(template="...")
    SC->>KH: create_sandbox_claim(name, template, ns)
    KH-->>SC: claim created
    SC->>KH: resolve_sandbox_name(claim, ns, t)
    KH-->>SC: sandbox_id
    SC->>KH: wait_for_sandbox_ready(sandbox_id, ns, t)
    KH-->>SC: Ready=True
    SC->>SB: Sandbox(claim_name, sandbox_id, ns, ...)
    SB->>CN: SandboxConnector(...)
    SC-->>User: Sandbox handle
    User->>SB: sandbox.commands.run("ls")
    SB->>CN: send_request("POST", "execute", json=...)
    CN->>Router: HTTP POST with X-Sandbox-* headers
    Router-->>CN: 200 + ExecutionResult JSON
    CN-->>SB: response
    SB-->>User: ExecutionResult
    User->>SC: delete_sandbox(claim_name)
    SC->>SB: terminate()
    SB->>CN: close() (stops port-forward, closes session)
    SB->>KH: delete_sandbox_claim(claim_name, ns)
    KH-->>SB: deleted (or 404 swallowed)
```

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox_client.py:94-154](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/sandbox.py:190-214](), [clients/python/agentic-sandbox-client/k8s_agent_sandbox/connector.py:319-372]()

## Summary

The synchronous SDK core is intentionally small and explicit: `SandboxClient` owns lifecycle and a registry, `Sandbox` owns engines and connection state, `SandboxConnector` owns transport and chooses one of four strategies, and `K8sHelper` is the sole touchpoint with the Kubernetes API. Layered on top are deterministic teardown semantics (`close_connection` vs. `terminate`), idempotent delete paths that absorb 404s, defensive path validation in `Filesystem._safe_upload_path`, and an exception hierarchy rooted at `SandboxError`. The async surface mirrors this structure module-for-module behind `AsyncSandboxClient`, which `__init__.py` imports lazily and replaces with an informative stub when the async extras are not installed.

Sources: [clients/python/agentic-sandbox-client/k8s_agent_sandbox/__init__.py:26-36]()
