Metadata-Version: 2.4
Name: nullspace-sdk
Version: 0.1.9
Summary: Cloud sandboxes for AI agents
Project-URL: Homepage, https://docs.13-215-85-171.sslip.io
Project-URL: Repository, https://github.com/catamaran-research/nullspace
Project-URL: Documentation, https://docs.13-215-85-171.sslip.io/sdk-reference
Project-URL: Issues, https://github.com/catamaran-research/nullspace/issues
Author-email: Nullspace <team@nullspace.dev>
License-Expression: Apache-2.0
Keywords: agents,ai,cloud,firecracker,microvm,sandbox
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: click>=8.0
Requires-Dist: httpx>=0.27
Requires-Dist: pathspec>=0.12
Requires-Dist: pydantic>=2.0
Requires-Dist: rich>=13.0
Requires-Dist: websockets>=13.0
Provides-Extra: cli
Requires-Dist: click>=8.0; extra == 'cli'
Requires-Dist: rich>=13.0; extra == 'cli'
Provides-Extra: dev
Requires-Dist: click>=8.0; extra == 'dev'
Requires-Dist: mcp[cli]<2,>=1; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-httpx>=0.34; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: rich>=13.0; extra == 'dev'
Requires-Dist: zstandard>=0.23; extra == 'dev'
Provides-Extra: mcp
Requires-Dist: mcp[cli]<2,>=1; extra == 'mcp'
Description-Content-Type: text/markdown

# Nullspace

Open-source cloud sandboxes for AI agents. Create isolated Linux environments on demand, run commands, read/write files, and expose ports -- all from a few lines of Python.

