From b71094aeb9c1d8a1d5f6fa4198d240949f04eb56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 03:56:01 +0000 Subject: [PATCH 1/2] sdks/rust: add Rust SDK with commands, files, exec, templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the surface of the Python and TypeScript SDKs: - Sandbox::create / connect / kill / hibernate / wake / set_timeout - commands().run, .start, .background, .attach, .list, .kill (WebSocket streaming over the same multiplexed binary protocol used by the other SDKs: 0x01 stdout, 0x02 stderr, 0x03 exit, 0x04 scrollback_end, 0x00 stdin) - files().{read, read_bytes, write, list, make_dir, remove, exists} - Template::{build, list, get, delete} - Checkpoint + preview URL CRUD on Sandbox - signed download_url / upload_url Examples are 1:1 ports of the existing Python/TypeScript suites: - test_commands.rs (9 sub-tests, mirrors test_commands.py) - test_file_ops.rs (8 sub-tests, mirrors test_file_ops.py) - test_python_sdk.rs (mirrors test_python_sdk.py) Each example creates a fresh sandbox, runs the suite, and tears the sandbox down — they double as integration tests against a real backend once OPENCOMPUTER_API_KEY/OPENCOMPUTER_API_URL are set: cargo run --example test_commands cargo run --example test_file_ops cargo run --example test_python_sdk Offline checks (run in CI): cargo fmt --check cargo clippy --all-targets -- -D warnings cargo build --all-targets cargo test --tests A new GitHub workflow (.github/workflows/test-rust-sdk.yml) runs all four on PRs that touch sdks/rust. --- .github/workflows/test-rust-sdk.yml | 44 ++ AGENTS.md | 4 +- README.md | 2 + sdks/rust/.gitignore | 2 + sdks/rust/Cargo.toml | 44 ++ sdks/rust/README.md | 74 +++ sdks/rust/examples/test_commands.rs | 434 +++++++++++++++++ sdks/rust/examples/test_file_ops.rs | 339 +++++++++++++ sdks/rust/examples/test_python_sdk.rs | 223 +++++++++ sdks/rust/src/error.rs | 55 +++ sdks/rust/src/exec.rs | 429 +++++++++++++++++ sdks/rust/src/filesystem.rs | 178 +++++++ sdks/rust/src/lib.rs | 41 ++ sdks/rust/src/sandbox.rs | 659 ++++++++++++++++++++++++++ sdks/rust/src/template.rs | 101 ++++ sdks/rust/tests/smoke.rs | 70 +++ 16 files changed, 2697 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test-rust-sdk.yml create mode 100644 sdks/rust/.gitignore create mode 100644 sdks/rust/Cargo.toml create mode 100644 sdks/rust/README.md create mode 100644 sdks/rust/examples/test_commands.rs create mode 100644 sdks/rust/examples/test_file_ops.rs create mode 100644 sdks/rust/examples/test_python_sdk.rs create mode 100644 sdks/rust/src/error.rs create mode 100644 sdks/rust/src/exec.rs create mode 100644 sdks/rust/src/filesystem.rs create mode 100644 sdks/rust/src/lib.rs create mode 100644 sdks/rust/src/sandbox.rs create mode 100644 sdks/rust/src/template.rs create mode 100644 sdks/rust/tests/smoke.rs diff --git a/.github/workflows/test-rust-sdk.yml b/.github/workflows/test-rust-sdk.yml new file mode 100644 index 00000000..f7de26e9 --- /dev/null +++ b/.github/workflows/test-rust-sdk.yml @@ -0,0 +1,44 @@ +name: Test Rust SDK + +on: + push: + branches: [main] + paths: + - 'sdks/rust/**' + - '.github/workflows/test-rust-sdk.yml' + pull_request: + paths: + - 'sdks/rust/**' + - '.github/workflows/test-rust-sdk.yml' + workflow_dispatch: + +defaults: + run: + working-directory: sdks/rust + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: sdks/rust + + - name: Format check + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Build (lib + examples) + run: cargo build --all-targets + + - name: Offline tests + run: cargo test --tests diff --git a/AGENTS.md b/AGENTS.md index ff82986f..a1956b7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ truth they come from. - `proto/` — inter-tier contracts - `docs/mint.json` — docs navigation - `cmd/oc/` — CLI entrypoint -- `sdks/typescript/` and `sdks/python/` — published SDKs +- `sdks/typescript/`, `sdks/python/`, and `sdks/rust/` — published SDKs Managed-agent product behavior is mostly **not** implemented here: @@ -132,7 +132,7 @@ you are editing: - `proto/` — contracts between tiers - public HTTP API routes in `internal/api/` -- `sdks/` — published TypeScript and Python SDKs +- `sdks/` — published TypeScript, Python, and Rust SDKs - `cmd/oc/` — CLI behavior users script against - `docs/` — user-facing product and API documentation diff --git a/README.md b/README.md index 545ca84c..613cba2a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Install the SDK: npm install @opencomputer/sdk # or pip install opencomputer-sdk +# or, in Cargo.toml +# opencomputer = "0.1" ``` ```typescript diff --git a/sdks/rust/.gitignore b/sdks/rust/.gitignore new file mode 100644 index 00000000..96ef6c0b --- /dev/null +++ b/sdks/rust/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/sdks/rust/Cargo.toml b/sdks/rust/Cargo.toml new file mode 100644 index 00000000..cb1c2ad1 --- /dev/null +++ b/sdks/rust/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "opencomputer" +version = "0.1.0" +edition = "2021" +rust-version = "1.74" +description = "Rust SDK for OpenComputer - cloud sandbox platform" +license = "MIT" +repository = "https://github.com/diggerhq/opencomputer" +homepage = "https://github.com/diggerhq/opencomputer/tree/main/sdks/rust" +documentation = "https://docs.rs/opencomputer" +readme = "README.md" +keywords = ["sandbox", "opencomputer", "cloud", "containers", "code-execution"] +categories = ["api-bindings", "asynchronous", "development-tools"] + +[lib] +name = "opencomputer" +path = "src/lib.rs" + +[dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } +futures-util = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +url = "2" +thiserror = "1" +bytes = "1" + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } +serde_json = "1" + +[[example]] +name = "test_commands" +path = "examples/test_commands.rs" + +[[example]] +name = "test_file_ops" +path = "examples/test_file_ops.rs" + +[[example]] +name = "test_python_sdk" +path = "examples/test_python_sdk.rs" diff --git a/sdks/rust/README.md b/sdks/rust/README.md new file mode 100644 index 00000000..f76a2512 --- /dev/null +++ b/sdks/rust/README.md @@ -0,0 +1,74 @@ +# opencomputer + +Rust SDK for [OpenComputer](https://github.com/diggerhq/opencomputer) — cloud sandbox platform. + +## Install + +```toml +[dependencies] +opencomputer = "0.1" +tokio = { version = "1", features = ["full"] } +``` + +## Quick Start + +```rust +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; + +#[tokio::main] +async fn main() -> opencomputer::Result<()> { + let sandbox = Sandbox::create(SandboxOpts::new().template("base")).await?; + + // Execute commands + let result = sandbox.commands().run("echo hello", RunOpts::new()).await?; + println!("{}", result.stdout); // "hello\n" + + // Read and write files + sandbox + .files() + .write("/tmp/test.txt", "Hello, world!") + .await?; + let _content = sandbox.files().read("/tmp/test.txt").await?; + + // Clean up + sandbox.kill().await?; + Ok(()) +} +``` + +## Configuration + +| Builder method | Env Variable | Default | +|----------------|------------------------|----------------------------------| +| `.api_url(..)` | `OPENCOMPUTER_API_URL` | `https://app.opencomputer.dev` | +| `.api_key(..)` | `OPENCOMPUTER_API_KEY` | (none) | + +## Examples + +The `examples/` directory mirrors the test scripts in the Python and TypeScript SDKs: + +```bash +export OPENCOMPUTER_API_KEY=osb_... +export OPENCOMPUTER_API_URL=https://app.opencomputer.dev # or your self-hosted URL + +cargo run --example test_commands +cargo run --example test_file_ops +cargo run --example test_python_sdk +``` + +Each example creates a fresh sandbox, runs an end-to-end suite, and tears the +sandbox down. They exit non-zero if any check fails, so they double as +integration tests. + +## Offline tests + +```bash +cargo test +``` + +These tests verify the public API surface and JSON deserialization without +contacting the backend. + +## License + +MIT diff --git a/sdks/rust/examples/test_commands.rs b/sdks/rust/examples/test_commands.rs new file mode 100644 index 00000000..91f1afea --- /dev/null +++ b/sdks/rust/examples/test_commands.rs @@ -0,0 +1,434 @@ +//! Command Edge Cases Test +//! +//! Tests: +//! 1. Basic commands +//! 2. stderr handling +//! 3. Non-zero exit codes +//! 4. Large stdout output +//! 5. Environment variable passing +//! 6. Working directory +//! 7. Shell features (pipes, redirects, subshells) +//! 8. Concurrent commands on same sandbox +//! 9. Command timeout +//! +//! Usage: +//! cargo run --example test_commands + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Instant; + +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; + +static PASSED: AtomicUsize = AtomicUsize::new(0); +static FAILED: AtomicUsize = AtomicUsize::new(0); + +fn green(msg: &str) { + println!("\x1b[32m✓ {}\x1b[0m", msg); +} +fn red(msg: &str) { + println!("\x1b[31m✗ {}\x1b[0m", msg); +} +fn bold(msg: &str) { + println!("\x1b[1m{}\x1b[0m", msg); +} +fn dim(msg: &str) { + println!("\x1b[2m {}\x1b[0m", msg); +} + +fn check(desc: &str, condition: bool, detail: &str) { + if condition { + green(desc); + PASSED.fetch_add(1, Ordering::SeqCst); + } else if detail.is_empty() { + red(desc); + FAILED.fetch_add(1, Ordering::SeqCst); + } else { + red(&format!("{} ({})", desc, detail)); + FAILED.fetch_add(1, Ordering::SeqCst); + } +} + +#[tokio::main] +async fn main() { + bold("\n╔══════════════════════════════════════════════════╗"); + bold("║ Command Edge Cases Test ║"); + bold("╚══════════════════════════════════════════════════╝\n"); + + let sandbox = match Sandbox::create(SandboxOpts::new().template("base").timeout(120)).await { + Ok(s) => Arc::new(s), + Err(e) => { + red(&format!("Fatal error: {}", e)); + FAILED.fetch_add(1, Ordering::SeqCst); + print_summary(); + return; + } + }; + green(&format!("Created sandbox: {}", sandbox.sandbox_id())); + println!(); + + if let Err(e) = run_tests(&sandbox).await { + red(&format!("Fatal error: {}", e)); + FAILED.fetch_add(1, Ordering::SeqCst); + } + + if let Err(e) = sandbox.kill().await { + red(&format!("Failed to kill sandbox: {}", e)); + } else { + green("Sandbox killed"); + } + + print_summary(); +} + +async fn run_tests(sandbox: &Arc) -> opencomputer::Result<()> { + // ── Test 1: Basic commands ── + bold("━━━ Test 1: Basic commands ━━━\n"); + + let echo = sandbox + .commands() + .run("echo hello-world", RunOpts::new()) + .await?; + check( + "Echo returns correct output", + echo.stdout.trim() == "hello-world", + "", + ); + check("Echo exit code is 0", echo.exit_code == 0, ""); + + let multi = sandbox + .commands() + .run("echo line1 && echo line2 && echo line3", RunOpts::new()) + .await?; + let lines: Vec<&str> = multi.stdout.trim().split('\n').collect(); + check("Multi-command outputs 3 lines", lines.len() == 3, ""); + check( + "Multi-command content correct", + lines.first() == Some(&"line1") && lines.get(2) == Some(&"line3"), + "", + ); + println!(); + + // ── Test 2: stderr handling ── + bold("━━━ Test 2: stderr handling ━━━\n"); + + let stderr_cmd = sandbox + .commands() + .run("echo error-msg >&2", RunOpts::new()) + .await?; + check( + "stderr captured", + stderr_cmd.stderr.trim() == "error-msg", + "", + ); + check( + "stdout empty when writing to stderr", + stderr_cmd.stdout.trim().is_empty(), + "", + ); + check( + "Exit code 0 even with stderr", + stderr_cmd.exit_code == 0, + "", + ); + + let mixed = sandbox + .commands() + .run("echo stdout-data && echo stderr-data >&2", RunOpts::new()) + .await?; + check( + "Mixed: stdout captured", + mixed.stdout.contains("stdout-data"), + "", + ); + check( + "Mixed: stderr captured", + mixed.stderr.contains("stderr-data"), + "", + ); + println!(); + + // ── Test 3: Non-zero exit codes ── + bold("━━━ Test 3: Non-zero exit codes ━━━\n"); + + let exit1 = sandbox.commands().run("exit 1", RunOpts::new()).await?; + check( + "Exit code 1 captured", + exit1.exit_code == 1, + &format!("got {}", exit1.exit_code), + ); + + let exit42 = sandbox.commands().run("exit 42", RunOpts::new()).await?; + check( + "Exit code 42 captured", + exit42.exit_code == 42, + &format!("got {}", exit42.exit_code), + ); + + let false_cmd = sandbox.commands().run("false", RunOpts::new()).await?; + check( + "'false' returns exit code 1", + false_cmd.exit_code == 1, + &format!("got {}", false_cmd.exit_code), + ); + + let not_found = sandbox + .commands() + .run("nonexistent-command-xyz 2>&1 || true", RunOpts::new()) + .await?; + check("Non-existent command handled", not_found.exit_code == 0, ""); + println!(); + + // ── Test 4: Large stdout ── + bold("━━━ Test 4: Large stdout output ━━━\n"); + + let large_out = sandbox + .commands() + .run("seq 1 10000", RunOpts::new()) + .await?; + let line_count = large_out.stdout.trim().split('\n').count(); + check( + "10000 lines of output captured", + line_count == 10000, + &format!("got {} lines", line_count), + ); + dim(&format!("Output size: {} chars", large_out.stdout.len())); + + let large_lines: Vec<&str> = large_out.stdout.trim().split('\n').collect(); + check("First line is 1", large_lines.first() == Some(&"1"), ""); + check( + "Last line is 10000", + large_lines.last() == Some(&"10000"), + "", + ); + println!(); + + // ── Test 5: Environment variables ── + bold("━━━ Test 5: Environment variable passing ━━━\n"); + + let env_result = sandbox + .commands() + .run( + "echo $MY_VAR", + RunOpts::new().env("MY_VAR", "secret-value-123"), + ) + .await?; + check( + "Env var passed correctly", + env_result.stdout.trim() == "secret-value-123", + "", + ); + + let multi_env = sandbox + .commands() + .run( + "echo \"$A:$B:$C\"", + RunOpts::new() + .env("A", "alpha") + .env("B", "beta") + .env("C", "gamma"), + ) + .await?; + check( + "Multiple env vars", + multi_env.stdout.trim() == "alpha:beta:gamma", + "", + ); + + let special_env = sandbox + .commands() + .run( + "echo $SPECIAL", + RunOpts::new().env("SPECIAL", "hello world with spaces & stuff"), + ) + .await?; + check( + "Env var with special chars", + special_env.stdout.trim() == "hello world with spaces & stuff", + "", + ); + println!(); + + // ── Test 6: Working directory ── + bold("━━━ Test 6: Working directory ━━━\n"); + + sandbox + .commands() + .run("mkdir -p /tmp/workdir/sub", RunOpts::new()) + .await?; + sandbox + .files() + .write("/tmp/workdir/sub/data.txt", "found-it") + .await?; + + let cwd_result = sandbox + .commands() + .run("cat data.txt", RunOpts::new().cwd("/tmp/workdir/sub")) + .await?; + check( + "Working directory respected", + cwd_result.stdout.trim() == "found-it", + "", + ); + + let pwd_result = sandbox + .commands() + .run("pwd", RunOpts::new().cwd("/tmp/workdir")) + .await?; + check( + "pwd reflects cwd", + pwd_result.stdout.trim() == "/tmp/workdir", + "", + ); + println!(); + + // ── Test 7: Shell features ── + bold("━━━ Test 7: Shell features (pipes, redirects, subshells) ━━━\n"); + + let pipe_result = sandbox + .commands() + .run("echo 'hello world' | tr ' ' '-'", RunOpts::new()) + .await?; + check("Pipe works", pipe_result.stdout.trim() == "hello-world", ""); + + let subshell = sandbox + .commands() + .run("echo $(hostname)", RunOpts::new()) + .await?; + check( + "Command substitution works", + !subshell.stdout.trim().is_empty(), + subshell.stdout.trim(), + ); + + sandbox + .commands() + .run("echo redirect-test > /tmp/redirect.txt", RunOpts::new()) + .await?; + let redirect_content = sandbox.files().read("/tmp/redirect.txt").await?; + check( + "Redirect to file works", + redirect_content.trim() == "redirect-test", + "", + ); + + sandbox + .commands() + .run( + "touch /tmp/wc-a.txt /tmp/wc-b.txt /tmp/wc-c.txt", + RunOpts::new(), + ) + .await?; + let wc_result = sandbox + .commands() + .run("ls /tmp/wc-*.txt | wc -l", RunOpts::new()) + .await?; + check( + "Wildcard expansion works", + wc_result.stdout.trim() == "3", + "", + ); + + let arith = sandbox + .commands() + .run("echo $((42 * 7))", RunOpts::new()) + .await?; + check( + "Arithmetic expansion works", + arith.stdout.trim() == "294", + "", + ); + + let here_str = sandbox + .commands() + .run("bash -c \"cat <<< 'here-string-data'\"", RunOpts::new()) + .await?; + check( + "Here string works", + here_str.stdout.trim() == "here-string-data", + "", + ); + println!(); + + // ── Test 8: Concurrent commands ── + bold("━━━ Test 8: Concurrent commands on same sandbox ━━━\n"); + + let concurrent_start = Instant::now(); + let mut handles = Vec::new(); + for i in 0..10 { + let sb = sandbox.clone(); + handles.push(tokio::spawn(async move { + let r = sb + .commands() + .run(&format!("echo concurrent-{}", i), RunOpts::new()) + .await; + (i, r) + })); + } + + let mut all_correct = true; + for h in handles { + let (index, result) = match h.await { + Ok(v) => v, + Err(_) => { + all_correct = false; + continue; + } + }; + match result { + Ok(r) => { + if r.stdout.trim() != format!("concurrent-{}", index) || r.exit_code != 0 { + all_correct = false; + dim(&format!( + "Command {}: expected \"concurrent-{}\", got \"{}\" (exit {})", + index, + index, + r.stdout.trim(), + r.exit_code + )); + } + } + Err(_) => all_correct = false, + } + } + let concurrent_ms = concurrent_start.elapsed().as_millis(); + + check( + "10 concurrent commands all returned correctly", + all_correct, + "", + ); + dim(&format!("Total concurrent time: {}ms", concurrent_ms)); + println!(); + + // ── Test 9: Command timeout ── + bold("━━━ Test 9: Command timeout ━━━\n"); + + let timeout_start = Instant::now(); + let _ = sandbox + .commands() + .run("sleep 30", RunOpts::new().timeout(3)) + .await; + let timeout_ms = timeout_start.elapsed().as_millis(); + check( + "Command timed out within ~3s", + timeout_ms < 10_000, + &format!("took {}ms", timeout_ms), + ); + println!(); + + Ok(()) +} + +fn print_summary() { + bold("========================================"); + bold(&format!( + " Results: {} passed, {} failed", + PASSED.load(Ordering::SeqCst), + FAILED.load(Ordering::SeqCst) + )); + bold("========================================\n"); + if FAILED.load(Ordering::SeqCst) > 0 { + std::process::exit(1); + } +} diff --git a/sdks/rust/examples/test_file_ops.rs b/sdks/rust/examples/test_file_ops.rs new file mode 100644 index 00000000..9eb38c11 --- /dev/null +++ b/sdks/rust/examples/test_file_ops.rs @@ -0,0 +1,339 @@ +//! File Operations Edge Cases Test +//! +//! Tests: +//! 1. Large file write/read (1MB) +//! 2. Special characters in content +//! 3. Deeply nested directories +//! 4. File deletion and overwrite +//! 5. Large directory listing +//! 6. Empty file handling +//! 7. File exists / not exists +//! 8. Write via commands + read via SDK +//! +//! Usage: +//! cargo run --example test_file_ops + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; + +static PASSED: AtomicUsize = AtomicUsize::new(0); +static FAILED: AtomicUsize = AtomicUsize::new(0); + +fn green(msg: &str) { + println!("\x1b[32m✓ {}\x1b[0m", msg); +} +fn red(msg: &str) { + println!("\x1b[31m✗ {}\x1b[0m", msg); +} +fn bold(msg: &str) { + println!("\x1b[1m{}\x1b[0m", msg); +} +fn dim(msg: &str) { + println!("\x1b[2m {}\x1b[0m", msg); +} + +fn check(desc: &str, condition: bool, detail: &str) { + if condition { + green(desc); + PASSED.fetch_add(1, Ordering::SeqCst); + } else if detail.is_empty() { + red(desc); + FAILED.fetch_add(1, Ordering::SeqCst); + } else { + red(&format!("{} ({})", desc, detail)); + FAILED.fetch_add(1, Ordering::SeqCst); + } +} + +#[tokio::main] +async fn main() { + bold("\n╔══════════════════════════════════════════════════╗"); + bold("║ File Operations Edge Cases Test ║"); + bold("╚══════════════════════════════════════════════════╝\n"); + + let sandbox = match Sandbox::create(SandboxOpts::new().template("base").timeout(120)).await { + Ok(s) => s, + Err(e) => { + red(&format!("Fatal error: {}", e)); + FAILED.fetch_add(1, Ordering::SeqCst); + print_summary(); + return; + } + }; + green(&format!("Created sandbox: {}", sandbox.sandbox_id())); + println!(); + + if let Err(e) = run_tests(&sandbox).await { + red(&format!("Fatal error: {}", e)); + FAILED.fetch_add(1, Ordering::SeqCst); + } + + if let Err(e) = sandbox.kill().await { + red(&format!("Failed to kill sandbox: {}", e)); + } else { + green("Sandbox killed"); + } + + print_summary(); +} + +async fn run_tests(sandbox: &Sandbox) -> opencomputer::Result<()> { + // ── Test 1: Large file ── + bold("━━━ Test 1: Large file (1MB) ━━━\n"); + + let one_mb: String = "X".repeat(1024 * 1024); + let write_start = Instant::now(); + sandbox + .files() + .write("/tmp/large.txt", one_mb.clone()) + .await?; + dim(&format!("Write: {}ms", write_start.elapsed().as_millis())); + + let read_start = Instant::now(); + let large_content = sandbox.files().read("/tmp/large.txt").await?; + dim(&format!("Read: {}ms", read_start.elapsed().as_millis())); + + check( + "1MB file size preserved", + large_content.len() == one_mb.len(), + &format!("{} bytes", large_content.len()), + ); + check("1MB file content intact", large_content == one_mb, ""); + println!(); + + // ── Test 2: Special characters ── + bold("━━━ Test 2: Special characters ━━━\n"); + + let special_content = + "Hello \"world\" & 'quotes' \\ newline\nTab\there 日本語 emoji🎉 nullish ?? chain?."; + sandbox + .files() + .write("/tmp/special.txt", special_content) + .await?; + let special_read = sandbox.files().read("/tmp/special.txt").await?; + check( + "Special characters preserved", + special_read == special_content, + &format!( + "got: {}...", + &special_read.chars().take(50).collect::() + ), + ); + + let json_content = serde_json::to_string_pretty(&serde_json::json!({ + "key": "value", + "nested": { "arr": [1, 2, 3] }, + "unicode": "日本語" + })) + .unwrap(); + sandbox + .files() + .write("/tmp/data.json", json_content.clone()) + .await?; + let json_read = sandbox.files().read("/tmp/data.json").await?; + check("JSON content preserved", json_read == json_content, ""); + + let multiline: String = (0..100) + .map(|i| format!("Line {}: Some content here", i + 1)) + .collect::>() + .join("\n"); + sandbox + .files() + .write("/tmp/multiline.txt", multiline.clone()) + .await?; + let multi_read = sandbox.files().read("/tmp/multiline.txt").await?; + check( + "100-line file preserved", + multi_read == multiline, + &format!("lines: {}", multi_read.split('\n').count()), + ); + println!(); + + // ── Test 3: Deeply nested directories ── + bold("━━━ Test 3: Deeply nested directories ━━━\n"); + + let deep_path = "/tmp/a/b/c/d/e/f/g/h"; + sandbox + .commands() + .run(&format!("mkdir -p {}", deep_path), RunOpts::new()) + .await?; + sandbox + .files() + .write(&format!("{}/deep.txt", deep_path), "bottom-of-tree") + .await?; + let deep_content = sandbox + .files() + .read(&format!("{}/deep.txt", deep_path)) + .await?; + check( + "8-level nested file created and read", + deep_content == "bottom-of-tree", + "", + ); + + let mid_entries = sandbox.files().list("/tmp/a/b/c/d").await?; + check( + "Intermediate dir lists correctly", + mid_entries.iter().any(|e| e.name == "e" && e.is_dir), + "", + ); + println!(); + + // ── Test 4: File deletion and overwrite ── + bold("━━━ Test 4: File deletion and overwrite ━━━\n"); + + sandbox + .files() + .write("/tmp/overwrite.txt", "original") + .await?; + let mut content = sandbox.files().read("/tmp/overwrite.txt").await?; + check("Original content written", content == "original", ""); + + sandbox + .files() + .write("/tmp/overwrite.txt", "overwritten") + .await?; + content = sandbox.files().read("/tmp/overwrite.txt").await?; + check("Overwritten content correct", content == "overwritten", ""); + + sandbox.files().write("/tmp/overwrite.txt", "short").await?; + content = sandbox.files().read("/tmp/overwrite.txt").await?; + check( + "Shorter overwrite correct (no trailing data)", + content == "short", + "", + ); + + let exists_before = sandbox.files().exists("/tmp/overwrite.txt").await; + check("File exists before delete", exists_before, ""); + + sandbox.files().remove("/tmp/overwrite.txt").await?; + let exists_after = sandbox.files().exists("/tmp/overwrite.txt").await; + check("File gone after delete", !exists_after, ""); + + sandbox.files().remove("/tmp/a").await?; + let dir_gone = sandbox + .files() + .exists(&format!("{}/deep.txt", deep_path)) + .await; + check("Recursive directory deletion", !dir_gone, ""); + println!(); + + // ── Test 5: Large directory listing ── + bold("━━━ Test 5: Large directory listing ━━━\n"); + + sandbox + .commands() + .run( + "for i in $(seq 1 50); do echo content-$i > /tmp/listtest-$i.txt; done", + RunOpts::new(), + ) + .await?; + let entries = sandbox.files().list("/tmp").await?; + let list_test_files: Vec<_> = entries + .iter() + .filter(|e| e.name.starts_with("listtest-")) + .collect(); + check( + "50 files visible in listing", + list_test_files.len() == 50, + &format!("found {}", list_test_files.len()), + ); + + if let Some(entry) = list_test_files.first() { + check("Entry has name", !entry.name.is_empty(), ""); + check("Entry has is_dir=false", !entry.is_dir, ""); + check( + "Entry has size > 0", + entry.size > 0, + &format!("size={}", entry.size), + ); + } + println!(); + + // ── Test 6: Empty file ── + bold("━━━ Test 6: Empty file handling ━━━\n"); + + sandbox.files().write("/tmp/empty.txt", "").await?; + let empty_content = sandbox.files().read("/tmp/empty.txt").await?; + check( + "Empty file returns empty string", + empty_content.is_empty(), + &format!("got: \"{}\"", empty_content), + ); + check( + "Empty file exists", + sandbox.files().exists("/tmp/empty.txt").await, + "", + ); + println!(); + + // ── Test 7: File exists checks ── + bold("━━━ Test 7: File exists checks ━━━\n"); + + check( + "Existing file → true", + sandbox.files().exists("/tmp/special.txt").await, + "", + ); + check( + "Non-existent file → false", + !sandbox.files().exists("/tmp/nope-no-way.txt").await, + "", + ); + check( + "Non-existent deep path → false", + !sandbox.files().exists("/tmp/no/such/path/file.txt").await, + "", + ); + println!(); + + // ── Test 8: Write via commands + read via SDK ── + bold("━━━ Test 8: Write via commands + read via SDK ━━━\n"); + + sandbox + .commands() + .run( + "dd if=/dev/urandom bs=256 count=1 2>/dev/null | base64 > /tmp/random.b64", + RunOpts::new(), + ) + .await?; + let b64_content = sandbox.files().read("/tmp/random.b64").await?; + check( + "Base64 random data readable", + b64_content.len() > 100, + &format!("{} chars", b64_content.len()), + ); + + sandbox + .commands() + .run( + "echo -n \"command-written\" > /tmp/cmd-file.txt", + RunOpts::new(), + ) + .await?; + let cmd_file_content = sandbox.files().read("/tmp/cmd-file.txt").await?; + check( + "Command-written file readable via SDK", + cmd_file_content == "command-written", + "", + ); + println!(); + + Ok(()) +} + +fn print_summary() { + bold("========================================"); + bold(&format!( + " Results: {} passed, {} failed", + PASSED.load(Ordering::SeqCst), + FAILED.load(Ordering::SeqCst) + )); + bold("========================================\n"); + if FAILED.load(Ordering::SeqCst) > 0 { + std::process::exit(1); + } +} diff --git a/sdks/rust/examples/test_python_sdk.rs b/sdks/rust/examples/test_python_sdk.rs new file mode 100644 index 00000000..246a0788 --- /dev/null +++ b/sdks/rust/examples/test_python_sdk.rs @@ -0,0 +1,223 @@ +//! Python SDK Production Test (Rust port) +//! +//! Validates that the Python template works end-to-end by running a Python +//! test script inside a sandbox that exercises stdlib, file ops, env vars, etc. +//! +//! Usage: +//! cargo run --example test_python_sdk + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; + +static PASSED: AtomicUsize = AtomicUsize::new(0); +static FAILED: AtomicUsize = AtomicUsize::new(0); + +fn green(msg: &str) { + println!("\x1b[32m✓ {}\x1b[0m", msg); +} +fn red(msg: &str) { + println!("\x1b[31m✗ {}\x1b[0m", msg); +} +fn bold(msg: &str) { + println!("\x1b[1m{}\x1b[0m", msg); +} +fn dim(msg: &str) { + println!("\x1b[2m {}\x1b[0m", msg); +} + +fn check(desc: &str, condition: bool, detail: &str) { + if condition { + green(desc); + PASSED.fetch_add(1, Ordering::SeqCst); + } else if detail.is_empty() { + red(desc); + FAILED.fetch_add(1, Ordering::SeqCst); + } else { + red(&format!("{} ({})", desc, detail)); + FAILED.fetch_add(1, Ordering::SeqCst); + } +} + +const PYTHON_TEST_SCRIPT: &str = r#" +import json +import os + +results = {} + +# Test 1: Basic echo +import subprocess +r = subprocess.run(["echo", "hello-from-python"], capture_output=True, text=True) +results["echo"] = r.stdout.strip() + +# Test 2: File write + read +with open("/tmp/py-test.txt", "w") as f: + f.write("python-sdk-data") +with open("/tmp/py-test.txt", "r") as f: + results["file_content"] = f.read() + +# Test 3: Environment variables +results["home"] = os.environ.get("HOME", "unknown") +results["path_exists"] = "PATH" in os.environ + +# Test 4: Nested directory +os.makedirs("/tmp/py-nested/deep/dir", exist_ok=True) +with open("/tmp/py-nested/deep/dir/file.txt", "w") as f: + f.write("nested-content") +with open("/tmp/py-nested/deep/dir/file.txt", "r") as f: + results["nested"] = f.read() + +# Test 5: Python-specific features +import sys +results["python_version"] = sys.version.split()[0] +results["platform"] = sys.platform + +# Test 6: Math/stdlib +import math +results["pi"] = str(round(math.pi, 5)) + +# Test 7: JSON handling +data = {"key": "value", "number": 42, "nested": {"a": True}} +results["json_roundtrip"] = json.loads(json.dumps(data)) == data + +print(json.dumps(results)) +"#; + +#[tokio::main] +async fn main() { + bold("\n╔══════════════════════════════════════════════════╗"); + bold("║ Python SDK Production Test ║"); + bold("╚══════════════════════════════════════════════════╝\n"); + + bold("[1/4] Creating Python sandbox..."); + let sandbox = match Sandbox::create(SandboxOpts::new().template("python").timeout(120)).await { + Ok(s) => s, + Err(e) => { + red(&format!("Fatal error: {}", e)); + FAILED.fetch_add(1, Ordering::SeqCst); + print_summary(); + return; + } + }; + green(&format!("Created: {}", sandbox.sandbox_id())); + dim(&format!("Domain: {}", sandbox.domain())); + println!(); + + if let Err(e) = run_tests(&sandbox).await { + red(&format!("Fatal error: {}", e)); + FAILED.fetch_add(1, Ordering::SeqCst); + } + + if let Err(e) = sandbox.kill().await { + red(&format!("Failed to kill sandbox: {}", e)); + } else { + green("Sandbox killed"); + } + print_summary(); +} + +async fn run_tests(sandbox: &Sandbox) -> opencomputer::Result<()> { + // --- Write and run Python test script --- + bold("[2/4] Writing Python test script..."); + sandbox + .files() + .write("/tmp/test_sdk.py", PYTHON_TEST_SCRIPT) + .await?; + green("Script written to /tmp/test_sdk.py"); + println!(); + + bold("[3/4] Running Python tests inside sandbox..."); + let result = sandbox + .commands() + .run("python3 /tmp/test_sdk.py", RunOpts::new().timeout(30)) + .await?; + check( + "Python script exited cleanly", + result.exit_code == 0, + &format!("exit code: {}", result.exit_code), + ); + + if result.exit_code != 0 { + dim(&format!("stderr: {}", result.stderr)); + dim(&format!("stdout: {}", result.stdout)); + } else { + let data: serde_json::Value = + serde_json::from_str(result.stdout.trim()).unwrap_or_default(); + + let s = |k: &str| { + data.get(k) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + }; + let b = |k: &str| data.get(k).and_then(|v| v.as_bool()).unwrap_or(false); + + check( + "Echo command works", + s("echo") == "hello-from-python", + &s("echo"), + ); + check( + "File write/read works", + s("file_content") == "python-sdk-data", + &s("file_content"), + ); + check("HOME env var present", s("home") != "unknown", &s("home")); + check("PATH env var exists", b("path_exists"), ""); + check( + "Nested directory file works", + s("nested") == "nested-content", + &s("nested"), + ); + check( + "Python version detected", + s("python_version").starts_with("3."), + &s("python_version"), + ); + check( + "Platform is Linux", + s("platform") == "linux", + &s("platform"), + ); + check("Math.pi correct", s("pi") == "3.14159", &s("pi")); + check("JSON roundtrip works", b("json_roundtrip"), ""); + dim(&format!( + "Python {} on {}", + s("python_version"), + s("platform") + )); + } + println!(); + + // --- Verify file ops from SDK side --- + bold("[4/4] Verifying files from Rust SDK..."); + let content = sandbox.files().read("/tmp/py-test.txt").await?; + check( + "SDK can read Python-written file", + content == "python-sdk-data", + &content, + ); + + let entries = sandbox.files().list("/tmp/py-nested/deep/dir").await?; + check( + "SDK can list Python-created directory", + entries.iter().any(|e| e.name == "file.txt"), + "", + ); + println!(); + + Ok(()) +} + +fn print_summary() { + bold("========================================"); + bold(&format!( + " Results: {} passed, {} failed", + PASSED.load(Ordering::SeqCst), + FAILED.load(Ordering::SeqCst) + )); + bold("========================================\n"); + if FAILED.load(Ordering::SeqCst) > 0 { + std::process::exit(1); + } +} diff --git a/sdks/rust/src/error.rs b/sdks/rust/src/error.rs new file mode 100644 index 00000000..3c5f8450 --- /dev/null +++ b/sdks/rust/src/error.rs @@ -0,0 +1,55 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +// The WebSocket and HTTP error types are >100 bytes, which would push every +// `Result` past the `result_large_err` threshold. Box them so the SDK's +// happy-path `Result` stays small. +#[derive(Debug, Error)] +pub enum Error { + #[error("HTTP error: {0}")] + Http(#[from] Box), + + #[error("WebSocket error: {0}")] + WebSocket(#[from] Box), + + #[error("URL parse error: {0}")] + Url(#[from] url::ParseError), + + #[error("JSON error: {0}")] + Json(#[from] Box), + + #[error("API returned status {status}: {body}")] + Api { status: u16, body: String }, + + #[error("Build failed: {0}")] + BuildFailed(String), + + #[error("{0}")] + Other(String), +} + +impl Error { + pub(crate) fn other(msg: impl Into) -> Self { + Self::Other(msg.into()) + } +} + +// Convenience `From` impls so `?` works with the un-boxed library error types. +impl From for Error { + fn from(e: reqwest::Error) -> Self { + Error::Http(Box::new(e)) + } +} + +impl From for Error { + fn from(e: tokio_tungstenite::tungstenite::Error) -> Self { + Error::WebSocket(Box::new(e)) + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::Json(Box::new(e)) + } +} diff --git a/sdks/rust/src/exec.rs b/sdks/rust/src/exec.rs new file mode 100644 index 00000000..f3053d5d --- /dev/null +++ b/sdks/rust/src/exec.rs @@ -0,0 +1,429 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::task::JoinHandle; +use tokio_tungstenite::tungstenite::Message; + +use crate::error::Result; +use crate::sandbox::{check_ok, parse_response, ClientCtx}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProcessResult { + #[serde(rename = "exitCode")] + pub exit_code: i32, + #[serde(default)] + pub stdout: String, + #[serde(default)] + pub stderr: String, +} + +#[derive(Debug, Clone, Default)] +pub struct RunOpts { + pub timeout: Option, + pub env: Option>, + pub cwd: Option, +} + +impl RunOpts { + pub fn new() -> Self { + Self::default() + } + + pub fn timeout(mut self, secs: u64) -> Self { + self.timeout = Some(secs); + self + } + + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env + .get_or_insert_with(HashMap::new) + .insert(key.into(), value.into()); + self + } + + pub fn envs(mut self, env: HashMap) -> Self { + self.env = Some(env); + self + } + + pub fn cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecSessionInfo { + #[serde(rename = "sessionID")] + pub session_id: String, + #[serde(rename = "sandboxID", default)] + pub sandbox_id: String, + pub command: String, + #[serde(default)] + pub args: Vec, + pub running: bool, + #[serde(rename = "exitCode", default)] + pub exit_code: Option, + #[serde(rename = "startedAt", default)] + pub started_at: String, + #[serde(rename = "attachedClients", default)] + pub attached_clients: u32, +} + +#[derive(Debug, Clone, Default)] +pub struct ExecStartOpts { + pub args: Option>, + pub env: Option>, + pub cwd: Option, + pub timeout: Option, + pub max_run_after_disconnect: Option, +} + +impl ExecStartOpts { + pub fn new() -> Self { + Self::default() + } + + pub fn args(mut self, args: Vec) -> Self { + self.args = Some(args); + self + } + + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env + .get_or_insert_with(HashMap::new) + .insert(key.into(), value.into()); + self + } + + pub fn cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + pub fn timeout(mut self, secs: u64) -> Self { + self.timeout = Some(secs); + self + } +} + +/// Channel of streaming events from an attached exec session. +pub enum StreamEvent { + Stdout(Vec), + Stderr(Vec), + /// Server has finished replaying scrollback; subsequent stdout/stderr is live. + ScrollbackEnd, + /// Process exited with this code. Always the last event. + Exit(i32), +} + +pub struct ExecSession { + pub session_id: String, + pub sandbox_id: String, + exit: Mutex>>, + cached_exit: Mutex>, + stdin_tx: mpsc::UnboundedSender>, + reader: Mutex>>, + ctx: Arc, +} + +impl ExecSession { + /// Wait for the process to exit and return its exit code. + pub async fn done(&self) -> i32 { + if let Some(code) = *self.cached_exit.lock().await { + return code; + } + let recv = self.exit.lock().await.take(); + let code = match recv { + Some(rx) => rx.await.unwrap_or(-1), + None => -1, + }; + *self.cached_exit.lock().await = Some(code); + code + } + + /// Write to the process stdin. + pub fn send_stdin(&self, data: impl Into>) { + let _ = self.stdin_tx.send(data.into()); + } + + /// Kill the underlying process. Default signal is SIGKILL (9). + pub async fn kill(&self, signal: Option) -> Result<()> { + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/exec/{}/kill", + self.ctx.api_url, self.sandbox_id, self.session_id + )) + .headers(self.ctx.headers()) + .json(&json!({ "signal": signal.unwrap_or(9) })) + .send() + .await?; + check_ok(resp, "kill exec session").await + } + + /// Detach from the session. Does not kill the process. + pub async fn close(&self) { + if let Some(handle) = self.reader.lock().await.take() { + handle.abort(); + } + } +} + +pub struct Exec { + ctx: Arc, + sandbox_id: String, +} + +impl Exec { + pub(crate) fn new(ctx: Arc, sandbox_id: String) -> Self { + Self { ctx, sandbox_id } + } + + /// Run a shell command and wait for completion. + /// + /// The command is executed via `sh -c`, so shell features like pipes, + /// redirects, and env var expansion work as expected. + pub async fn run(&self, command: &str, opts: RunOpts) -> Result { + let mut body = serde_json::Map::new(); + body.insert("cmd".into(), Value::String("sh".into())); + body.insert("args".into(), json!(["-c", command])); + body.insert("timeout".into(), json!(opts.timeout.unwrap_or(60))); + if let Some(env) = opts.env { + body.insert("envs".into(), serde_json::to_value(env)?); + } + if let Some(cwd) = opts.cwd { + body.insert("cwd".into(), Value::String(cwd)); + } + + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/exec/run", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.headers()) + .json(&Value::Object(body)) + .send() + .await?; + + parse_response(resp, "run command").await + } + + /// Start a long-running command and attach for streaming I/O. + pub async fn start( + &self, + command: &str, + opts: ExecStartOpts, + ) -> Result<(ExecSession, mpsc::UnboundedReceiver)> { + let mut body = serde_json::Map::new(); + body.insert("cmd".into(), Value::String(command.into())); + if let Some(args) = opts.args { + body.insert("args".into(), serde_json::to_value(args)?); + } + if let Some(env) = opts.env { + body.insert("envs".into(), serde_json::to_value(env)?); + } + if let Some(cwd) = opts.cwd { + body.insert("cwd".into(), Value::String(cwd)); + } + if let Some(t) = opts.timeout { + body.insert("timeout".into(), json!(t)); + } + if let Some(t) = opts.max_run_after_disconnect { + body.insert("maxRunAfterDisconnect".into(), json!(t)); + } + + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/exec", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.headers()) + .json(&Value::Object(body)) + .send() + .await?; + + #[derive(Deserialize)] + struct StartResp { + #[serde(rename = "sessionID")] + session_id: String, + } + let started: StartResp = parse_response(resp, "start exec session").await?; + self.attach(&started.session_id).await + } + + /// Alias for [`Exec::start`]. + pub async fn background( + &self, + command: &str, + opts: ExecStartOpts, + ) -> Result<(ExecSession, mpsc::UnboundedReceiver)> { + self.start(command, opts).await + } + + pub async fn attach( + &self, + session_id: &str, + ) -> Result<(ExecSession, mpsc::UnboundedReceiver)> { + let ws_base = self + .ctx + .api_url + .replacen("https://", "wss://", 1) + .replacen("http://", "ws://", 1); + let auth = if !self.ctx.api_key.is_empty() { + format!("?api_key={}", urlencoding(&self.ctx.api_key)) + } else { + String::new() + }; + let ws_url = format!( + "{}/sandboxes/{}/exec/{}{}", + ws_base, self.sandbox_id, session_id, auth + ); + + let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url).await?; + let (mut ws_sink, mut ws_read) = ws_stream.split(); + + let (events_tx, events_rx) = mpsc::unbounded_channel(); + let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::>(); + let (exit_tx, exit_rx) = oneshot::channel::(); + + let writer = tokio::spawn(async move { + while let Some(payload) = stdin_rx.recv().await { + let mut msg = Vec::with_capacity(1 + payload.len()); + msg.push(0x00); + msg.extend_from_slice(&payload); + if ws_sink.send(Message::Binary(msg)).await.is_err() { + break; + } + } + let _ = ws_sink.close().await; + }); + + let events_tx_for_reader = events_tx.clone(); + let reader = tokio::spawn(async move { + let mut got_exit = false; + let mut exit_tx = Some(exit_tx); + while let Some(msg) = ws_read.next().await { + let msg = match msg { + Ok(m) => m, + Err(_) => break, + }; + let data = match msg { + Message::Binary(b) => b, + Message::Close(_) => break, + _ => continue, + }; + if data.is_empty() { + continue; + } + let stream_id = data[0]; + let payload: Vec = data[1..].to_vec(); + match stream_id { + 0x01 => { + let _ = events_tx_for_reader.send(StreamEvent::Stdout(payload)); + } + 0x02 => { + let _ = events_tx_for_reader.send(StreamEvent::Stderr(payload)); + } + 0x03 => { + let code = if payload.len() >= 4 { + i32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) + } else { + 0 + }; + got_exit = true; + let _ = events_tx_for_reader.send(StreamEvent::Exit(code)); + if let Some(tx) = exit_tx.take() { + let _ = tx.send(code); + } + } + 0x04 => { + let _ = events_tx_for_reader.send(StreamEvent::ScrollbackEnd); + } + _ => {} + } + } + if !got_exit { + let _ = events_tx_for_reader.send(StreamEvent::Exit(-1)); + if let Some(tx) = exit_tx.take() { + let _ = tx.send(-1); + } + } + // Stop the writer once the reader is done (drops stdin_tx eventually). + drop(events_tx_for_reader); + let _ = writer.await; + }); + + let session = ExecSession { + session_id: session_id.to_string(), + sandbox_id: self.sandbox_id.clone(), + exit: Mutex::new(Some(exit_rx)), + cached_exit: Mutex::new(None), + stdin_tx, + reader: Mutex::new(Some(reader)), + ctx: self.ctx.clone(), + }; + Ok((session, events_rx)) + } + + pub async fn list(&self) -> Result> { + let resp = self + .ctx + .http + .get(format!( + "{}/sandboxes/{}/exec", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.auth_only_headers()) + .send() + .await?; + parse_response(resp, "list exec sessions").await + } + + pub async fn kill(&self, session_id: &str, signal: Option) -> Result<()> { + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/exec/{}/kill", + self.ctx.api_url, self.sandbox_id, session_id + )) + .headers(self.ctx.headers()) + .json(&json!({ "signal": signal.unwrap_or(9) })) + .send() + .await?; + check_ok(resp, "kill exec session").await + } +} + +fn urlencoding(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + let c = b as char; + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') { + out.push(c); + } else { + out.push_str(&format!("%{:02X}", b)); + } + } + out +} + +impl Drop for ExecSession { + fn drop(&mut self) { + if let Ok(mut guard) = self.reader.try_lock() { + if let Some(handle) = guard.take() { + handle.abort(); + } + } + } +} diff --git a/sdks/rust/src/filesystem.rs b/sdks/rust/src/filesystem.rs new file mode 100644 index 00000000..c81a0e3e --- /dev/null +++ b/sdks/rust/src/filesystem.rs @@ -0,0 +1,178 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; +use crate::sandbox::ClientCtx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntryInfo { + pub name: String, + #[serde(rename = "isDir", default)] + pub is_dir: bool, + #[serde(default)] + pub path: String, + #[serde(default)] + pub size: u64, +} + +pub struct Filesystem { + ctx: Arc, + sandbox_id: String, +} + +impl Filesystem { + pub(crate) fn new(ctx: Arc, sandbox_id: String) -> Self { + Self { ctx, sandbox_id } + } + + fn url(&self) -> String { + format!("{}/sandboxes/{}/files", self.ctx.api_url, self.sandbox_id) + } + + pub async fn read(&self, path: &str) -> Result { + let resp = self + .ctx + .http + .get(self.url()) + .headers(self.ctx.auth_only_headers()) + .query(&[("path", path)]) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::Api { + status: status.as_u16(), + body: format!("failed to read {}: {}", path, body), + }); + } + Ok(resp.text().await?) + } + + pub async fn read_bytes(&self, path: &str) -> Result> { + let resp = self + .ctx + .http + .get(self.url()) + .headers(self.ctx.auth_only_headers()) + .query(&[("path", path)]) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::Api { + status: status.as_u16(), + body: format!("failed to read {}: {}", path, body), + }); + } + Ok(resp.bytes().await?.to_vec()) + } + + pub async fn write(&self, path: &str, content: impl Into>) -> Result<()> { + let body: Vec = content.into(); + let resp = self + .ctx + .http + .put(self.url()) + .headers(self.ctx.auth_only_headers()) + .query(&[("path", path)]) + .body(body) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::Api { + status: status.as_u16(), + body: format!("failed to write {}: {}", path, body), + }); + } + Ok(()) + } + + pub async fn list(&self, path: &str) -> Result> { + let resp = self + .ctx + .http + .get(format!("{}/list", self.url())) + .headers(self.ctx.auth_only_headers()) + .query(&[("path", path)]) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::Api { + status: status.as_u16(), + body: format!("failed to list {}: {}", path, body), + }); + } + let bytes = resp.bytes().await?; + if bytes.is_empty() { + return Ok(Vec::new()); + } + // Server may return null when empty. + let v: serde_json::Value = serde_json::from_slice(&bytes)?; + if v.is_null() { + return Ok(Vec::new()); + } + Ok(serde_json::from_value(v)?) + } + + pub async fn make_dir(&self, path: &str) -> Result<()> { + let resp = self + .ctx + .http + .post(format!("{}/mkdir", self.url())) + .headers(self.ctx.auth_only_headers()) + .query(&[("path", path)]) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::Api { + status: status.as_u16(), + body: format!("failed to mkdir {}: {}", path, body), + }); + } + Ok(()) + } + + pub async fn remove(&self, path: &str) -> Result<()> { + let resp = self + .ctx + .http + .delete(self.url()) + .headers(self.ctx.auth_only_headers()) + .query(&[("path", path)]) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::Api { + status: status.as_u16(), + body: format!("failed to remove {}: {}", path, body), + }); + } + Ok(()) + } + + pub async fn exists(&self, path: &str) -> bool { + match self + .ctx + .http + .get(self.url()) + .headers(self.ctx.auth_only_headers()) + .query(&[("path", path)]) + .send() + .await + { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } + } +} diff --git a/sdks/rust/src/lib.rs b/sdks/rust/src/lib.rs new file mode 100644 index 00000000..37aa6c04 --- /dev/null +++ b/sdks/rust/src/lib.rs @@ -0,0 +1,41 @@ +//! Rust SDK for [OpenComputer](https://github.com/diggerhq/opencomputer) — cloud sandbox platform. +//! +//! ```no_run +//! use opencomputer::{Sandbox, SandboxOpts}; +//! +//! # async fn run() -> opencomputer::Result<()> { +//! let sandbox = Sandbox::create(SandboxOpts::new().template("base")).await?; +//! +//! let result = sandbox.commands().run("echo hello").await?; +//! println!("{}", result.stdout); // "hello\n" +//! +//! sandbox.files().write("/tmp/test.txt", "Hello, world!").await?; +//! let content = sandbox.files().read("/tmp/test.txt").await?; +//! +//! sandbox.kill().await?; +//! # Ok(()) +//! # } +//! ``` + +mod error; +mod exec; +mod filesystem; +mod sandbox; +mod template; + +pub use error::{Error, Result}; +pub use exec::{Exec, ExecSessionInfo, ProcessResult, RunOpts}; +pub use filesystem::{EntryInfo, Filesystem}; +pub use sandbox::{CheckpointInfo, PatchInfo, PatchResult, PreviewURLResult, Sandbox, SandboxOpts}; +pub use template::{Template, TemplateInfo}; + +pub(crate) const DEFAULT_API_URL: &str = "https://app.opencomputer.dev"; + +pub(crate) fn resolve_api_url(url: &str) -> String { + let trimmed = url.trim_end_matches('/'); + if trimmed.ends_with("/api") { + trimmed.to_string() + } else { + format!("{}/api", trimmed) + } +} diff --git a/sdks/rust/src/sandbox.rs b/sdks/rust/src/sandbox.rs new file mode 100644 index 00000000..e8d0b0bc --- /dev/null +++ b/sdks/rust/src/sandbox.rs @@ -0,0 +1,659 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::error::{Error, Result}; +use crate::exec::Exec; +use crate::filesystem::Filesystem; +use crate::{resolve_api_url, DEFAULT_API_URL}; + +#[derive(Debug, Clone, Default)] +pub struct SandboxOpts { + pub template: Option, + /// Idle timeout in seconds. `0` (default) = persistent, never auto-hibernates. + pub timeout: Option, + pub api_key: Option, + pub api_url: Option, + pub envs: Option>, + pub metadata: Option>, + pub cpu_count: Option, + pub memory_mb: Option, + pub disk_mb: Option, + pub secret_store: Option, + pub snapshot: Option, +} + +impl SandboxOpts { + pub fn new() -> Self { + Self::default() + } + + pub fn template(mut self, template: impl Into) -> Self { + self.template = Some(template.into()); + self + } + + pub fn timeout(mut self, timeout: u64) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn api_key(mut self, api_key: impl Into) -> Self { + self.api_key = Some(api_key.into()); + self + } + + pub fn api_url(mut self, api_url: impl Into) -> Self { + self.api_url = Some(api_url.into()); + self + } + + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.envs + .get_or_insert_with(HashMap::new) + .insert(key.into(), value.into()); + self + } + + pub fn envs(mut self, envs: HashMap) -> Self { + self.envs = Some(envs); + self + } + + pub fn metadata(mut self, metadata: HashMap) -> Self { + self.metadata = Some(metadata); + self + } + + pub fn cpu_count(mut self, cpu_count: u32) -> Self { + self.cpu_count = Some(cpu_count); + self + } + + pub fn memory_mb(mut self, memory_mb: u64) -> Self { + self.memory_mb = Some(memory_mb); + self + } + + pub fn disk_mb(mut self, disk_mb: u64) -> Self { + self.disk_mb = Some(disk_mb); + self + } + + pub fn secret_store(mut self, secret_store: impl Into) -> Self { + self.secret_store = Some(secret_store.into()); + self + } + + pub fn snapshot(mut self, snapshot: impl Into) -> Self { + self.snapshot = Some(snapshot.into()); + self + } +} + +#[derive(Debug, Deserialize)] +struct SandboxData { + #[serde(rename = "sandboxID")] + sandbox_id: String, + #[serde(default)] + status: String, + #[serde(rename = "templateID", default)] + template_id: String, + #[serde(rename = "connectURL", default)] + #[allow(dead_code)] + connect_url: String, + #[serde(default)] + #[allow(dead_code)] + token: String, + #[serde(rename = "sandboxDomain", default)] + sandbox_domain: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckpointInfo { + pub id: String, + #[serde(rename = "sandboxId")] + pub sandbox_id: String, + #[serde(rename = "orgId")] + pub org_id: String, + pub name: String, + pub status: String, + #[serde(rename = "sizeBytes", default)] + pub size_bytes: u64, + #[serde(rename = "createdAt", default)] + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PatchInfo { + pub id: String, + #[serde(rename = "checkpointId")] + pub checkpoint_id: String, + pub sequence: u64, + pub script: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub strategy: String, + #[serde(rename = "createdAt", default)] + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PatchResult { + pub patch: PatchInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreviewURLResult { + pub id: String, + #[serde(rename = "sandboxId")] + pub sandbox_id: String, + #[serde(rename = "orgId", default)] + pub org_id: String, + pub hostname: String, + #[serde(rename = "customHostname", default)] + pub custom_hostname: Option, + pub port: u16, + #[serde(rename = "sslStatus", default)] + pub ssl_status: String, + #[serde(rename = "createdAt", default)] + pub created_at: String, +} + +pub(crate) struct ClientCtx { + pub api_url: String, + pub api_key: String, + pub http: Client, +} + +impl ClientCtx { + pub fn headers(&self) -> reqwest::header::HeaderMap { + let mut h = reqwest::header::HeaderMap::new(); + h.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("application/json"), + ); + if !self.api_key.is_empty() { + if let Ok(v) = reqwest::header::HeaderValue::from_str(&self.api_key) { + h.insert("X-API-Key", v); + } + } + h + } + + pub fn auth_only_headers(&self) -> reqwest::header::HeaderMap { + let mut h = reqwest::header::HeaderMap::new(); + if !self.api_key.is_empty() { + if let Ok(v) = reqwest::header::HeaderValue::from_str(&self.api_key) { + h.insert("X-API-Key", v); + } + } + h + } +} + +pub struct Sandbox { + sandbox_id: String, + template_id: String, + sandbox_domain: String, + status: std::sync::Mutex, + ctx: Arc, +} + +impl std::fmt::Debug for Sandbox { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Sandbox") + .field("sandbox_id", &self.sandbox_id) + .field("template", &self.template_id) + .finish() + } +} + +impl Sandbox { + pub fn sandbox_id(&self) -> &str { + &self.sandbox_id + } + + pub fn template(&self) -> &str { + &self.template_id + } + + pub fn status(&self) -> String { + self.status.lock().unwrap().clone() + } + + /// Preview URL domain for port 80 (e.g., `sb-xxx-p80.workers.opencomputer.dev`). + pub fn domain(&self) -> String { + if self.sandbox_domain.is_empty() { + return String::new(); + } + format!("{}-p80.{}", self.sandbox_id, self.sandbox_domain) + } + + pub fn preview_domain(&self, port: u16) -> String { + if self.sandbox_domain.is_empty() { + return String::new(); + } + format!("{}-p{}.{}", self.sandbox_id, port, self.sandbox_domain) + } + + pub fn files(&self) -> Filesystem { + Filesystem::new(self.ctx.clone(), self.sandbox_id.clone()) + } + + pub fn exec(&self) -> Exec { + Exec::new(self.ctx.clone(), self.sandbox_id.clone()) + } + + /// Backwards-compatible alias for [`Sandbox::exec`]. Mirrors the Python and + /// TypeScript SDKs. + pub fn commands(&self) -> Exec { + self.exec() + } + + pub async fn create(opts: SandboxOpts) -> Result { + let api_url = match opts.api_url.clone() { + Some(u) => resolve_api_url(&u), + None => match std::env::var("OPENCOMPUTER_API_URL") { + Ok(u) => resolve_api_url(&u), + Err(_) => resolve_api_url(DEFAULT_API_URL), + }, + }; + let api_key = opts + .api_key + .or_else(|| std::env::var("OPENCOMPUTER_API_KEY").ok()) + .unwrap_or_default(); + + let mut body = serde_json::Map::new(); + body.insert( + "templateID".into(), + Value::String(opts.template.unwrap_or_else(|| "base".into())), + ); + body.insert("timeout".into(), json!(opts.timeout.unwrap_or(0))); + if let Some(envs) = opts.envs { + body.insert("envs".into(), serde_json::to_value(envs)?); + } + if let Some(metadata) = opts.metadata { + body.insert("metadata".into(), serde_json::to_value(metadata)?); + } + if let Some(c) = opts.cpu_count { + body.insert("cpuCount".into(), json!(c)); + } + if let Some(m) = opts.memory_mb { + body.insert("memoryMB".into(), json!(m)); + } + if let Some(d) = opts.disk_mb { + body.insert("diskMB".into(), json!(d)); + } + if let Some(store) = opts.secret_store { + body.insert("secretStore".into(), Value::String(store)); + } + let has_snapshot = opts.snapshot.is_some(); + if let Some(snap) = opts.snapshot { + body.insert("snapshot".into(), Value::String(snap)); + } + + let http = build_client(has_snapshot)?; + let ctx = Arc::new(ClientCtx { + api_url: api_url.clone(), + api_key: api_key.clone(), + http, + }); + + let resp = ctx + .http + .post(format!("{}/sandboxes", api_url)) + .headers(ctx.headers()) + .json(&Value::Object(body)) + .send() + .await?; + + let data: SandboxData = parse_response(resp, "create sandbox").await?; + let status = data.status.clone(); + Ok(Sandbox { + sandbox_id: data.sandbox_id, + template_id: data.template_id, + sandbox_domain: data.sandbox_domain, + status: std::sync::Mutex::new(if status.is_empty() { + "running".into() + } else { + status + }), + ctx, + }) + } + + pub async fn connect(sandbox_id: impl Into, opts: SandboxOpts) -> Result { + let sandbox_id = sandbox_id.into(); + let api_url = match opts.api_url { + Some(u) => resolve_api_url(&u), + None => match std::env::var("OPENCOMPUTER_API_URL") { + Ok(u) => resolve_api_url(&u), + Err(_) => resolve_api_url(DEFAULT_API_URL), + }, + }; + let api_key = opts + .api_key + .or_else(|| std::env::var("OPENCOMPUTER_API_KEY").ok()) + .unwrap_or_default(); + + let http = build_client(false)?; + let ctx = Arc::new(ClientCtx { + api_url: api_url.clone(), + api_key, + http, + }); + + let resp = ctx + .http + .get(format!("{}/sandboxes/{}", api_url, sandbox_id)) + .headers(ctx.auth_only_headers()) + .send() + .await?; + let data: SandboxData = parse_response(resp, "connect sandbox").await?; + let status = if data.status.is_empty() { + "running".into() + } else { + data.status.clone() + }; + Ok(Sandbox { + sandbox_id: data.sandbox_id, + template_id: data.template_id, + sandbox_domain: data.sandbox_domain, + status: std::sync::Mutex::new(status), + ctx, + }) + } + + pub async fn kill(&self) -> Result<()> { + let resp = self + .ctx + .http + .delete(format!( + "{}/sandboxes/{}", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.auth_only_headers()) + .send() + .await?; + check_ok(resp, "kill sandbox").await?; + *self.status.lock().unwrap() = "stopped".into(); + Ok(()) + } + + pub async fn is_running(&self) -> bool { + match self + .ctx + .http + .get(format!( + "{}/sandboxes/{}", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.auth_only_headers()) + .send() + .await + { + Ok(resp) if resp.status().is_success() => match resp.json::().await { + Ok(data) => { + let running = data.status == "running"; + *self.status.lock().unwrap() = data.status; + running + } + Err(_) => false, + }, + _ => false, + } + } + + pub async fn hibernate(&self) -> Result<()> { + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/hibernate", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.headers()) + .send() + .await?; + check_ok(resp, "hibernate sandbox").await?; + *self.status.lock().unwrap() = "hibernated".into(); + Ok(()) + } + + pub async fn wake(&self, timeout: Option) -> Result<()> { + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/wake", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.headers()) + .json(&json!({ "timeout": timeout.unwrap_or(0) })) + .send() + .await?; + let data: SandboxData = parse_response(resp, "wake sandbox").await?; + *self.status.lock().unwrap() = data.status; + Ok(()) + } + + pub async fn set_timeout(&self, timeout: u64) -> Result<()> { + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/timeout", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.headers()) + .json(&json!({ "timeout": timeout })) + .send() + .await?; + check_ok(resp, "set timeout").await + } + + pub async fn create_checkpoint(&self, name: impl Into) -> Result { + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/checkpoints", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.headers()) + .json(&json!({ "name": name.into() })) + .send() + .await?; + parse_response(resp, "create checkpoint").await + } + + pub async fn list_checkpoints(&self) -> Result> { + let resp = self + .ctx + .http + .get(format!( + "{}/sandboxes/{}/checkpoints", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.auth_only_headers()) + .send() + .await?; + parse_response(resp, "list checkpoints").await + } + + pub async fn restore_checkpoint(&self, checkpoint_id: &str) -> Result<()> { + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/checkpoints/{}/restore", + self.ctx.api_url, self.sandbox_id, checkpoint_id + )) + .headers(self.ctx.headers()) + .send() + .await?; + check_ok(resp, "restore checkpoint").await + } + + pub async fn delete_checkpoint(&self, checkpoint_id: &str) -> Result<()> { + let resp = self + .ctx + .http + .delete(format!( + "{}/sandboxes/{}/checkpoints/{}", + self.ctx.api_url, self.sandbox_id, checkpoint_id + )) + .headers(self.ctx.auth_only_headers()) + .send() + .await?; + if resp.status().as_u16() == 404 { + return Ok(()); + } + check_ok(resp, "delete checkpoint").await + } + + pub async fn create_preview_url( + &self, + port: u16, + domain: Option<&str>, + ) -> Result { + let mut body = serde_json::Map::new(); + body.insert("port".into(), json!(port)); + body.insert("authConfig".into(), json!({})); + if let Some(d) = domain { + body.insert("domain".into(), Value::String(d.into())); + } + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/preview", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.headers()) + .json(&Value::Object(body)) + .send() + .await?; + parse_response(resp, "create preview URL").await + } + + pub async fn list_preview_urls(&self) -> Result> { + let resp = self + .ctx + .http + .get(format!( + "{}/sandboxes/{}/preview", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.auth_only_headers()) + .send() + .await?; + parse_response(resp, "list preview URLs").await + } + + pub async fn delete_preview_url(&self, port: u16) -> Result<()> { + let resp = self + .ctx + .http + .delete(format!( + "{}/sandboxes/{}/preview/{}", + self.ctx.api_url, self.sandbox_id, port + )) + .headers(self.ctx.auth_only_headers()) + .send() + .await?; + if resp.status().as_u16() == 404 { + return Ok(()); + } + check_ok(resp, "delete preview URL").await + } + + pub async fn download_url(&self, path: &str, expires_in: Option) -> Result { + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/files/download-url", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.headers()) + .json(&json!({ "path": path, "expiresIn": expires_in.unwrap_or(3600) })) + .send() + .await?; + let v: Value = parse_response(resp, "download URL").await?; + Ok(v.get("url") + .and_then(|u| u.as_str()) + .unwrap_or_default() + .to_string()) + } + + pub async fn upload_url(&self, path: &str, expires_in: Option) -> Result { + let resp = self + .ctx + .http + .post(format!( + "{}/sandboxes/{}/files/upload-url", + self.ctx.api_url, self.sandbox_id + )) + .headers(self.ctx.headers()) + .json(&json!({ "path": path, "expiresIn": expires_in.unwrap_or(3600) })) + .send() + .await?; + let v: Value = parse_response(resp, "upload URL").await?; + Ok(v.get("url") + .and_then(|u| u.as_str()) + .unwrap_or_default() + .to_string()) + } +} + +fn build_client(long_timeout: bool) -> Result { + let secs = if long_timeout { 300 } else { 30 }; + Client::builder() + .timeout(std::time::Duration::from_secs(secs)) + .build() + .map_err(Into::into) +} + +pub(crate) async fn parse_response( + resp: reqwest::Response, + op: &str, +) -> Result { + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::Api { + status: status.as_u16(), + body: format!("failed to {}: {}", op, body), + }); + } + let bytes = resp.bytes().await?; + if bytes.is_empty() { + // Some endpoints return empty bodies on success. + let v: T = serde_json::from_slice(b"null").map_err(|_| { + Error::other(format!( + "empty response from {} (and not deserializable)", + op + )) + })?; + return Ok(v); + } + serde_json::from_slice(&bytes).map_err(Into::into) +} + +pub(crate) async fn check_ok(resp: reqwest::Response, op: &str) -> Result<()> { + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::Api { + status: status.as_u16(), + body: format!("failed to {}: {}", op, body), + }); + } + Ok(()) +} diff --git a/sdks/rust/src/template.rs b/sdks/rust/src/template.rs new file mode 100644 index 00000000..4d1db1a4 --- /dev/null +++ b/sdks/rust/src/template.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::error::Result; +use crate::sandbox::{check_ok, parse_response, ClientCtx}; +use crate::{resolve_api_url, DEFAULT_API_URL}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemplateInfo { + #[serde(rename = "templateID")] + pub template_id: String, + pub name: String, + #[serde(default = "default_tag")] + pub tag: String, + #[serde(default = "default_status")] + pub status: String, +} + +fn default_tag() -> String { + "latest".into() +} +fn default_status() -> String { + "ready".into() +} + +pub struct Template { + ctx: Arc, +} + +impl Template { + pub fn new(api_key: Option, api_url: Option) -> Result { + let api_url = match api_url { + Some(u) => resolve_api_url(&u), + None => match std::env::var("OPENCOMPUTER_API_URL") { + Ok(u) => resolve_api_url(&u), + Err(_) => resolve_api_url(DEFAULT_API_URL), + }, + }; + let api_key = api_key + .or_else(|| std::env::var("OPENCOMPUTER_API_KEY").ok()) + .unwrap_or_default(); + + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build()?; + Ok(Self { + ctx: Arc::new(ClientCtx { + api_url, + api_key, + http, + }), + }) + } + + pub async fn build(&self, name: &str, dockerfile: &str) -> Result { + let resp = self + .ctx + .http + .post(format!("{}/templates", self.ctx.api_url)) + .headers(self.ctx.headers()) + .json(&json!({ "name": name, "dockerfile": dockerfile })) + .send() + .await?; + parse_response(resp, "build template").await + } + + pub async fn list(&self) -> Result> { + let resp = self + .ctx + .http + .get(format!("{}/templates", self.ctx.api_url)) + .headers(self.ctx.auth_only_headers()) + .send() + .await?; + parse_response(resp, "list templates").await + } + + pub async fn get(&self, name: &str) -> Result { + let resp = self + .ctx + .http + .get(format!("{}/templates/{}", self.ctx.api_url, name)) + .headers(self.ctx.auth_only_headers()) + .send() + .await?; + parse_response(resp, "get template").await + } + + pub async fn delete(&self, name: &str) -> Result<()> { + let resp = self + .ctx + .http + .delete(format!("{}/templates/{}", self.ctx.api_url, name)) + .headers(self.ctx.auth_only_headers()) + .send() + .await?; + check_ok(resp, "delete template").await + } +} diff --git a/sdks/rust/tests/smoke.rs b/sdks/rust/tests/smoke.rs new file mode 100644 index 00000000..3c6185e5 --- /dev/null +++ b/sdks/rust/tests/smoke.rs @@ -0,0 +1,70 @@ +//! Compilation and offline smoke tests. +//! +//! These tests do not hit the network — they verify that the SDK's public +//! types, builders, and url-resolution logic behave as expected. The full +//! integration tests live in `examples/` and require a real backend. + +use opencomputer::{ExecSessionInfo, RunOpts, SandboxOpts}; + +#[test] +fn sandbox_opts_builder_threads_values() { + let opts = SandboxOpts::new() + .template("base") + .timeout(120) + .api_key("osb_test") + .api_url("https://example.com") + .env("FOO", "bar") + .cpu_count(2) + .memory_mb(2048) + .disk_mb(20480); + + assert_eq!(opts.template.as_deref(), Some("base")); + assert_eq!(opts.timeout, Some(120)); + assert_eq!(opts.api_key.as_deref(), Some("osb_test")); + assert_eq!(opts.api_url.as_deref(), Some("https://example.com")); + assert_eq!(opts.cpu_count, Some(2)); + assert_eq!(opts.memory_mb, Some(2048)); + assert_eq!(opts.disk_mb, Some(20480)); + assert_eq!( + opts.envs.unwrap().get("FOO").map(String::as_str), + Some("bar") + ); +} + +#[test] +fn run_opts_builder_threads_values() { + let opts = RunOpts::new() + .timeout(45) + .env("A", "1") + .env("B", "2") + .cwd("/work"); + + assert_eq!(opts.timeout, Some(45)); + assert_eq!(opts.cwd.as_deref(), Some("/work")); + let env = opts.env.unwrap(); + assert_eq!(env.get("A").map(String::as_str), Some("1")); + assert_eq!(env.get("B").map(String::as_str), Some("2")); +} + +#[test] +fn exec_session_info_deserializes_camelcase() { + let json = r#"{ + "sessionID": "sess_123", + "sandboxID": "sb_abc", + "command": "bash", + "args": ["-c", "echo hi"], + "running": true, + "exitCode": null, + "startedAt": "2025-01-01T00:00:00Z", + "attachedClients": 2 + }"#; + + let info: ExecSessionInfo = serde_json::from_str(json).expect("deserialize"); + assert_eq!(info.session_id, "sess_123"); + assert_eq!(info.sandbox_id, "sb_abc"); + assert_eq!(info.command, "bash"); + assert_eq!(info.args, vec!["-c", "echo hi"]); + assert!(info.running); + assert_eq!(info.exit_code, None); + assert_eq!(info.attached_clients, 2); +} From 12323a11687841ff052d14ffa32d61f23ce46097 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 21:29:42 +0000 Subject: [PATCH 2/2] docs: cover Rust SDK; examples: add examples/rust port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs ---- - New navigation group "Rust SDK" in docs/docs.json - New reference pages: docs/reference/rust-sdk/{overview,sandbox,exec,filesystem}.mdx These mirror the structure of the TS/Py reference and document the exact public surface (SandboxOpts, RunOpts, ExecStartOpts, StreamEvent, EntryInfo, ProcessResult, ExecSession, etc.) - Add Rust tabs to the existing CodeGroups in: docs/introduction.mdx docs/quickstart.mdx docs/sandboxes/running-commands.mdx docs/sandboxes/working-with-files.mdx - Root README: drop the inline TOML hint and instead show a real Rust quickstart alongside the TS one. Examples -------- - examples/rust/ — small Cargo project that depends on the local SDK by path. src/main.rs mirrors examples/test.py and examples/test.ts line-for-line so the three feel like the same demo across languages. (Sits at src/main.rs because bin/ is globally gitignored.) SDK --- - Re-export ExecSession, ExecStartOpts, and StreamEvent from lib.rs so the streaming API documented in the new reference pages is actually reachable from outside the crate. - Extend tests/smoke.rs to assert those types are public + matchable. - README.md: add a streaming snippet using the now-exported types. CI -- - Workflow now builds examples/rust as a second cargo project and runs fmt-check on both. Path filters cover both sdks/rust/** and examples/rust/**. All offline checks pass locally on rustc 1.94: cargo fmt --check (sdks/rust + examples/rust) cargo clippy --all-targets -- -D warnings cargo build --all-targets (SDK) cargo build (examples/rust) cargo test --tests (4/4 pass) --- .github/workflows/test-rust-sdk.yml | 26 +++-- README.md | 36 +++++- docs/docs.json | 9 ++ docs/introduction.mdx | 22 ++++ docs/quickstart.mdx | 19 ++++ docs/reference/rust-sdk/exec.mdx | 147 +++++++++++++++++++++++++ docs/reference/rust-sdk/filesystem.mdx | 71 ++++++++++++ docs/reference/rust-sdk/overview.mdx | 60 ++++++++++ docs/reference/rust-sdk/sandbox.mdx | 103 +++++++++++++++++ docs/sandboxes/running-commands.mdx | 28 +++++ docs/sandboxes/working-with-files.mdx | 12 ++ examples/rust/.gitignore | 2 + examples/rust/Cargo.toml | 13 +++ examples/rust/src/main.rs | 83 ++++++++++++++ sdks/rust/README.md | 25 +++++ sdks/rust/src/lib.rs | 4 +- sdks/rust/tests/smoke.rs | 24 +++- 17 files changed, 670 insertions(+), 14 deletions(-) create mode 100644 docs/reference/rust-sdk/exec.mdx create mode 100644 docs/reference/rust-sdk/filesystem.mdx create mode 100644 docs/reference/rust-sdk/overview.mdx create mode 100644 docs/reference/rust-sdk/sandbox.mdx create mode 100644 examples/rust/.gitignore create mode 100644 examples/rust/Cargo.toml create mode 100644 examples/rust/src/main.rs diff --git a/.github/workflows/test-rust-sdk.yml b/.github/workflows/test-rust-sdk.yml index f7de26e9..4d50bdc5 100644 --- a/.github/workflows/test-rust-sdk.yml +++ b/.github/workflows/test-rust-sdk.yml @@ -5,17 +5,15 @@ on: branches: [main] paths: - 'sdks/rust/**' + - 'examples/rust/**' - '.github/workflows/test-rust-sdk.yml' pull_request: paths: - 'sdks/rust/**' + - 'examples/rust/**' - '.github/workflows/test-rust-sdk.yml' workflow_dispatch: -defaults: - run: - working-directory: sdks/rust - jobs: build-and-test: name: Build & Test @@ -29,16 +27,30 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - workspaces: sdks/rust + workspaces: | + sdks/rust + examples/rust + + - name: Format check (SDK) + working-directory: sdks/rust + run: cargo fmt --all -- --check - - name: Format check + - name: Format check (examples) + working-directory: examples/rust run: cargo fmt --all -- --check - name: Clippy + working-directory: sdks/rust run: cargo clippy --all-targets -- -D warnings - - name: Build (lib + examples) + - name: Build SDK (lib + examples + tests) + working-directory: sdks/rust run: cargo build --all-targets + - name: Build examples/rust + working-directory: examples/rust + run: cargo build + - name: Offline tests + working-directory: sdks/rust run: cargo test --tests diff --git a/README.md b/README.md index 613cba2a..5e6409ac 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,9 @@ oc config set api-key YOUR_API_KEY Install the SDK: ```bash -npm install @opencomputer/sdk -# or -pip install opencomputer-sdk -# or, in Cargo.toml -# opencomputer = "0.1" +npm install @opencomputer/sdk # TypeScript +pip install opencomputer-sdk # Python +cargo add opencomputer tokio --features tokio/full # Rust ``` ```typescript @@ -64,6 +62,34 @@ console.log(output.stdout); // hello await sandbox.kill(); ``` +```rust +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; + +#[tokio::main] +async fn main() -> opencomputer::Result<()> { + let sandbox = Sandbox::create(SandboxOpts::new().template("default")).await?; + + let result = sandbox + .commands() + .run("node --version", RunOpts::new()) + .await?; + println!("{}", result.stdout); + + sandbox + .files() + .write("/app/index.js", "console.log(\"hello\")") + .await?; + let output = sandbox + .commands() + .run("node /app/index.js", RunOpts::new()) + .await?; + println!("{}", output.stdout); // hello + + sandbox.kill().await?; + Ok(()) +} +``` + ### Agent SDK Run a full Claude agent session inside the VM with real-time event streaming: diff --git a/docs/docs.json b/docs/docs.json index 0884cdfa..bdab718b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -133,6 +133,15 @@ "reference/python-sdk/secrets" ] }, + { + "group": "Rust SDK", + "pages": [ + "reference/rust-sdk/overview", + "reference/rust-sdk/sandbox", + "reference/rust-sdk/exec", + "reference/rust-sdk/filesystem" + ] + }, { "group": "CLI Reference", "pages": [ diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 00a8a979..e1a8c025 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -32,6 +32,12 @@ npm install @opencomputer/sdk pip install opencomputer-sdk ``` +```toml cargo +# In Cargo.toml +opencomputer = "0.1" +tokio = { version = "1", features = ["full"] } +``` + ```bash CLI # Installs to ~/.local/bin (no sudo). See cli/overview for manual install. curl -fsSL https://raw.githubusercontent.com/diggerhq/opencomputer/main/scripts/install.sh | bash @@ -71,6 +77,22 @@ async def main(): asyncio.run(main()) ``` +```rust Rust +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; + +#[tokio::main] +async fn main() -> opencomputer::Result<()> { + let sandbox = Sandbox::create(SandboxOpts::new()).await?; + let result = sandbox + .commands() + .run("echo 'Hello World from OpenSandbox!'", RunOpts::new()) + .await?; + println!("{}", result.stdout); + sandbox.kill().await?; + Ok(()) +} +``` + ## Next Steps diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index d08f2bed..deee76d9 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -45,6 +45,25 @@ async def main(): asyncio.run(main()) ``` +```rust Rust +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; + +#[tokio::main] +async fn main() -> opencomputer::Result<()> { + let sandbox = Sandbox::create(SandboxOpts::new()).await?; + + // Run a command inside the sandbox + let result = sandbox + .commands() + .run("echo 'Hello World from OpenSandbox!'", RunOpts::new()) + .await?; + println!("{}", result.stdout); + + sandbox.kill().await?; + Ok(()) +} +``` + Running this will print: diff --git a/docs/reference/rust-sdk/exec.mdx b/docs/reference/rust-sdk/exec.mdx new file mode 100644 index 00000000..55f14f75 --- /dev/null +++ b/docs/reference/rust-sdk/exec.mdx @@ -0,0 +1,147 @@ +--- +title: "Exec" +description: "Run commands and stream output" +--- + +`sandbox.commands()` and `sandbox.exec()` return the same `Exec` handle — +the names are kept for parity with the TypeScript and Python SDKs. + +## `exec.run(command, opts: RunOpts) -> Result` + +Run a shell command and wait for completion. Executed via `sh -c`, so pipes, +redirects, and shell expansion work. + +```rust +use opencomputer::RunOpts; + +let result = sandbox + .commands() + .run( + "npm run build", + RunOpts::new() + .cwd("/app") + .env("NODE_ENV", "production") + .timeout(120), + ) + .await?; + +if result.exit_code != 0 { + eprintln!("Build failed: {}", result.stderr); +} +``` + +### `RunOpts` + +| Builder method | Type | Default | Description | +| --------------------- | ------- | ------- | -------------------------------------- | +| `.timeout(secs)` | `u64` | `60` | Timeout in seconds | +| `.env(key, value)` | `&str`, `&str` | — | Inject one env var; chainable | +| `.envs(map)` | `HashMap` | — | Replace the env-var map | +| `.cwd(path)` | `&str` / `String` | — | Working directory | + +### `ProcessResult` + +| Field | Type | Description | +| ------------- | -------- | --------------- | +| `exit_code` | `i32` | Exit code | +| `stdout` | `String` | Captured stdout | +| `stderr` | `String` | Captured stderr | + +## `exec.start(command, opts: ExecStartOpts)` + +Start a long-running command and attach for streaming I/O. Returns +`(ExecSession, mpsc::UnboundedReceiver)`. Drain the receiver +to consume stdout / stderr / exit / scrollback events. + +`exec.background(...)` is an alias. + +```rust +use opencomputer::{ExecStartOpts, StreamEvent}; + +let (session, mut events) = sandbox + .exec() + .start( + "node", + ExecStartOpts::new() + .args(vec!["server.js".into()]) + .cwd("/app") + .env("PORT", "3000"), + ) + .await?; + +tokio::spawn(async move { + while let Some(ev) = events.recv().await { + match ev { + StreamEvent::Stdout(b) => print!("{}", String::from_utf8_lossy(&b)), + StreamEvent::Stderr(b) => eprint!("{}", String::from_utf8_lossy(&b)), + StreamEvent::ScrollbackEnd => eprintln!("--- live output ---"), + StreamEvent::Exit(code) => { + eprintln!("exited: {code}"); + break; + } + } + } +}); + +session.send_stdin("hello\n"); +let code = session.done().await; +``` + +### `ExecStartOpts` + +| Builder method | Type | Description | +| ------------------------------------ | --------- | ------------------------------------------- | +| `.args(vec)` | `Vec` | Command arguments | +| `.env(key, value)` / `.envs(map)` | — | Environment variables | +| `.cwd(path)` | `&str` / `String` | Working directory | +| `.timeout(secs)` | `u64` | Timeout in seconds | +| `max_run_after_disconnect` | `u64` | Seconds to keep running after disconnect | + +### `ExecSession` + +| Member | Description | +| ---------------------------------- | -------------------------------------------------------- | +| `.session_id` | `String` — exec session ID | +| `.sandbox_id` | `String` | +| `.done().await` | Wait for the process to exit; returns the exit code | +| `.send_stdin(data)` | Write to the process stdin | +| `.kill(signal: Option).await` | Kill the process. Default signal is `SIGKILL` (9) | +| `.close().await` | Detach from the WebSocket without killing the process | + +### `StreamEvent` + +| Variant | Payload | Description | +| ---------------------- | ----------- | ---------------------------------------------- | +| `Stdout(Vec)` | bytes | Stdout chunk | +| `Stderr(Vec)` | bytes | Stderr chunk | +| `ScrollbackEnd` | — | End of historical replay; live output begins | +| `Exit(i32)` | exit code | Always the last event | + +## Managing sessions + +```rust +let sessions = sandbox.exec().list().await?; +for s in sessions { + let status = if s.running { + "running".to_string() + } else { + format!("exited ({:?})", s.exit_code) + }; + println!("{} {} clients={}", s.session_id, status, s.attached_clients); +} + +sandbox.exec().kill(&session_id, Some(15)).await?; // SIGTERM +``` + +## `exec.attach(session_id)` + +Re-attach to a running exec session. Same return type as `start()`. Server +replays scrollback first, sends a `StreamEvent::ScrollbackEnd`, then streams +live output. + + + CLI equivalent: [`oc exec`](/cli/exec). + Other SDKs: [TypeScript](/reference/typescript-sdk/exec) · + [Python](/reference/python-sdk/exec) · + [HTTP API](/api-reference/exec/run). + diff --git a/docs/reference/rust-sdk/filesystem.mdx b/docs/reference/rust-sdk/filesystem.mdx new file mode 100644 index 00000000..5a9c5e1a --- /dev/null +++ b/docs/reference/rust-sdk/filesystem.mdx @@ -0,0 +1,71 @@ +--- +title: "Filesystem" +description: "Read, write, and manage files inside a sandbox" +--- + +`sandbox.files()` returns a `Filesystem` handle. All paths are absolute +inside the sandbox. + +```rust +sandbox.files().write("/tmp/hi.txt", "hello").await?; +let content = sandbox.files().read("/tmp/hi.txt").await?; +``` + +## Methods + +### `files.read(path) -> Result` + +Read a file as UTF-8 text. + +### `files.read_bytes(path) -> Result>` + +Read a file as raw bytes (binary-safe). + +### `files.write(path, content) -> Result<()>` + +Overwrite a file. `content: impl Into>` — `&str`, `String`, `Vec`, +and `&[u8]` all work. + +```rust +sandbox.files().write("/tmp/data.bin", vec![0u8, 1, 2, 3]).await?; +sandbox.files().write("/tmp/note.txt", "hi").await?; +``` + +### `files.list(path) -> Result>` + +List a directory. Returns an empty `Vec` if the directory is empty. + +```rust +for entry in sandbox.files().list("/tmp").await? { + let kind = if entry.is_dir { "d" } else { "-" }; + println!("{} {} ({} bytes)", kind, entry.name, entry.size); +} +``` + +### `EntryInfo` + +| Field | Type | Description | +| ---------- | ------- | -------------------------------- | +| `name` | `String`| File / directory name | +| `is_dir` | `bool` | `true` if directory | +| `path` | `String`| Full path inside the sandbox | +| `size` | `u64` | Size in bytes (0 for directories) | + +### `files.make_dir(path) -> Result<()>` + +Create a directory. Use `sandbox.commands().run("mkdir -p ...")` if you need +intermediate directories created. + +### `files.remove(path) -> Result<()>` + +Remove a file or directory (recursive for directories). + +### `files.exists(path) -> bool` + +Check whether a path exists. Returns `false` on any error (does not throw). + + + Other SDKs: [TypeScript](/reference/typescript-sdk/filesystem) · + [Python](/reference/python-sdk/filesystem) · + [HTTP API](/api-reference/files/read). + diff --git a/docs/reference/rust-sdk/overview.mdx b/docs/reference/rust-sdk/overview.mdx new file mode 100644 index 00000000..8e855000 --- /dev/null +++ b/docs/reference/rust-sdk/overview.mdx @@ -0,0 +1,60 @@ +--- +title: "Rust SDK" +description: "Complete Rust SDK reference" +--- + +## Installation + +```toml +# Cargo.toml +[dependencies] +opencomputer = "0.1" +tokio = { version = "1", features = ["full"] } +``` + +```rust +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; +``` + +The SDK is async and built on `tokio` + `reqwest`. WebSocket streaming +(used by `exec.start` / `exec.attach`) goes through `tokio-tungstenite`. + +## Quick Example + +```rust +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; + +#[tokio::main] +async fn main() -> opencomputer::Result<()> { + let sandbox = Sandbox::create(SandboxOpts::new().template("base")).await?; + + let result = sandbox.commands().run("echo hello", RunOpts::new()).await?; + println!("{}", result.stdout); + + sandbox.files().write("/tmp/hi.txt", "hello").await?; + let _ = sandbox.files().read("/tmp/hi.txt").await?; + + sandbox.kill().await?; + Ok(()) +} +``` + +## Modules + + + + Create, connect, and manage sandbox lifecycle + + + Run commands and stream output + + + Read, write, and manage files + + + + + Agent / PTY / shell / secret-store / usage modules are not yet exposed in + the Rust SDK. Track the [Rust SDK milestone on + GitHub](https://github.com/diggerhq/opencomputer/issues) for parity progress. + diff --git a/docs/reference/rust-sdk/sandbox.mdx b/docs/reference/rust-sdk/sandbox.mdx new file mode 100644 index 00000000..1320260f --- /dev/null +++ b/docs/reference/rust-sdk/sandbox.mdx @@ -0,0 +1,103 @@ +--- +title: "Sandbox" +description: "Create, connect, and manage sandbox lifecycle" +--- + +## Construction + +### `Sandbox::create(opts: SandboxOpts) -> Result` + +Create a new sandbox. [HTTP API →](/api-reference/sandboxes/create) + +`SandboxOpts` is a builder. All fields are optional; defaults match the +TypeScript and Python SDKs. + +| Builder method | Type | Default | Description | +| -------------------------------- | ----------------------------- | ------- | -------------------------------------------------------- | +| `.template(name)` | `&str` / `String` | `"base"`| Template name | +| `.timeout(secs)` | `u64` | `0` | Idle timeout. `0` = persistent, never auto-hibernates | +| `.api_key(key)` | `&str` / `String` | env | Falls back to `OPENCOMPUTER_API_KEY` | +| `.api_url(url)` | `&str` / `String` | env | Falls back to `OPENCOMPUTER_API_URL` | +| `.env(key, value)` | `&str`, `&str` | — | Inject one env var; chainable | +| `.envs(map)` | `HashMap` | — | Replace the env-var map | +| `.metadata(map)` | `HashMap` | — | Arbitrary metadata | +| `.cpu_count(n)` | `u32` | — | CPU cores | +| `.memory_mb(mb)` | `u64` | — | Memory in MB | +| `.disk_mb(mb)` | `u64` | — | Workspace disk size in MB | +| `.secret_store(name)` | `&str` / `String` | — | Secret store — resolves encrypted secrets + allowlist | +| `.snapshot(name)` | `&str` / `String` | — | Pre-built snapshot to fork from | + +```rust +use opencomputer::{Sandbox, SandboxOpts}; + +let sandbox = Sandbox::create( + SandboxOpts::new() + .template("base") + .timeout(120) + .env("FOO", "bar"), +).await?; +``` + +### `Sandbox::connect(sandbox_id, opts: SandboxOpts) -> Result` + +Re-attach to an existing sandbox. Only `api_key` / `api_url` are read from +`opts`. + +```rust +let sandbox = Sandbox::connect("sb_abc123", SandboxOpts::new()).await?; +``` + +## Accessors + +| Method | Returns | Description | +| -------------------- | ------------- | ------------------------------------------ | +| `.sandbox_id()` | `&str` | Sandbox ID | +| `.template()` | `&str` | Template ID | +| `.status()` | `String` | Current cached status | +| `.domain()` | `String` | Preview URL domain for port 80 (or empty) | +| `.preview_domain(port)` | `String` | Preview URL domain for a specific port | +| `.files()` | `Filesystem` | See [Filesystem](/reference/rust-sdk/filesystem) | +| `.exec()` | `Exec` | See [Exec](/reference/rust-sdk/exec) | +| `.commands()` | `Exec` | Alias for `.exec()` (parity with TS/Py) | + +## Lifecycle + +| Method | Description | +| ----------------------------------------- | ------------------------------------------------------------ | +| `.kill() -> Result<()>` | Delete the sandbox | +| `.is_running() -> bool` | Refresh and report status | +| `.hibernate() -> Result<()>` | Hibernate the VM | +| `.wake(timeout) -> Result<()>` | Wake from hibernation. `timeout: Option` (`None` = 0) | +| `.set_timeout(secs) -> Result<()>` | Update the idle timeout | + +## Checkpoints + +| Method | Description | +| ------------------------------------------------------- | ------------------------------------------ | +| `.create_checkpoint(name) -> Result` | Snapshot the running VM | +| `.list_checkpoints() -> Result>` | List all checkpoints for this sandbox | +| `.restore_checkpoint(id) -> Result<()>` | Roll back in place | +| `.delete_checkpoint(id) -> Result<()>` | Delete (404 is treated as success) | + +## Preview URLs + +| Method | Description | +| ----------------------------------------------------------- | ------------------------------------------ | +| `.create_preview_url(port, domain) -> Result` | Expose `port` externally | +| `.list_preview_urls() -> Result>` | All preview URLs for this sandbox | +| `.delete_preview_url(port) -> Result<()>` | Remove the preview URL | + +## Signed URLs + +| Method | Description | +| --------------------------------------------------------- | ---------------------------------------- | +| `.download_url(path, expires_in) -> Result` | Public signed download URL | +| `.upload_url(path, expires_in) -> Result` | Public signed upload URL (PUT) | + +`expires_in: Option` — `None` defaults to 3600 seconds (max 86400). + +## Errors + +All fallible methods return `opencomputer::Result` (alias for +`Result`). The `Error` enum covers HTTP, WebSocket, +JSON, URL, and API-status errors. diff --git a/docs/sandboxes/running-commands.mdx b/docs/sandboxes/running-commands.mdx index 950421bc..1503741c 100644 --- a/docs/sandboxes/running-commands.mdx +++ b/docs/sandboxes/running-commands.mdx @@ -38,6 +38,15 @@ async with await Sandbox.create() as sandbox: print(result.stdout) # "Hello, World!\n" ``` +```rust Rust +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; + +let sandbox = Sandbox::create(SandboxOpts::new()).await?; +let result = sandbox.exec().run("echo Hello, World!", RunOpts::new()).await?; +println!("{}", result.stdout); // "Hello, World!\n" +sandbox.kill().await?; +``` + ## Quick Commands: `exec.run()` @@ -71,6 +80,25 @@ if result.exit_code != 0: print("Build failed:", result.stderr) ``` +```rust Rust +use opencomputer::RunOpts; + +let result = sandbox + .exec() + .run( + "npm run build", + RunOpts::new() + .cwd("/app") + .env("NODE_ENV", "production") + .timeout(120), + ) + .await?; + +if result.exit_code != 0 { + eprintln!("Build failed: {}", result.stderr); +} +``` + ### Parameters diff --git a/docs/sandboxes/working-with-files.mdx b/docs/sandboxes/working-with-files.mdx index 16a94a6d..000dbb83 100644 --- a/docs/sandboxes/working-with-files.mdx +++ b/docs/sandboxes/working-with-files.mdx @@ -26,6 +26,18 @@ async with await Sandbox.create() as sandbox: print(content) # "Hello, World!" ``` +```rust Rust +use opencomputer::{Sandbox, SandboxOpts}; + +let sandbox = Sandbox::create(SandboxOpts::new()).await?; + +sandbox.files().write("/app/hello.txt", "Hello, World!").await?; +let content = sandbox.files().read("/app/hello.txt").await?; +println!("{}", content); // "Hello, World!" + +sandbox.kill().await?; +``` + ## Reading Files diff --git a/examples/rust/.gitignore b/examples/rust/.gitignore new file mode 100644 index 00000000..96ef6c0b --- /dev/null +++ b/examples/rust/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/examples/rust/Cargo.toml b/examples/rust/Cargo.toml new file mode 100644 index 00000000..1be399d0 --- /dev/null +++ b/examples/rust/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "opencomputer-examples" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +opencomputer = { path = "../../sdks/rust" } +tokio = { version = "1", features = ["full"] } + +[[bin]] +name = "test" +path = "src/main.rs" diff --git a/examples/rust/src/main.rs b/examples/rust/src/main.rs new file mode 100644 index 00000000..796fb8af --- /dev/null +++ b/examples/rust/src/main.rs @@ -0,0 +1,83 @@ +//! Mirrors `examples/test.py` and `examples/test.ts`. +//! +//! Run with: +//! OPENCOMPUTER_API_KEY=... cargo run --bin test + +use opencomputer::{RunOpts, Sandbox, SandboxOpts}; + +#[tokio::main] +async fn main() -> opencomputer::Result<()> { + println!("Creating sandbox..."); + let sb = Sandbox::create( + SandboxOpts::new() + .template("base") + .timeout(3600) + .api_url("https://app.opencomputer.dev"), + ) + .await?; + println!("Sandbox created: {}", sb.sandbox_id()); + + // Run commands + println!("\n--- Commands ---"); + let result = sb + .commands() + .run("echo hello from rust sdk", RunOpts::new()) + .await?; + println!("stdout: {}", result.stdout.trim()); + println!("exit code: {}", result.exit_code); + + let uname = sb.commands().run("uname -a", RunOpts::new()).await?; + println!("uname: {}", uname.stdout.trim()); + + // Filesystem + println!("\n--- Filesystem ---"); + sb.files() + .write("/tmp/greeting.txt", "Hello from Rust SDK!") + .await?; + let content = sb.files().read("/tmp/greeting.txt").await?; + println!("file content: {}", content); + + let exists = sb.files().exists("/tmp/greeting.txt").await; + println!("file exists: {}", exists); + + sb.files().make_dir("/tmp/mydir").await?; + sb.files() + .write("/tmp/mydir/test.py", "print(\"hello from python\")") + .await?; + + let entries = sb.files().list("/tmp").await?; + println!("ls /tmp:"); + for entry in entries { + let kind = if entry.is_dir { "d" } else { "-" }; + println!(" {} {}", kind, entry.name); + } + + // Run a multi-line script + println!("\n--- Script execution ---"); + let script_body = [ + "#!/bin/bash", + "echo \"Current directory: $(pwd)\"", + "echo \"User: $(whoami)\"", + "echo \"Date: $(date)\"", + "echo \"Files in /tmp:\"", + "ls /tmp", + ] + .join("\n"); + sb.files().write("/tmp/script.sh", script_body).await?; + + let script = sb + .commands() + .run("bash /tmp/script.sh", RunOpts::new()) + .await?; + println!("{}", script.stdout); + + // Check sandbox status + println!("--- Status ---"); + let running = sb.is_running().await; + println!("running: {}", running); + + // Clean up + sb.kill().await?; + println!("\nSandbox killed. Done!"); + Ok(()) +} diff --git a/sdks/rust/README.md b/sdks/rust/README.md index f76a2512..93661ece 100644 --- a/sdks/rust/README.md +++ b/sdks/rust/README.md @@ -43,6 +43,31 @@ async fn main() -> opencomputer::Result<()> { | `.api_url(..)` | `OPENCOMPUTER_API_URL` | `https://app.opencomputer.dev` | | `.api_key(..)` | `OPENCOMPUTER_API_KEY` | (none) | +## Streaming output + +```rust +use opencomputer::{ExecStartOpts, StreamEvent}; + +let (session, mut events) = sandbox + .exec() + .start("node", ExecStartOpts::new().args(vec!["server.js".into()])) + .await?; + +while let Some(ev) = events.recv().await { + match ev { + StreamEvent::Stdout(b) => print!("{}", String::from_utf8_lossy(&b)), + StreamEvent::Stderr(b) => eprint!("{}", String::from_utf8_lossy(&b)), + StreamEvent::Exit(code) => { + println!("exited: {code}"); + break; + } + StreamEvent::ScrollbackEnd => {} + } +} + +let _ = session.done().await; +``` + ## Examples The `examples/` directory mirrors the test scripts in the Python and TypeScript SDKs: diff --git a/sdks/rust/src/lib.rs b/sdks/rust/src/lib.rs index 37aa6c04..155d4704 100644 --- a/sdks/rust/src/lib.rs +++ b/sdks/rust/src/lib.rs @@ -24,7 +24,9 @@ mod sandbox; mod template; pub use error::{Error, Result}; -pub use exec::{Exec, ExecSessionInfo, ProcessResult, RunOpts}; +pub use exec::{ + Exec, ExecSession, ExecSessionInfo, ExecStartOpts, ProcessResult, RunOpts, StreamEvent, +}; pub use filesystem::{EntryInfo, Filesystem}; pub use sandbox::{CheckpointInfo, PatchInfo, PatchResult, PreviewURLResult, Sandbox, SandboxOpts}; pub use template::{Template, TemplateInfo}; diff --git a/sdks/rust/tests/smoke.rs b/sdks/rust/tests/smoke.rs index 3c6185e5..8242c4a4 100644 --- a/sdks/rust/tests/smoke.rs +++ b/sdks/rust/tests/smoke.rs @@ -4,7 +4,7 @@ //! types, builders, and url-resolution logic behave as expected. The full //! integration tests live in `examples/` and require a real backend. -use opencomputer::{ExecSessionInfo, RunOpts, SandboxOpts}; +use opencomputer::{ExecSessionInfo, ExecStartOpts, RunOpts, SandboxOpts, StreamEvent}; #[test] fn sandbox_opts_builder_threads_values() { @@ -46,6 +46,28 @@ fn run_opts_builder_threads_values() { assert_eq!(env.get("B").map(String::as_str), Some("2")); } +#[test] +fn exec_start_opts_and_stream_event_are_public() { + // Compile-time proof that the public surface advertised in the docs + // (and used by the streaming examples) is actually exported. + let _ = ExecStartOpts::new() + .args(vec!["-c".into(), "echo hi".into()]) + .env("FOO", "bar") + .cwd("/tmp") + .timeout(10); + + fn classify(e: StreamEvent) -> &'static str { + match e { + StreamEvent::Stdout(_) => "stdout", + StreamEvent::Stderr(_) => "stderr", + StreamEvent::ScrollbackEnd => "scrollback_end", + StreamEvent::Exit(_) => "exit", + } + } + assert_eq!(classify(StreamEvent::Stdout(vec![1, 2, 3])), "stdout"); + assert_eq!(classify(StreamEvent::Exit(42)), "exit"); +} + #[test] fn exec_session_info_deserializes_camelcase() { let json = r#"{