This guide is for developers working on SandD itself.
- Rust: Install from https://rustup.rs/
- Python 3.8+: With pip
- Docker: Only for the e2e tests and images
The Makefile creates .venv/ and installs maturin, pytest and ruff into it as needed.
SandD/
├── protocol/ # Shared message types (sandd-protocol crate)
│ └── src/lib.rs
│
├── server/ # Server: Rust WebSocket server + PyO3 bindings
│ ├── src/
│ │ ├── lib.rs # Crate root
│ │ ├── server.rs # WebSocket server (axum): /ws, /stats, /health
│ │ ├── registry.rs # Daemon connection registry
│ │ └── python.rs # Python API bindings (behind the `python` feature)
│ └── Cargo.toml
│
├── sandd/ # Daemon binary (runs on workers)
│ ├── src/
│ │ ├── main.rs # Entry point, connection loop, tunnel setup
│ │ ├── executor.rs # Command execution
│ │ ├── session.rs # Interactive sessions (PTY)
│ │ └── snapshot/ # Workspace snapshots
│ ├── benches/ # Criterion benchmarks
│ └── Cargo.toml
│
├── python/
│ ├── sandd/ # Python package (Server, models, tunnel keys; AsyncServer is a stub)
│ └── tests/ # Unit, integration and e2e tests
│
├── hack/ # Dockerfiles, compose stacks, install script
└── examples/ # Usage examples
make dev # Build and install the Python package (debug, editable)
make daemon-build # Build the daemon (debug): ./target/debug/sandd
make release # Python package (optimized)
make daemon-release # Daemon binary (optimized): ./target/release/sanddRun make help for the full list of targets.
The python feature of server/ is off by default, so plain cargo build and
cargo test work without a Python interpreter. Maturin turns it on when building the
wheel (see [tool.maturin] in pyproject.toml).
Edit files in protocol/src/, server/src/, sandd/src/, or python/sandd/.
# If you changed protocol/, server/ or python/sandd/
make dev
# If you changed protocol/ or sandd/
make daemon-build# Terminal 1: start a server
python3 -c "
from sandd import Server
server = Server()
server.wait_for_daemon('dev-1', timeout=60)
print(server.exec('dev-1', 'uname -a').stdout)
"
# Terminal 2: start a daemon
./target/debug/sandd --server-url ws://127.0.0.1:8765/ws --daemon-id dev-1See examples/ for sessions, snapshots and tunnel mode.
See ARCHITECTURE.md for detailed design.
Key concepts:
Python → Registry → Channel → handle_websocket → WebSocket → Daemon
(cmd_tx) (bridge) (cmd_rx) (network)
Why channels? No WebSocket type conflicts, lock-free, idiomatic async Rust.
Outgoing (Python → Daemon):
command_tx: mpsc::UnboundedSender<Message> // Stored in registryIncoming (Daemon → Python):
pending_commands: oneshot::Sender<Result> // Request/Response
sessions: mpsc::Sender<Vec<u8>> // Streaming
file_transfers: Vec<Vec<u8>> // Chunked bufferingmake test # Lint + Rust tests + Python tests (no Docker)
make test-e2e # Direct-mode e2e against daemons in Docker (slow)
make test-e2e-tunnel # Tunnel-mode e2e with a Headscale mesh in Docker (slow)
make lint # ruff check
make format # ruff fix + format
make benchmark # Daemon benchmarks (criterion)Python tests are selected by marker: e2e for Docker-based tests, tunnel for the
tunnel stack. make test runs -m "not e2e".
-
Add the variant to
Messageinprotocol/src/lib.rs(shared by server and daemon):MyNewCommand { field: String }
-
Handle it in
server/src/server.rs:Message::MyNewCommand { field } => { // Forward to daemon or handle }
-
Handle it in
sandd/src/main.rs:Message::MyNewCommand { field } => { // Execute and respond }
-
Add a method to
Serverinserver/src/python.rs:#[pymethods] impl Server { fn my_method(&self, arg: String) -> PyResult<()> { // Implementation } }
-
Add a wrapper in
python/sandd/server.py:def my_method(self, arg: str) -> None: """User-friendly docstring""" self._server.my_method(arg)
Enable Rust logs:
RUST_LOG=debug ./target/debug/sandd --server-url ws://127.0.0.1:8765/wsServer logs: Server(verbose=True) (the default) logs at INFO; set
RUST_LOG=sandbox_server=debug for more.
- File transfer is not supported yet.
Server.upload_fileandServer.download_fileexist, and the protocol messages are defined, but the feature is unfinished: don't rely on either call. - No daemon authentication. Any client that speaks
sandd.v1can register as any daemon id. Run the server on a trusted network or in tunnel mode. See ARCHITECTURE.md.
- PyO3
non_local_definitionswarning: Safe to ignore (PyO3 macro limitation)
At 200 concurrent daemons:
- Memory: ~2-3 GB
- CPU (idle): ~5%
- CPU (100 cmds/sec): ~15-25%
- Command latency p99: <20ms
- Run
make test - Check formatting:
cargo fmt --allandmake format - Check lints:
cargo clippy --workspace --all-targets - Update docs if adding features
feat: brief description
More detailed explanation if needed.
Include motivation and context.
Use a feat:, fix: or chore: prefix, as in the existing history.
- Keep dependencies minimal
- Prefer well-maintained crates
- Check licensing compatibility (Apache-2.0)
- Update the version in
pyproject.toml,protocol/Cargo.toml,server/Cargo.tomlandsandd/Cargo.toml(and thesandd-protocoldependency version insandd/Cargo.toml) - Tag and push:
git tag v0.x.y && git push upstream v0.x.y. TheReleaseworkflow builds the daemon binaries and creates the GitHub release. - Publish packages:
make publish(wheels to PyPI, needsINFTYAI_PYPI_TOKEN; crates to crates.io)
Daemon won't connect:
- Check the server is up:
curl http://server-host:8765/health - Verify firewall allows outbound WebSocket connections
- Check daemon logs:
RUST_LOG=info ./target/release/sandd ... - Check what the server sees:
curl http://server-host:8765/stats
Commands timing out:
- Increase
timeoutparameter inexec()(in seconds) - Check daemon system resources:
top,free -h - Verify command actually completes when run manually
- Check daemon logs for errors
High memory usage:
- Monitor active sessions (they hold state)
- Close unused sessions with
session.close() - Check number of connected daemons:
server.daemon_count()
Symptom: PanicException: there is no reactor running
Cause: Trying to use tokio::runtime::Handle::current() from Python thread
Fix: Store runtime handle in the struct, use it for block_on()
Symptom: expected tokio_tungstenite::WebSocket, found axum::WebSocket
Solution: Use channels, don't store WebSocket directly in registry
Symptom: Maturin can't find package in workspace
Fix: Use -m server/Cargo.toml flag
WebSocket-based JSON protocol for agent-daemon communication.
For complete protocol specification, see proposals/PROTOCOL.md.
- Check ARCHITECTURE.md for design details
- Check proposals/PROTOCOL.md for protocol specification
- Check QUICKSTART.md for usage examples