boxxkite-client 0.8.0

Rust client for a hosted boxxkite control-plane (sandboxes, exec, files, audit log, human takeover)
Documentation
mod common;

use boxxkite_client::StartProcessOptions;
use serde_json::json;
use wiremock::matchers::{body_json, method, path, query_param};
use wiremock::{Mock, ResponseTemplate};

#[tokio::test]
async fn start_process_sends_only_set_optional_fields() {
    let server = common::mock_server().await;
    let client = common::client_for(&server);

    Mock::given(method("POST"))
        .and(path("/v1/sandboxes/sess-1/processes"))
        .and(body_json(json!({
            "command": "npm run dev",
            "max_runtime_seconds": 3600
        })))
        .respond_with(ResponseTemplate::new(201).set_body_json(json!({
            "process_id": "proc_1", "status": "running", "started_at": "2026-01-01T00:00:00Z"
        })))
        .mount(&server)
        .await;

    let result = client
        .start_process("sess-1", "npm run dev", StartProcessOptions::new())
        .await
        .expect("start_process should succeed");

    assert_eq!(result.process_id, "proc_1");
    assert_eq!(result.status, "running");
}

#[tokio::test]
async fn start_process_sends_all_set_optional_fields() {
    let server = common::mock_server().await;
    let client = common::client_for(&server);

    Mock::given(method("POST"))
        .and(path("/v1/sandboxes/sess-1/processes"))
        .and(body_json(json!({
            "command": "npm run dev",
            "description": "dev server",
            "max_runtime_seconds": 1800,
            "expose_port": 3000
        })))
        .respond_with(ResponseTemplate::new(201).set_body_json(json!({
            "process_id": "proc_2", "status": "running", "started_at": "2026-01-01T00:00:00Z"
        })))
        .mount(&server)
        .await;

    let options = StartProcessOptions::new()
        .description("dev server")
        .max_runtime_seconds(1800)
        .expose_port(3000);

    client
        .start_process("sess-1", "npm run dev", options)
        .await
        .expect("start_process should succeed");
}

#[tokio::test]
async fn list_processes_returns_all_tracked_processes() {
    let server = common::mock_server().await;
    let client = common::client_for(&server);

    Mock::given(method("GET"))
        .and(path("/v1/sandboxes/sess-1/processes"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "processes": [
                {
                    "process_id": "proc_1", "command": "npm run dev", "description": null,
                    "status": "running", "started_at": "2026-01-01T00:00:00Z",
                    "exit_code": null, "expose_port": 3000
                },
                {
                    "process_id": "proc_2", "command": "sleep 1", "description": "quick nap",
                    "status": "exited", "started_at": "2026-01-01T00:00:00Z",
                    "exit_code": 0, "expose_port": null
                }
            ]
        })))
        .mount(&server)
        .await;

    let result = client
        .list_processes("sess-1")
        .await
        .expect("list_processes should succeed");

    assert_eq!(result.processes.len(), 2);
    assert_eq!(result.processes[0].expose_port, Some(3000));
    assert_eq!(result.processes[1].expose_port, None);
    assert_eq!(result.processes[1].exit_code, Some(0));
}

#[tokio::test]
async fn get_process_output_sends_since_offset_as_query_param() {
    let server = common::mock_server().await;
    let client = common::client_for(&server);

    Mock::given(method("GET"))
        .and(path("/v1/sandboxes/sess-1/processes/proc_1/output"))
        .and(query_param("since_offset", "42"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "status": "running", "stdout_chunk": "hello\n", "next_offset": 48,
            "truncated": false, "exit_code": null
        })))
        .mount(&server)
        .await;

    let result = client
        .get_process_output("sess-1", "proc_1", 42)
        .await
        .expect("get_process_output should succeed");

    assert_eq!(result.stdout_chunk, "hello\n");
    assert_eq!(result.next_offset, 48);
}

