clawdforge/clients/rust
Kayos ebbd7cc553 clients/rust: apply audit findings — UTF-8 panic + Debug redaction + path-traversal (062d405 → next)
HIGH:
- H1: truncate() uses floor_char_boundary (was panicking on multibyte boundaries)
- H2: hand-written Debug for Client/ClientBuilder/AppToken redacts bearer (was leaking via dbg!()/tracing)
- H3: revoke_token validates name client-side (rejects path traversal sequences)

MEDIUM:
- M1: From<reqwest::Error> maps timeouts to Error::Timeout (was always Transport)
- M2: revoke_token accepts 2xx empty body (was rejecting RFC-correct 204 No Content)
- M3: tests use assert!(matches!) instead of matches!().then_some().unwrap()
- M4: ClientBuilder.max_upload_bytes optional cap
- M5: lib.rs deny(missing_docs)

LOW:
- L1: cargo fmt
- L2: drop dead AUTHORIZATION import

Audit: memory/clawdforge-audits/rust-062d405.md
2026-04-28 23:26:22 -07:00
..
examples clients/rust: apply audit findings — UTF-8 panic + Debug redaction + path-traversal (062d405 → next) 2026-04-28 23:26:22 -07:00
src clients/rust: apply audit findings — UTF-8 panic + Debug redaction + path-traversal (062d405 → next) 2026-04-28 23:26:22 -07:00
tests clients/rust: apply audit findings — UTF-8 panic + Debug redaction + path-traversal (062d405 → next) 2026-04-28 23:26:22 -07:00
Cargo.toml clients/rust: initial Rust SDK for clawdforge 2026-04-28 22:35:16 -07:00
README.md clients/rust: initial Rust SDK for clawdforge 2026-04-28 22:35:16 -07:00

clawdforge — Rust client

Async Rust SDK for clawdforge, a small LAN-only HTTP service that wraps claude -p subprocess calls behind a bearer-token-gated REST API.

  • Tokio + reqwest under the hood
  • serde + serde_json types
  • Streaming multipart upload (tokio::fs::File, no full-file buffer)
  • Builder pattern for configuration
  • Typed RunResult::as_json::<T>() and as_text() helpers over a serde_json::Value payload

Install

This crate is not on crates.io. Pull it directly from the upstream git host:

cargo add clawdforge --git https://gitea.sulkta.com/Sulkta-Coop/clawdforge --rev <pin>

Or pin manually in Cargo.toml:

[dependencies]
clawdforge = { git = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge", rev = "<pin>" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

For an in-repo workspace consumer, point at the clients/rust/ path:

clawdforge = { path = "../clawdforge/clients/rust" }

Quickstart

use clawdforge::{Client, RunRequest};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .base_url("http://localhost:8800")
        .token("cf_xxxxxxxxxxxxxxxx")
        .build()?;

    // Liveness — does not require a token, but sends one if configured.
    let h = client.healthz().await?;
    println!("claude present: {} version: {:?}", h.claude_present, h.claude_version);

    // Run a prompt. `result` is a serde_json::Value — narrow via .as_json::<T>().
    let r = client.run(RunRequest {
        prompt: "Reply with JSON: {\"hello\":\"world\"}".into(),
        model: Some("sonnet".into()),
        timeout_secs: Some(30),
        ..Default::default()
    }).await?;

    #[derive(serde::Deserialize)]
    struct Hello { hello: String }
    let typed: Hello = r.as_json()?;
    println!("{}", typed.hello);

    // Upload a file, then attach it to a follow-up run.
    let ft = client.upload_file("./recipe.png", Some(3600)).await?;
    let r2 = client.run(RunRequest {
        prompt: "extract recipe data".into(),
        files: Some(vec![ft.file_token]),
        ..Default::default()
    }).await?;
    println!("{:?}", r2.result);

    Ok(())
}

Public API

Client::builder()

Builder for the HTTP client.

Method Purpose
.base_url(url) Required. e.g. "http://localhost:8800".
.token(t) App bearer for /run, /files.
.admin_token(t) Admin bearer for /admin/*.
.timeout(Duration) Per-request timeout (default 120 s).
.user_agent(s) Override User-Agent header.
.danger_accept_invalid_certs(bool) Skip TLS verify (off by default).
.build() Returns Result<Client, Error>.

Client async methods

Method Endpoint Notes
healthz() GET /healthz Returns Healthz.
run(RunRequest) POST /run Returns RunResult. 502 surfaces as Error::Api.
upload_file(path, ttl_secs) POST /files Streams from disk; returns FileToken.
create_token(TokenCreateRequest) POST /admin/tokens Admin only. Returns AppToken.
list_tokens() GET /admin/tokens Admin only. Returns TokenList.
revoke_token(name) DELETE /admin/tokens/{name} Admin only.

RunResult helpers

let r = client.run(req).await?;

// Try a typed shape.
#[derive(serde::Deserialize)]
struct Recipe { name: String, qty: u32 }
let recipe: Recipe = r.as_json()?;

// Or fall back to a string when the model declined to emit JSON.
if let Some(text) = r.as_text() {
    println!("{text}");
}

r.result itself is serde_json::Value if you need to branch on shape.

Error model

pub enum Error {
    Auth(String),                // missing/invalid bearer, 401, 403
    Api { status: u16, body: String },  // any other non-2xx
    Transport(reqwest::Error),   // connect, TLS, read, request timeout
    Json(serde_json::Error),     // decode failure on a 2xx body
    Io(std::io::Error),          // local file open in upload_file
    Timeout(String),             // explicit deadline (reserved)
    Config(String),              // builder misconfiguration
}

A 502 from /run lands in Error::Api { status: 502, body } — the body is the JSON failure envelope. Recover the structured form with:

let parsed: clawdforge::RunFailure = serde_json::from_str(&body)?;

Wire format

clawdforge speaks snake_case JSON end-to-end. The structs in this crate match that without #[serde(rename_all = "camelCase")]. If a future endpoint exposes camelCase, prefer per-field #[serde(rename = "...")] over a blanket container attribute so both styles can coexist.

Examples

CLAWDFORGE_URL=http://localhost:8800 \
CLAWDFORGE_TOKEN=cf_xxxx \
cargo run --example basic

Optional file demo:

CLAWDFORGE_DEMO_FILE=./some.png cargo run --example basic

Development

cargo build --release
cargo test --all
cargo clippy --all-targets -- -D warnings
cargo build --examples

Tests use wiremock — no live clawdforge needed.

License

MIT.