[![PyPI](https://img.shields.io/pypi/v/nullspace-sdk)](https://pypi.org/project/nullspace-sdk/)
[![License](https://img.shields.io/pypi/l/nullspace-sdk)](https://github.com/catamaran-research/nullspace/blob/main/LICENSE)

## Install

```bash
python -m pip install "${NULLSPACE_SDK_INSTALL_SPEC:-nullspace-sdk}"
```

For private beta, `NULLSPACE_SDK_INSTALL_SPEC` may point at a pinned PyPI
version, a hosted wheel URL, or a private repo tag supplied in the handout.

For CLI usage:

```bash
python -m pip install "nullspace-sdk[cli]"
```

For Claude Code or Codex local-agent setup, install the MCP extra and then
install project-local Nullspace docs:

```bash
python -m pip install "nullspace-sdk[cli,mcp]"
nullspace docs install --agent all
```

Or install from source:

```bash
uv pip install -e .              # from this directory
uv pip install -e ./sdks/python  # from repo root
uv pip install -e ".[cli]"              # from this directory, with the CLI
uv pip install -e "./sdks/python[cli]"  # from repo root, with the CLI
```

Docs:

- [Hosted endpoints](../../docs/site/reference/hosted-endpoints.mdx)
- [Python SDK Overview](../../docs/site/guides/python-sdk/overview.mdx)
- [Python CLI Guide](../../docs/site/guides/python-sdk/cli.mdx)

## Quickstart

### Synchronous

```python
from nullspace import Sandbox

with Sandbox.create() as sandbox:
    # Run a command
    result = sandbox.commands.run("echo 'Hello from Nullspace!'", shell=True)
    print(result.stdout)

    # Work with files
    sandbox.files.write("/hello.txt", "world")
    print(sandbox.files.read("/hello.txt"))

    # Expose a port
    server = sandbox.commands.run(
        "python3 -m http.server 8080 --bind 0.0.0.0",
        background=True,
        shell=True,
    )
    try:
        print(sandbox.get_url(8080))
        input("Open the URL, then press Enter to stop the server and destroy the sandbox...")
    finally:
        server.kill()
```

### Asynchronous

```python
import asyncio
from nullspace import AsyncSandbox


async def main() -> None:
    async with await AsyncSandbox.create() as sandbox:
        result = await sandbox.commands.run("echo 'Hello from Nullspace!'", shell=True)
        print(result.stdout)

        await sandbox.files.write("/hello.txt", "world")
        print(await sandbox.files.read("/hello.txt"))


asyncio.run(main())
```

Use `shell=True` for normal authored command strings. Use `args` when you want
exact argument boundaries without shell parsing. The SDK does not infer shell
mode from a plain string, and `shell=True` cannot be combined with `args`.

Path contract note:

- No breaking change: `/workspace` remains the default mutable work tree for
  agent and repo-style flows.
- General filesystem APIs also accept valid sandbox-scoped absolute paths such
  as `/tmp/...`, `/data/...`, and `/srv/app/...`.
- Endpoint-specific path rules remain explicit: user-controlled `cwd` and
  mount-path inputs still reject reserved runtime paths under
  `/workspace/.nullspace`, and `/context/...` only exists when a source-mount
  capable surface provides it.

## Preview URLs

```python
from nullspace import Sandbox, redact_preview_token, redact_preview_url

sandbox = Sandbox.connect("sb_123")

preview = sandbox.create_signed_preview_url(8080, expires_in_seconds=900)
readiness = sandbox.wait_for_preview(8080, timeout_secs=30)
print(readiness.ready)
print(redact_preview_url(preview.url))

target = sandbox.create_preview_proxy_target(
    8080,
    transports=["http", "websocket"],
)
print(target.http_url, target.token_header_name)
print(redact_preview_token(target.http_token))
```

Preview SDK methods return raw signed URLs and header tokens so callers can
open browsers and configure customer-run proxies intentionally. Redact those
values before writing terminal logs, app logs, or support bundles. Direct
WebSocket previews should use the returned `websocket_url`; custom preview
proxies should forward `x-nullspace-preview-proxy-token` on every upstream
request to Nullspace edge.

## File Uploads

Use `write()` for in-memory strings and bytes. Use `upload_file()` or `upload()`
when the source is a local file path or readable binary stream. Use
`upload_dir()` or `upload()` when the source is a local directory path.

```python
from nullspace import Sandbox

with Sandbox.create() as sandbox:
    result = sandbox.files.upload_file("./dist/app.tar.gz")
    print(result.transport, result.target_path, result.bytes_uploaded)
```

Without an explicit destination, `result.target_path` is returned as the
resolved absolute sandbox path. With the default path contract, that is
typically `/workspace/app.tar.gz`.

The SDK picks direct upload for smaller known-length files and switches to
resumable upload for larger files automatically. You can force resumable mode
and wire a progress callback:

```python
from nullspace import FileUploadError, Sandbox


def on_progress(event) -> None:
    print(event.phase, event.bytes_completed, event.bytes_total, event.transport)


with Sandbox.create() as sandbox:
    try:
        sandbox.files.upload_file(
            "./dist/model.bin",
            "/data/model.bin",
            resumable=True,
            progress=on_progress,
        )
    except FileUploadError as exc:
        if exc.upload_id:
            resumed = sandbox.files.resume_upload(
                exc.upload_id,
                "./dist/model.bin",
                progress=on_progress,
            )
            print(resumed.upload_id, resumed.bytes_uploaded)
        else:
            raise
```

Local directory paths use resumable tar upload. `upload()` dispatches directory
paths to `upload_dir()` with the default `merge` conflict policy:

```python
from nullspace import Sandbox

with Sandbox.create() as sandbox:
    result = sandbox.files.upload_dir(
        "./src",
        "/data/src",
        ignore_patterns=["*.pyc", "!pkg/__init__.py"],
    )
    print(result.kind, result.file_count, result.target_path)
```

Directory uploads honor `.nullspaceignore` from the source root, append any
explicit `ignore_patterns` after it, preserve symlinks, and do not implicitly
exclude `.git` or other dot-directories.

If a resumable directory upload fails mid-transfer, pass the same source
directory back to `resume_upload()`:

```python
from nullspace import FileUploadError, Sandbox

with Sandbox.create() as sandbox:
    try:
        sandbox.files.upload_dir("./src", "/data/src")
    except FileUploadError as exc:
        if exc.upload_id:
            resumed = sandbox.files.resume_upload(exc.upload_id, "./src")
            print(resumed.upload_id, resumed.bytes_uploaded)
        else:
            raise
```

## CLI Uploads

The bundled CLI now exposes the same upload surface under
`nullspace sandbox upload`:

```bash
nullspace sandbox upload sb_123 ./dist/app.tar.gz /data/app.tar.gz
nullspace sandbox upload sb_123 ./src /data/src --exclude '*.pyc'
nullspace sandbox upload sb_123 - /tmp/stdin.bin
```

Resumable failures print a concrete next command when the source can be replayed:

```bash
nullspace sandbox upload sb_123 ./big.iso --resume up_123
```

Use `--dry-run` to preview local file or directory uploads, and add `--json`
for machine-readable output.

## Authentication

For local interactive use, save your API key and API URL with:

```bash
nullspace auth login --api-url https://api.13-215-85-171.sslip.io
```

For scripts, CI, and coding agents, you can also set credentials as
environment variables:

```bash
export NULLSPACE_API_KEY=ns_live_...
export NULLSPACE_API_URL=https://api.13-215-85-171.sslip.io
```

`nullspace auth login` writes `~/.nullspace/config.json` for backward
compatibility. The SDK accepts explicit `api_key=` / `base_url=` arguments;
without those, the SDK and CLI read environment variables, project `.env`,
`~/.config/nullspace/config.json`, then legacy `~/.nullspace/config.json`.
The legacy config key `api_url` is accepted as an alias for `base_url`.

Or pass it directly:

```python
from nullspace import Sandbox

sandbox = Sandbox.create(api_key="ns_live_...")
sandbox.kill()
```

## Auto-Resume

Use `auto_resume=True` with paused timeout behavior when a sandbox should wake
on its preview URL after hibernating:

```python
from nullspace import Sandbox

sandbox = Sandbox.create(on_timeout="pause", auto_resume=True)
url = sandbox.get_url(8080)
```

After the sandbox pauses, inbound HTTP or websocket traffic to its preview URL
wakes it and the original request is forwarded once the resumed execution is
ready. `Sandbox.connect(id)` is still an explicit reconnect operation: it
resumes a paused sandbox ID by snapshot route and returns the new running
execution. `Sandbox.get_info_by_id(id)` is read-only and does not wake paused
sandboxes.

## Features

- **On-demand sandboxes** — spin up isolated Linux environments in milliseconds
- **Command execution** — run shell commands and capture stdout/stderr
- **File I/O** — read, write, list, and search files inside the sandbox
- **Persistent volumes** — tenant-scoped shared volume objects, direct `volume.files` management in Python and CLI, by-name lookup, and canonical `volumes=[...]` sandbox mounts
- **Port exposure** — expose sandbox ports via direct preview URLs, WebSocket
  URLs, and customer-run preview proxy targets
- **PTY sessions** — interactive terminal sessions over WebSocket
- **PTY identity** — use `session_id` for reconnect and management; numeric `pid` remains for legacy reconnect compatibility
- **Snapshot & resume** — hibernate sandboxes and resume them later
- **Fork** — clone a running sandbox (unique to Nullspace)

## Current Limits

- **Volumes** are currently a Firecracker-only SDK surface. The Python SDK and
  bundled CLI expose direct volume file management plus create-time mounts.
- Shared mounts use close-to-open visibility, atomic rename, and `flock` plus
  traditional `fcntl` record locks; snapshot resume and fork remount them with
  fresh internal leases before the new sandbox becomes ready. This remounts
  external shared storage; it does not make Firecracker VM memory or mutable
  rootfs snapshots portable across incompatible runtime hosts.
- The canonical mount shape is `volumes=[...]` with `ref`, `mount_path`,
  optional `subpath`, and `read_only`.

## Persistent Volumes

```python
from nullspace import Sandbox, Volume

shared = Volume.create("team-data")
same_shared = Volume.from_name("team-data")

with Sandbox.create(volumes=[shared.mount("/workspace/shared")]) as sandbox:
    sandbox.files.write("/workspace/shared/hello.txt", "persistent state")
    print([attachment.mount_path for attachment in sandbox.volumes])
    print(same_shared.id)
```

Direct volume file management uses the same persistent data without starting a
sandbox first:

```python
from pathlib import Path
import tempfile

from nullspace import Volume

shared = Volume.from_name("team-data", create_if_missing=True)
shared.files.make_dir("/datasets")
shared.files.write("/datasets/hello.txt", "hello from direct volume access\n")

with tempfile.TemporaryDirectory() as tmpdir:
    local_file = Path(tmpdir) / "artifact.txt"
    local_file.write_text("uploaded from local disk\n", encoding="utf-8")
    shared.files.upload_file(local_file, "/datasets/artifact.txt")
    shared.files.download_file("/datasets/artifact.txt", Path(tmpdir) / "artifact-copy.txt")
    shared.files.download_dir("/datasets", Path(tmpdir) / "datasets-copy")

print(shared.files.read("/datasets/hello.txt").strip())
print(shared.files.download_url("/datasets/artifact.txt"))
```

```bash
nullspace volume ls-files team-data /
nullspace volume upload team-data ./dist/model.bin /models/model.bin
nullspace volume download team-data /models/model.bin ./model.bin
nullspace volume download team-data /datasets/frontend ./frontend-copy
nullspace volume download team-data /datasets/frontend ./frontend-copy.tar --archive
```

See the full guides:

- [`docs/site/guides/python-sdk/volumes.mdx`](../../docs/site/guides/python-sdk/volumes.mdx)

## Templates

Template build and logging are Firecracker-only. Dockerfile builds use BuildKit.
`build_backend="native"` remains valid for non-Dockerfile declarative/OCI inputs
and historical build filters, but is rejected for Dockerfile input.

```python
from nullspace import Sandbox, Template, default_build_logger, wait_for_timeout

builder = (
    Template()
    .from_ubuntu_image("22.04")
    .set_runtime_envs({"HELLO": "Hello from Nullspace!"})
    .set_start_cmd(
        "echo $HELLO > /tmp/boot.log",
        readiness=wait_for_timeout(5_000),
    )
)

build = Template.build(
    builder,
    name="hello-template",
    tags=["stable"],
    on_log_entry=default_build_logger(),
)

with Sandbox.create(template=build.canonical_ref) as sandbox:
    print(sandbox.files.read("/tmp/boot.log").strip())
```

Use `Template.build_in_background(...)` plus `build.get_status(...)` for background builds, and `TemplateBuild.connect(...)` to reconnect to an existing build.
Ref-based management helpers include `Template.get_tags(...)`, `Template.assign_tags(...)`, and `Template.remove_tag(...)`.

For the full template guide and migration notes, see:

- [`docs/site/guides/python-sdk/templates.mdx`](../../docs/site/guides/python-sdk/templates.mdx)
- [`docs/site/guides/python-sdk/template-build-logging-migration.mdx`](../../docs/site/guides/python-sdk/template-build-logging-migration.mdx)

## Links

- [Hosted endpoints](../../docs/site/reference/hosted-endpoints.mdx)
- [GitHub](https://github.com/catamaran-research/nullspace)
- [Python SDK Overview](../../docs/site/guides/python-sdk/overview.mdx)
- [Python Template Guide](../../docs/site/guides/python-sdk/templates.mdx)
- [Python Template Build Migration Guide](../../docs/site/guides/python-sdk/template-build-logging-migration.mdx)
- [Documentation source](../../docs/site)
- [Changelog](./CHANGELOG.md)

## License

Apache-2.0
