Metadata-Version: 2.4
Name: boxkite-client
Version: 0.1.0
Summary: Python client for a hosted boxkite control-plane (sandbox creation, exec, files, LangChain tools).
License: FSL-1.1-Apache-2.0
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.27
Requires-Dist: websockets>=13
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.3; extra == "langchain"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"

# boxkite-client

A Python client for a **hosted** boxkite control-plane -- create sandboxes,
run commands, edit files, and (optionally) get LangChain tools wired to a
session, all over HTTP. This is not the boxkite package itself
(`boxkite`/`boxkite-sandbox`, which embeds `SandboxManager` directly against
your own Kubernetes cluster) -- use this when you're talking to *someone
else's* running control-plane, hosted or self-hosted, over its API.

## Install

```bash
pip install boxkite-client
pip install boxkite-client[langchain]  # for create_sandbox_tools
```

(Not yet published to PyPI -- install from this directory with `pip install -e .`
until it is.)

## Quickstart

```python
from boxkite_client import BoxkiteClient

client = BoxkiteClient(base_url="https://your-control-plane.example.com", api_key="bxk_live_...")

with client.sandbox(label="demo") as sb:
    result = sb.exec("python3 -c 'print(1 + 1)'")
    print(result["stdout"])  # "2\n"

    sb.file_create("notes.txt", "hello from boxkite-client\n")
    print(sb.view("notes.txt")["content"])
# sandbox is destroyed automatically here, even if an exception was raised above
```

Read-only search over the workspace -- `ls`, `glob`, `grep`:

```python
sb.ls()                                    # {"entries": [...]}  -- direct children of "/"
sb.ls(path="/src")                         # direct children of "/src"
sb.glob("**/*.py")                         # {"matches": [...]}  -- find files by name pattern
sb.grep("TODO", path="/src", glob="*.py")  # {"matches": [...], "error": None, "truncated": False}
```

`client.ls(session_id, ...)`, `client.glob(session_id, pattern, ...)`, and
`client.grep(session_id, pattern, ...)` are the same calls without a
`SandboxSession` wrapper. `grep`'s `max_matches` defaults to 500.

Background processes/sessions -- start a long-running process (a dev
server, a test watcher, a long build, a REPL) that keeps running after the
call returns, distinct from `exec()`'s one-shot request/response:

```python
proc = sb.start_process("npm run dev", max_runtime_seconds=1800)  # {"process_id", "status", "started_at"}

sb.get_process_output(proc["process_id"])   # {"status", "stdout_chunk", "next_offset", "truncated", "exit_code"}
sb.send_process_input(proc["process_id"], "y\n")
sb.stop_process(proc["process_id"])         # {"status", "exit_code"}
sb.list_processes()                         # {"processes": [...]}
```

This is polling-style, not streaming, and a backgrounded process is **not**
reachable over the network from any other call -- the same per-`exec`
network isolation applies here too. See `docs/PROCESS-SESSIONS-DESIGN.md`
for the full design and its stated non-goals.

Audit log and live watch (see `docs/SANDBOX-OBSERVABILITY-DESIGN.md`) --
every `exec`/file operation a sandbox runs is durably logged, readable as
paginated history or as a live feed:

```python
sb.get_log(limit=50, offset=0)  # {"entries": [...]}  -- paginated exec/file-op history

for entry in sb.watch():        # blocks, yielding one dict per logged operation as it happens
    print(entry["operation"], entry.get("detail"))
```

`watch()` is a live feed of *completed* exec/file operations, one per logged
event -- not a live terminal stream of a command's stdout mid-run. `watch()`
blocks the calling thread for as long as the stream stays open, so iterate it
from a dedicated thread if you need it alongside other work.

Interactive human takeover of a sandbox (a real PTY, proxied over
`WS .../takeover`) is a materially different problem -- see the design doc
section 3 for why -- but it is a separate call, `takeover()`:

```python
with sb.takeover() as ws:      # a websockets ClientConnection -- raw bytes, no envelope
    ws.send(b"ls -la\n")
    print(ws.recv())
```