#[tokio::test]
async fn send_process_input_returns_bytes_written() {
    let server = common::mock_server().await;
    let client = common::client_for(&server);

    Mock::given(method("POST"))
        .and(path("/v1/sandboxes/sess-1/processes/proc_1/input"))
        .and(body_json(json!({"data": "y\n"})))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({"bytes_written": 2})))
        .mount(&server)
        .await;

    let result = client
        .send_process_input("sess-1", "proc_1", "y\n")
        .await
        .expect("send_process_input should succeed");

    assert_eq!(result.bytes_written, 2);
}

#[tokio::test]
async fn stop_process_returns_exit_code() {
    let server = common::mock_server().await;
    let client = common::client_for(&server);

    Mock::given(method("POST"))
        .and(path("/v1/sandboxes/sess-1/processes/proc_1/stop"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "status": "stopped", "exit_code": 0
        })))
        .mount(&server)
        .await;

    let result = client
        .stop_process("sess-1", "proc_1")
        .await
        .expect("stop_process should succeed");

    assert_eq!(result.status, "stopped");
    assert_eq!(result.exit_code, Some(0));
}

#[tokio::test]
async fn get_process_output_maps_unknown_process_id_to_404() {
    let server = common::mock_server().await;
    let client = common::client_for(&server);

    Mock::given(method("GET"))
        .and(path("/v1/sandboxes/sess-1/processes/proc_missing/output"))
        .respond_with(ResponseTemplate::new(404).set_body_json(json!({
            "error": {"code": "not_found", "message": "Process not found"}
        })))
        .mount(&server)
        .await;

    let err = client
        .get_process_output("sess-1", "proc_missing", 0)
        .await
        .unwrap_err();

    assert_eq!(err.code(), Some("not_found"));
    assert_eq!(err.status(), Some(404));
}

#[tokio::test]
async fn stream_process_output_yields_output_then_exit() {
    use futures_util::StreamExt;
    use boxxkite_client::ProcessStreamEvent;

    let server = common::mock_server().await;
    let client = common::client_for(&server);

    let body = concat!(
        "event: output\n",
        "data: {\"type\":\"output\",\"stdout_chunk\":\"hi\",\"next_offset\":2,\"truncated\":false}\n\n",
        "event: exit\n",
        "data: {\"type\":\"exit\",\"status\":\"exited\",\"exit_code\":0}\n\n",
    );

    Mock::given(method("GET"))
        .and(path("/v1/sandboxes/sess-1/processes/proc-1/stream"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("Content-Type", "text/event-stream")
                .set_body_raw(body, "text/event-stream"),
        )
        .mount(&server)
        .await;

    let mut stream = client.stream_process_output("sess-1", "proc-1", 0);
    let first = stream.next().await.expect("first event").expect("first ok");
    match first {
        ProcessStreamEvent::Output { stdout_chunk, next_offset, truncated } => {
            assert_eq!(stdout_chunk, "hi");
            assert_eq!(next_offset, 2);
            assert!(!truncated);
        }
        other => panic!("expected Output, got {other:?}"),
    }
    let second = stream.next().await.expect("second event").expect("second ok");
    match second {
        ProcessStreamEvent::Exit { status, exit_code } => {
            assert_eq!(status, "exited");
            assert_eq!(exit_code, Some(0));
        }
        other => panic!("expected Exit, got {other:?}"),
    }
}

#[tokio::test]
async fn stream_process_output_maps_404_before_streaming() {
    use futures_util::StreamExt;

    let server = common::mock_server().await;
    let client = common::client_for(&server);

    Mock::given(method("GET"))
        .and(path("/v1/sandboxes/sess-1/processes/missing/stream"))
        .respond_with(ResponseTemplate::new(404).set_body_json(json!({
            "error": {"code": "not_found", "message": "Process not found"}
        })))
        .mount(&server)
        .await;

    let mut stream = client.stream_process_output("sess-1", "missing", 0);
    let first = stream.next().await.expect("stream should yield an error");
    let err = first.unwrap_err();
    assert_eq!(err.code(), Some("not_found"));
    assert_eq!(err.status(), Some(404));
}