There is no message envelope: `.send()`/`.recv()` raw bytes exactly as you
would over a local terminal. A missing/invalid API key, or a session that
isn't yours or was already destroyed, closes the connection with WS close
code `4401`/`4404` respectively -- this surfaces as
`websockets.exceptions.ConnectionClosed` the first time you `.send()` or
`.recv()`, not at `takeover()`/`connect()` time. See `docs/API.md`'s
`WS .../takeover` section for the full auth and logging contract -- every
takeover session is logged to the same audit trail as `get_log`/`watch`,
which is the load-bearing mitigation for shipping this without
fine-grained RBAC yet.

Without the context manager, you own create/destroy yourself:

```python
sandbox = client.create_sandbox(label="manual")
client.exec(sandbox["id"], "echo hi")
client.destroy_sandbox(sandbox["id"])
```

`create_sandbox` (and `client.sandbox(...)`) also accept these optional
keyword arguments:

| Argument | Default | Meaning |
|---|---|---|
| `size` | `"small"` | CPU/memory size preset: `"small"`, `"medium"`, or `"large"` |
| `storage_gb` | control-plane default | Overrides the sandbox's workspace/uploads/outputs/skills volume size (GB) |
| `lifetime_minutes` | control-plane default | Overrides how long the sandbox stays alive before the reaper tears it down |
| `count` | `1` | Create a batch of `count` sandboxes in one call, returned as a list |

All of these are bounded by fair-use ceilings on your account, never a paid
upgrade -- a request above your account's fair-use limit fails the same way a
concurrent-sandbox or monthly-usage limit does (see "Error handling" below).

```python
sandbox = client.create_sandbox(label="build-job", size="medium", storage_gb=20, lifetime_minutes=120)

sandboxes = client.create_sandbox(label="fanout", count=3)  # batch-create 3 sandboxes
```

## Async

Same shapes, `AsyncBoxkiteClient` + `async with`:

```python
import asyncio
from boxkite_client import AsyncBoxkiteClient

async def main():
    client = AsyncBoxkiteClient(base_url="https://your-control-plane.example.com", api_key="bxk_live_...")
    async with client.sandbox() as sb:
        result = await sb.exec("echo hi")
        print(result["stdout"])
    await client.aclose()

asyncio.run(main())
```

## LangChain agent

```python
from boxkite_client import BoxkiteClient
from boxkite_client.langchain_tools import create_sandbox_tools

client = BoxkiteClient(base_url="https://your-control-plane.example.com", api_key="bxk_live_...")
sandbox = client.create_sandbox(label="agent-session")

tools = create_sandbox_tools(client, sandbox["id"])
# tools = [bash_tool, file_create, view, str_replace, ls, glob, grep,
#   start_process, get_process_output, send_process_input, stop_process,
#   list_processes] -- hand these to any LangChain/LangGraph agent, same
# names/shapes as boxkite.tools' own factory.
```

## Command allowlist

`get_allowed_commands()` / `set_allowed_commands(rules)` /
`clear_allowed_commands()` (each available sync and async) manage a
per-account, persisted allowlist enforced on every future `exec()` call
against `/v1/sandboxes/{id}/exec`. Empty/unset (the default) means
unrestricted.

```python
client.get_allowed_commands()          # {"rules": [...]}  -- [] means unrestricted
client.set_allowed_commands([
    "git",
    {"command": "python3", "args_allow": [r"^-c .*"]},
])
client.clear_allowed_commands()        # back to unrestricted
```

This is an opt-in guardrail, not a sandbox-escape boundary — see the root
README's [The `boxkite` CLI](https://github.com/HarshitKmr10/boxkite#the-boxkite-cli)
section for why. A command that doesn't match any rule raises
`BoxkiteApiError` with `.code == "command_not_allowed"`.

## Error handling

Every non-2xx response raises `BoxkiteApiError` (with `.status_code`, `.code`,
`.message` from the control-plane's error envelope). A network-level failure
(DNS, TLS, timeout) raises `BoxkiteConnectionError`. Both subclass `BoxkiteError`.

```python
from boxkite_client import BoxkiteApiError

try:
    client.exec(sandbox["id"], "echo hi")
except BoxkiteApiError as exc:
    if exc.code == "concurrent_sandbox_limit_reached":
        ...  # back off, destroy an old session, etc.
```

Quota/usage before you hit a limit: `client.usage()` returns
`monthly_sandbox_hours_used`/`_limit` and `concurrent_sandboxes`/`_limit`.

## Development

```bash
pip install -e ".[dev,langchain]"
pytest tests/
```

Tests mock the control-plane with `httpx.MockTransport` -- no real deployment needed.
