v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan

The Rust SDK already existed at Sulkta-Coop/clawdforge clients/rust/ — async,
reqwest-based, bearer-auth, exposes Client::run() + Session for multi-turn.
Vendoring it into vendor/clawdforge so skald is self-contained: no
git-submodule + no needing the clawdforge repo cloned next to skald.
Trade-off accepted: updates require manual re-copy until both sides
stabilize and we publish to a private cargo registry.

What landed:

- vendor/clawdforge/ — full SDK source from Sulkta-Coop/clawdforge HEAD.
  Pinned in skald-core/Cargo.toml as a path dep.
- skald-core/src/forge.rs — three-pass orchestration shell. Forge wraps
  clawdforge::Client; generate() / cleanup() / audit() each build a
  RunRequest with the right system prompt + model alias (always opus),
  call client.run(), return a PassOutput.
  Prompt templates are TODO stubs (SYSTEM_GEN_TODO etc) — filling in the
  actual prose-craft prompts is its own deep session.
- skald-core/src/config.rs — ForgeConfig { base_url, app_token, model }.
  Resolved by the binary from env (CLAWDFORGE_URL + CLAWDFORGE_TOKEN);
  lib stays env-agnostic.
- skald-core::AuditFinding + AuditResponse — parse shape for what the
  third-Opus canon audit returns, ready to map onto audit_findings rows.
- docs/tts-pipeline.md — full plan for v0.2 narration + post-TTS audit
  chain. Whisper-large-v3 STT does text-to-text verification on every
  render; an optional Gemini Flash audio pass catches subjective issues
  (prosody, tone) Whisper can't see. Reroll loop on crit findings.

What's still stubbed:

- Prompt templates in forge.rs (gen / cleanup / audit) — placeholders
  that describe the role but don't constrain output shape yet.
- context.rs (assemble the LLM context blob from DB rows) — entire module
  TBD.
- No CLI subcommand yet for invoking forge — that comes after context.rs.

Naming note: in Rust 2024 'gen' is a reserved keyword (for generators),
so the method is Forge::generate(), not Forge::gen().
This commit is contained in:
Kayos 2026-05-13 10:18:56 -07:00
parent 4a91e0738d
commit f71b533e52
17 changed files with 3340 additions and 19 deletions

30
vendor/clawdforge/Cargo.toml vendored Normal file
View file

@ -0,0 +1,30 @@
[package]
name = "clawdforge"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Async Rust client for the clawdforge HTTP service (a LAN bearer-token-gated wrapper around `claude -p`)."
repository = "https://gitea.sulkta.com/Sulkta-Coop/clawdforge"
readme = "README.md"
keywords = ["claude", "http-client", "sdk", "async"]
categories = ["api-bindings", "asynchronous"]
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "stream", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
tokio = { version = "1", features = ["fs", "io-util", "rt"] }
tokio-util = { version = "0.7", features = ["io"] }
tracing = { version = "0.1", default-features = false, features = ["std"] }
url = "2"
bytes = "1"
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "io-util", "time"] }
wiremock = "0.6"
tempfile = "3"
[[example]]
name = "basic"
path = "examples/basic.rs"

270
vendor/clawdforge/README.md vendored Normal file
View file

@ -0,0 +1,270 @@
# 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.
[clawdforge]: https://gitea.sulkta.com/Sulkta-Coop/clawdforge
- 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:
```sh
cargo add clawdforge --git https://gitea.sulkta.com/Sulkta-Coop/clawdforge --rev <pin>
```
Or pin manually in `Cargo.toml`:
```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:
```toml
clawdforge = { path = "../clawdforge/clients/rust" }
```
## Quickstart
```rust
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(())
}
```
## Multi-turn / Sessions (v0.2)
v0.1 `Client::run` is a single-turn shot. v0.2 adds a parallel session API
backed by the server's [ACPX]-driven `/sessions/*` surface for back-and-forth
agent flows that need context across turns.
[ACPX]: https://github.com/openclaw/acpx
```rust
use clawdforge::{Client, SessionOptions};
#[tokio::main]
async fn main() -> Result<(), clawdforge::Error> {
let client = Client::builder()
.base_url("http://localhost:8800")
.token("cf_xxxxxxxxxxxxxxxx")
.build()?;
let mut s = client.new_session(SessionOptions::default()).await?;
let r1 = s.turn("Read README.md and summarize it").await?;
println!("{}", r1.text());
// Attach files uploaded via Client::upload_file.
let r2 = s
.turn_with_files(
"Now look at the auth flow",
&["ff_xyz".into()],
)
.await?;
println!("turn {}: {}", r2.turn_index, r2.text());
// Explicit close consumes `s` — using it after this is a compile error.
s.close().await?;
Ok(())
}
```
### Lifecycle
| API | Purpose |
|---|---|
| `Client::new_session(SessionOptions)` | `POST /sessions` — returns a `Session`. |
| `Session::turn(prompt)` | `POST /sessions/{id}/turn` with no files. |
| `Session::turn_with_files(prompt, &[token, ...])` | `POST /sessions/{id}/turn` with `ff_*` tokens from `upload_file`. |
| `Session::close(self)` | `DELETE /sessions/{id}`. **Consumes `self`** — use-after-close is a compile error. |
| `Client::list_sessions()` | `GET /sessions` — sessions visible to the calling token. |
| `Client::get_session(id)` | `GET /sessions/{id}` — current state. |
### Drop fallback
If a `Session` is dropped without an explicit `close().await?`, `Drop`
spawns a best-effort async DELETE via `tokio::spawn` to release the
server-side session. This is **best-effort**:
- The spawned future is not awaited — the calling task continues immediately.
- Failures are logged via `tracing::warn!` (target `clawdforge::session`),
not panicked.
- If `Session` is dropped outside any tokio runtime, the close is skipped
with a warning rather than panicking on `tokio::spawn`.
- If `close().await?` already ran, `Drop` short-circuits without a second
network call (an `AtomicBool` flag tracks closed state).
For deterministic cleanup, **prefer `s.close().await?`**. The Drop path is a
backstop for panics / early returns, not a primary lifecycle hook.
### `TurnResult::text()`
Concatenates all `"text"` events into one string. `"thinking"` and
`"tool_call"` events are skipped — inspect `result.events` directly if you
need them.
```rust
let r = s.turn("hi").await?;
let answer: String = r.text();
let n_tool_calls = r
.events
.iter()
.filter(|e| e.event_type == "tool_call")
.count();
```
### v0.1 compatibility
The v0.1 surface (`Client::run`, `Client::upload_file`,
`Client::create_token`, etc.) is byte-identical. v0.2 is purely additive. v0.1
callers do not need to change anything to upgrade.
## 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. |
| `new_session(opts)` | `POST /sessions` | v0.2. Returns `Session`. |
| `list_sessions()` | `GET /sessions` | v0.2. Returns `SessionList`. |
| `get_session(id)` | `GET /sessions/{id}` | v0.2. Returns `SessionState`. |
### `RunResult` helpers
```rust
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
```rust
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:
```rust
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
```sh
CLAWDFORGE_URL=http://localhost:8800 \
CLAWDFORGE_TOKEN=cf_xxxx \
cargo run --example basic
```
Optional file demo:
```sh
CLAWDFORGE_DEMO_FILE=./some.png cargo run --example basic
```
## Development
```sh
cargo build --release
cargo test --all
cargo clippy --all-targets -- -D warnings
cargo build --examples
```
Tests use [`wiremock`](https://docs.rs/wiremock) — no live clawdforge needed.
## License
MIT.

77
vendor/clawdforge/examples/basic.rs vendored Normal file
View file

@ -0,0 +1,77 @@
//! End-to-end usage example.
//!
//! Run against a live clawdforge:
//!
//! ```sh
//! CLAWDFORGE_URL=http://localhost:8800 \
//! CLAWDFORGE_TOKEN=cf_xxxxxxxxxxxxxxxx \
//! cargo run --example basic
//! ```
use clawdforge::{Client, RunRequest};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Hello {
hello: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let url =
std::env::var("CLAWDFORGE_URL").unwrap_or_else(|_| "http://localhost:8800".to_string());
let token = std::env::var("CLAWDFORGE_TOKEN")
.map_err(|_| "set CLAWDFORGE_TOKEN to a cf_... bearer minted via /admin/tokens")?;
let client = Client::builder().base_url(url).token(token).build()?;
// 1) liveness
let h = client.healthz().await?;
println!(
"healthz: ok={} claude_present={} version={:?}",
h.ok, h.claude_present, h.claude_version
);
// 2) JSON-shaped run
let r = client
.run(RunRequest {
prompt: r#"Reply with JSON: {"hello": "world"}"#.into(),
model: Some("sonnet".into()),
timeout_secs: Some(30),
..Default::default()
})
.await?;
println!(
"duration_ms={} stop_reason={:?}",
r.duration_ms, r.stop_reason
);
match r.as_json::<Hello>() {
Ok(v) => println!("parsed: hello = {}", v.hello),
Err(_) => match r.as_text() {
Some(t) => println!("text reply: {t}"),
None => println!("unparseable reply: {:?}", r.result),
},
}
// 3) optional file upload — only if a path is given.
if let Ok(path) = std::env::var("CLAWDFORGE_DEMO_FILE") {
let ft = client.upload_file(&path, Some(3600)).await?;
println!(
"uploaded {} bytes -> {} (ttl {}s)",
ft.size, ft.file_token, ft.ttl_secs
);
let r2 = client
.run(RunRequest {
prompt: "Describe the attached file in one sentence.".into(),
files: Some(vec![ft.file_token]),
..Default::default()
})
.await?;
println!("file-run reply: {:?}", r2.result);
}
Ok(())
}

515
vendor/clawdforge/src/client.rs vendored Normal file
View file

@ -0,0 +1,515 @@
//! HTTP client for clawdforge.
use std::path::Path;
use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::multipart::{Form, Part};
use reqwest::{Body, Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use tokio_util::io::ReaderStream;
use url::Url;
use crate::error::Error;
use crate::session::{
Session, SessionCloseResponse, SessionCreateResponse, SessionList, SessionOptions,
SessionState, TurnResult,
};
use crate::types::{
AppToken, FileToken, Healthz, RunRequest, RunResult, TokenCreateRequest, TokenList,
};
/// Default request timeout if neither the builder nor the per-call helper sets
/// one. 120 s leaves headroom over the server's default 60 s `claude` timeout
/// without making `/healthz` callers wait forever on a dead host.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
/// Async client for the clawdforge HTTP API.
///
/// Construct via [`Client::builder`]. `Client` is cheap to clone — internally
/// it wraps an `Arc`-backed `reqwest::Client`.
///
/// `Debug` is hand-written to redact bearer tokens — `format!("{:?}", client)`
/// will never expose `app_token` or `admin_token` plaintext.
#[derive(Clone)]
pub struct Client {
inner: reqwest::Client,
base: Url,
/// App-level bearer (used for `/run`, `/files`, `/healthz`). Optional so
/// admin-only callers don't have to mint a worthless app token.
app_token: Option<String>,
/// Admin bootstrap token (used for `/admin/*`). Optional.
admin_token: Option<String>,
/// Optional cap on `upload_file` size in bytes — `None` = no cap.
max_upload_bytes: Option<u64>,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("base_url", &self.base.as_str())
.field("app_token", &self.app_token.as_ref().map(|_| "<redacted>"))
.field(
"admin_token",
&self.admin_token.as_ref().map(|_| "<redacted>"),
)
.field("max_upload_bytes", &self.max_upload_bytes)
.finish_non_exhaustive()
}
}
/// Builder for [`Client`].
///
/// `Debug` is hand-written to redact bearer tokens.
#[derive(Default)]
pub struct ClientBuilder {
base_url: Option<String>,
app_token: Option<String>,
admin_token: Option<String>,
timeout: Option<Duration>,
user_agent: Option<String>,
danger_accept_invalid_certs: bool,
max_upload_bytes: Option<u64>,
}
impl std::fmt::Debug for ClientBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientBuilder")
.field("base_url", &self.base_url)
.field("app_token", &self.app_token.as_ref().map(|_| "<redacted>"))
.field(
"admin_token",
&self.admin_token.as_ref().map(|_| "<redacted>"),
)
.field("timeout", &self.timeout)
.field("user_agent", &self.user_agent)
.field(
"danger_accept_invalid_certs",
&self.danger_accept_invalid_certs,
)
.field("max_upload_bytes", &self.max_upload_bytes)
.finish_non_exhaustive()
}
}
impl Client {
/// Start building a client.
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
/// Base URL the client was configured with (trailing slash trimmed).
pub fn base_url(&self) -> &str {
self.base.as_str().trim_end_matches('/')
}
// ---- public API --------------------------------------------------------
/// `GET /healthz`. Does not require an app token, but the server still
/// enforces the global IP allowlist.
pub async fn healthz(&self) -> Result<Healthz, Error> {
let url = self.url("/healthz")?;
let req = self.inner.request(Method::GET, url);
// healthz works without a token, but if we have one, send it — the
// server treats it as a no-op.
let req = match &self.app_token {
Some(t) => req.bearer_auth(t),
None => req,
};
let resp = req.send().await?;
json_or_error(resp).await
}
/// `POST /run`. Returns the parsed [`RunResult`] on success. On HTTP 502
/// the body is surfaced as [`Error::Api`] with `status = 502` and the
/// failure JSON in `body` — see [`crate::types::RunFailure`] for the
/// structured form.
pub async fn run(&self, body: RunRequest) -> Result<RunResult, Error> {
let token = self
.app_token
.as_deref()
.ok_or_else(|| Error::Auth("no app token configured".into()))?;
let url = self.url("/run")?;
let resp = self
.inner
.post(url)
.bearer_auth(token)
.json(&body)
.send()
.await?;
json_or_error(resp).await
}
/// `POST /files`. Streams the file from disk via `tokio::fs::File`; large
/// uploads do not buffer fully in memory.
///
/// `ttl_secs` defaults to the server's 3600 if `None`. Server clamps to
/// `60..=86400`.
///
/// If [`ClientBuilder::max_upload_bytes`] was set and the file's size on
/// disk exceeds it, returns [`Error::Config`] before opening any network
/// connection.
pub async fn upload_file(
&self,
path: impl AsRef<Path>,
ttl_secs: Option<u32>,
) -> Result<FileToken, Error> {
let token = self
.app_token
.as_deref()
.ok_or_else(|| Error::Auth("no app token configured".into()))?;
let path = path.as_ref();
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("upload")
.to_string();
let file = tokio::fs::File::open(path).await?;
let len = file.metadata().await?.len();
if let Some(max) = self.max_upload_bytes {
if len > max {
return Err(Error::Config(format!(
"file size {len} bytes exceeds max_upload_bytes={max}"
)));
}
}
let stream = ReaderStream::new(file);
let body = Body::wrap_stream(stream);
let part = Part::stream_with_length(body, len)
.file_name(file_name)
.mime_str("application/octet-stream")
.map_err(|e| Error::Config(format!("invalid mime: {e}")))?;
let mut form = Form::new().part("file", part);
if let Some(t) = ttl_secs {
form = form.text("ttl_secs", t.to_string());
}
let url = self.url("/files")?;
let resp = self
.inner
.post(url)
.bearer_auth(token)
.multipart(form)
.send()
.await?;
json_or_error(resp).await
}
/// `POST /admin/tokens`. Requires an admin token on the client.
pub async fn create_token(&self, body: TokenCreateRequest) -> Result<AppToken, Error> {
let token = self.require_admin()?;
let url = self.url("/admin/tokens")?;
let resp = self
.inner
.post(url)
.bearer_auth(token)
.json(&body)
.send()
.await?;
json_or_error(resp).await
}
/// `GET /admin/tokens`. Requires an admin token on the client.
pub async fn list_tokens(&self) -> Result<TokenList, Error> {
let token = self.require_admin()?;
let url = self.url("/admin/tokens")?;
let resp = self.inner.get(url).bearer_auth(token).send().await?;
json_or_error(resp).await
}
/// `DELETE /admin/tokens/{name}`. Requires an admin token on the client.
/// Returns `Ok(())` on success, [`Error::Api`] with status 404 if the
/// token does not exist.
///
/// `name` is validated client-side to match the server's
/// `[a-z0-9][a-z0-9_-]{0,63}` constraint — anything containing `/`, `?`,
/// `#`, `..`, or empty short-circuits with [`Error::Config`] before a
/// request is sent. This is defense-in-depth against path traversal via
/// `Url::join` (which honors RFC 3986 `..` resolution).
pub async fn revoke_token(&self, name: &str) -> Result<(), Error> {
if name.is_empty()
|| name.contains('/')
|| name.contains('?')
|| name.contains('#')
|| name.contains("..")
{
return Err(Error::Config(format!("invalid token name: {name:?}")));
}
let token = self.require_admin()?;
let url = self.url(&format!("/admin/tokens/{name}"))?;
let resp = self.inner.delete(url).bearer_auth(token).send().await?;
// 2xx is success regardless of body — RFC-correct DELETE may return
// 204 No Content with no body.
if resp.status().is_success() {
return Ok(());
}
// Non-2xx: route through json_or_error to get Auth/Api mapping.
// Discard the (already-non-success) deserialization slot.
let _: serde_json::Value = json_or_error(resp).await?;
Ok(())
}
// ---- v0.2 multi-turn / sessions ---------------------------------------
/// `POST /sessions`. Create a new multi-turn session and return a
/// [`Session`] handle bound to this client.
///
/// The handle owns a clone of the client; dropping it without an explicit
/// `Session::close().await?` triggers a best-effort async DELETE via
/// `tokio::spawn`. See [`Session`] for the full lifecycle contract.
pub async fn new_session(&self, opts: SessionOptions) -> Result<Session, Error> {
let token = self.require_app()?;
let url = self.url("/sessions")?;
let resp = self
.inner
.post(url)
.bearer_auth(token)
.json(&opts)
.send()
.await?;
let created: SessionCreateResponse = json_or_error(resp).await?;
Ok(Session {
client: self.clone(),
session_id: created.session_id,
agent: created.agent,
created_at: created.created_at,
closed: std::sync::atomic::AtomicBool::new(false),
})
}
/// `GET /sessions`. List all sessions visible to the calling app token.
pub async fn list_sessions(&self) -> Result<SessionList, Error> {
let token = self.require_app()?;
let url = self.url("/sessions")?;
let resp = self.inner.get(url).bearer_auth(token).send().await?;
json_or_error(resp).await
}
/// `GET /sessions/{id}`. Fetch the current state of a session.
///
/// `id` is validated client-side against the same path-traversal guard as
/// [`Self::revoke_token`] — anything containing `/`, `?`, `#`, `..`, or
/// empty short-circuits with [`Error::Config`].
pub async fn get_session(&self, id: &str) -> Result<SessionState, Error> {
validate_session_id(id)?;
let token = self.require_app()?;
let url = self.url(&format!("/sessions/{id}"))?;
let resp = self.inner.get(url).bearer_auth(token).send().await?;
json_or_error(resp).await
}
/// Internal helper used by both [`Session::close`] and [`Session`]'s
/// `Drop` impl to issue `DELETE /sessions/{id}`.
pub(crate) async fn close_session_internal(&self, id: &str) -> Result<(), Error> {
validate_session_id(id)?;
let token = self.require_app()?;
let url = self.url(&format!("/sessions/{id}"))?;
let resp = self.inner.delete(url).bearer_auth(token).send().await?;
if resp.status().is_success() {
// Body is informational — `{ ok, already_closed? }`. Drain and
// ignore decode failure (server may legitimately 204).
let bytes = resp.bytes().await?;
if !bytes.is_empty() {
let _ = serde_json::from_slice::<SessionCloseResponse>(&bytes);
}
return Ok(());
}
// Funnel non-2xx through json_or_error for Auth/Api mapping.
let _: serde_json::Value = json_or_error(resp).await?;
Ok(())
}
/// Internal helper used by [`Session::turn`] /
/// [`Session::turn_with_files`] to dispatch to
/// `POST /sessions/{id}/turn`.
pub(crate) async fn turn_internal<B: serde::Serialize>(
&self,
id: &str,
body: &B,
) -> Result<TurnResult, Error> {
validate_session_id(id)?;
let token = self.require_app()?;
let url = self.url(&format!("/sessions/{id}/turn"))?;
let resp = self
.inner
.post(url)
.bearer_auth(token)
.json(body)
.send()
.await?;
json_or_error(resp).await
}
// ---- internal ----------------------------------------------------------
fn require_app(&self) -> Result<&str, Error> {
self.app_token
.as_deref()
.ok_or_else(|| Error::Auth("no app token configured".into()))
}
fn require_admin(&self) -> Result<&str, Error> {
self.admin_token
.as_deref()
.ok_or_else(|| Error::Auth("no admin token configured".into()))
}
fn url(&self, path: &str) -> Result<Url, Error> {
let trimmed = path.strip_prefix('/').unwrap_or(path);
self.base
.join(trimmed)
.map_err(|e| Error::Config(format!("bad path {path:?}: {e}")))
}
}
impl ClientBuilder {
/// Set the base URL (e.g. `http://localhost:8800`). Required.
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
/// Set the app bearer token used for `/run`, `/files`, and `/healthz`.
pub fn token(mut self, token: impl Into<String>) -> Self {
self.app_token = Some(token.into());
self
}
/// Set the admin bootstrap token used for `/admin/*`. May be set
/// alongside [`Self::token`].
pub fn admin_token(mut self, token: impl Into<String>) -> Self {
self.admin_token = Some(token.into());
self
}
/// Per-request timeout for the underlying `reqwest::Client`. Defaults to
/// 120 s.
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
/// Override the `User-Agent` header. Defaults to
/// `clawdforge-rs/<crate-version>`.
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
/// Skip TLS certificate verification. Off by default. Only useful against
/// self-signed local deployments.
pub fn danger_accept_invalid_certs(mut self, enable: bool) -> Self {
self.danger_accept_invalid_certs = enable;
self
}
/// Maximum file size (in bytes) accepted by [`Client::upload_file`].
/// Files exceeding this cap fail with [`Error::Config`] before any
/// network I/O. Default `None` = no client-side cap (the server's own
/// limit still applies).
pub fn max_upload_bytes(mut self, max: u64) -> Self {
self.max_upload_bytes = Some(max);
self
}
/// Finalize. Errors if `base_url` is missing or unparseable.
pub fn build(self) -> Result<Client, Error> {
let base_raw = self
.base_url
.ok_or_else(|| Error::Config("base_url is required".into()))?;
// Ensure trailing slash so `Url::join` treats the base as a directory.
let base_str = if base_raw.ends_with('/') {
base_raw
} else {
format!("{base_raw}/")
};
let base =
Url::parse(&base_str).map_err(|e| Error::Config(format!("invalid base_url: {e}")))?;
if !matches!(base.scheme(), "http" | "https") {
return Err(Error::Config(format!(
"unsupported scheme: {}",
base.scheme()
)));
}
let ua = self
.user_agent
.unwrap_or_else(|| format!("clawdforge-rs/{}", env!("CARGO_PKG_VERSION")));
let mut headers = HeaderMap::new();
headers.insert(
reqwest::header::ACCEPT,
HeaderValue::from_static("application/json"),
);
// We don't preset Authorization here — per-call helpers do it because
// the right token depends on which endpoint is being hit.
let inner = reqwest::Client::builder()
.timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
.user_agent(ua)
.default_headers(headers)
.danger_accept_invalid_certs(self.danger_accept_invalid_certs)
.build()?;
Ok(Client {
inner,
base,
app_token: self.app_token,
admin_token: self.admin_token,
max_upload_bytes: self.max_upload_bytes,
})
}
}
/// Decode `T` from a successful 2xx response, otherwise lift to [`Error`].
async fn json_or_error<T: DeserializeOwned>(resp: Response) -> Result<T, Error> {
let status = resp.status();
if status.is_success() {
let bytes = resp.bytes().await?;
return Ok(serde_json::from_slice::<T>(&bytes)?);
}
// Non-2xx: capture body lossily then translate.
let body = resp.text().await.unwrap_or_default();
match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(Error::Auth(format!(
"{}: {}",
status.as_u16(),
truncate(&body, 500)
))),
_ => Err(Error::api(status.as_u16(), body)),
}
}
/// Defense-in-depth path validator for session ids in `/sessions/{id}*`. Same
/// shape as [`Client::revoke_token`]'s name guard — RFC 3986 dot-segment
/// resolution inside `Url::join` is the threat model here.
fn validate_session_id(id: &str) -> Result<(), Error> {
if id.is_empty()
|| id.contains('/')
|| id.contains('?')
|| id.contains('#')
|| id.contains("..")
{
return Err(Error::Config(format!("invalid session id: {id:?}")));
}
Ok(())
}
/// Truncate `s` to at most `max` bytes, snapping down to the nearest UTF-8
/// codepoint boundary so we never panic on multibyte sequences. Appends `…`
/// to the truncated form. `str::floor_char_boundary` (stable 1.80+) does the
/// boundary math.
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let safe = s.floor_char_boundary(max);
format!("{}", &s[..safe])
}
}

68
vendor/clawdforge/src/error.rs vendored Normal file
View file

@ -0,0 +1,68 @@
//! Error types for the clawdforge client.
use thiserror::Error;
/// All errors surfaced by the client.
///
/// Variants are deliberately coarse — the underlying transport / serde errors
/// are preserved as `source()` for callers who want to dig in.
#[derive(Debug, Error)]
pub enum Error {
/// 401/403 from the server, or a missing/empty bearer token configured on
/// the client.
#[error("authentication failed: {0}")]
Auth(String),
/// Any non-2xx response that wasn't an auth error. The response body is
/// captured as a UTF-8 string (lossy if the server returned binary).
#[error("api error: status={status} body={body}")]
Api {
/// HTTP status code.
status: u16,
/// Response body (best-effort UTF-8).
body: String,
},
/// Request never completed: DNS, connect, TLS, body-read, etc.
#[error("transport error: {0}")]
Transport(reqwest::Error),
/// JSON decode failed on a successful HTTP response.
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
/// Local I/O error — currently only emitted by `upload_file` when opening
/// the source file.
#[error("io error: {0}")]
Io(#[from] std::io::Error),
/// The configured request timeout elapsed before the server replied.
/// Mapped from `reqwest::Error::is_timeout()` so callers can match on
/// timeouts specifically without inspecting the inner transport error.
#[error("timeout: {0}")]
Timeout(String),
/// Misconfigured client (e.g. invalid base URL).
#[error("invalid configuration: {0}")]
Config(String),
}
impl From<reqwest::Error> for Error {
fn from(e: reqwest::Error) -> Self {
if e.is_timeout() {
Self::Timeout(e.to_string())
} else {
Self::Transport(e)
}
}
}
impl Error {
/// Build an [`Error::Api`] from a status code and body string.
pub(crate) fn api(status: u16, body: impl Into<String>) -> Self {
Self::Api {
status,
body: body.into(),
}
}
}

58
vendor/clawdforge/src/lib.rs vendored Normal file
View file

@ -0,0 +1,58 @@
//! Async Rust client for the [clawdforge] HTTP service.
//!
//! clawdforge is a small LAN-only service that wraps `claude -p` subprocess
//! calls behind a bearer-token-gated REST API. This crate is a thin,
//! ergonomic Rust SDK for it.
//!
//! # Quickstart
//!
//! ```no_run
//! use clawdforge::{Client, RunRequest};
//!
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
//! let client = Client::builder()
//! .base_url("http://localhost:8800")
//! .token("cf_xxxxxxxxxxxxxxxx")
//! .build()?;
//!
//! let h = client.healthz().await?;
//! println!("claude present: {}", h.claude_present);
//!
//! let r = client.run(RunRequest {
//! prompt: "Reply with JSON: {\"hello\":\"world\"}".into(),
//! model: Some("sonnet".into()),
//! ..Default::default()
//! }).await?;
//!
//! #[derive(serde::Deserialize)]
//! struct Hello { hello: String }
//! let typed: Hello = r.as_json()?;
//! println!("{}", typed.hello);
//! # Ok(()) }
//! ```
//!
//! [clawdforge]: https://gitea.sulkta.com/Sulkta-Coop/clawdforge
//!
//! # Field naming
//!
//! The clawdforge wire format is snake_case end-to-end (Python / Pydantic
//! conventions), so structs in [`crate::types`] do **not** carry
//! `#[serde(rename_all = "camelCase")]`. If a future endpoint exposes
//! camelCase, prefer per-field `#[serde(rename = "...")]` over a blanket
//! container attribute.
#![deny(rust_2018_idioms)]
#![deny(missing_docs)]
mod client;
mod error;
pub mod session;
pub mod types;
pub use client::{Client, ClientBuilder};
pub use error::Error;
pub use session::{Session, SessionList, SessionOptions, SessionState, TurnEvent, TurnResult};
pub use types::{
AppToken, AppTokenInfo, FileToken, Healthz, RunFailure, RunRequest, RunResult,
TokenCreateRequest, TokenList,
};

281
vendor/clawdforge/src/session.rs vendored Normal file
View file

@ -0,0 +1,281 @@
//! Multi-turn session API (v0.2).
//!
//! v0.2 adds a parallel `/sessions/*` surface to clawdforge backed by ACPX. A
//! [`Session`] is a handle to one server-side session; [`Session::turn`]
//! dispatches a single prompt+files turn and returns the structured event
//! batch. Sessions are explicitly closed via [`Session::close`] (consumes the
//! handle, preventing use-after-close at compile time) or — as a last-resort
//! fallback — best-effort closed by [`Drop`] via `tokio::spawn`.
//!
//! v0.1 single-turn `Client::run` is unchanged; the v0.2 surface is purely
//! additive.
use std::sync::atomic::{AtomicBool, Ordering};
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::error::Error;
/// Options passed to [`Client::new_session`].
///
/// `Default` produces `agent = None` (server picks `"claude"`) and `meta =
/// None`.
#[derive(Debug, Default, Clone, Serialize)]
pub struct SessionOptions {
/// Agent slug to dispatch to. `None` falls back to the server-side default
/// (`"claude"`).
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
/// Free-form metadata stored alongside the session ledger row.
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
/// Reply body from `POST /sessions`.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SessionCreateResponse {
pub session_id: String,
pub agent: String,
pub created_at: i64,
}
/// One event in a turn's structured output.
///
/// `event_type` is one of `"thinking"`, `"text"`, `"tool_call"`, etc. (server
/// is the authority on the set).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TurnEvent {
/// Event discriminator (`"text"`, `"thinking"`, `"tool_call"`, ...).
#[serde(rename = "type")]
pub event_type: String,
/// Text content for `"text"` and `"thinking"` events.
#[serde(default)]
pub content: Option<String>,
/// Tool name for `"tool_call"` events.
#[serde(default)]
pub name: Option<String>,
/// Tool arguments for `"tool_call"` events.
#[serde(default)]
pub args: Option<serde_json::Value>,
/// Tool result for `"tool_call"` events.
#[serde(default)]
pub result: Option<serde_json::Value>,
}
/// Successful response body from `POST /sessions/{id}/turn`.
#[derive(Debug, Clone, Deserialize)]
pub struct TurnResult {
/// Always `true` on a 200 reply.
pub ok: bool,
/// The session this turn belongs to.
pub session_id: String,
/// 1-based index of this turn within the session.
pub turn_index: i32,
/// Structured events emitted during the turn.
pub events: Vec<TurnEvent>,
/// Reason the agent stopped (`"end_turn"`, `"max_tokens"`, ...).
pub stop_reason: String,
/// Wall-clock duration of the turn.
pub duration_ms: i64,
}
impl TurnResult {
/// Concatenate all `"text"` event contents into a single string.
///
/// Non-text events (`thinking`, `tool_call`, ...) are skipped. If an event
/// is `"text"` but `content` is `None`, it contributes the empty string.
pub fn text(&self) -> String {
let mut out = String::new();
for ev in &self.events {
if ev.event_type == "text" {
if let Some(c) = ev.content.as_deref() {
out.push_str(c);
}
}
}
out
}
}
/// Reply body from `GET /sessions/{id}` and entries in `GET /sessions`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionState {
/// Server-issued session id.
pub session_id: String,
/// Agent slug bound to the session.
pub agent: String,
/// App / consumer name that owns the session.
pub app_name: String,
/// Unix epoch seconds when created.
pub created_at: i64,
/// Unix epoch seconds of the last successful turn (or `None` if zero turns
/// have been dispatched yet).
#[serde(default)]
pub last_turn_at: Option<i64>,
/// Number of turns dispatched.
pub turn_count: i32,
/// Unix epoch seconds when closed (or `None` if still open).
#[serde(default)]
pub closed_at: Option<i64>,
}
/// Reply body from `DELETE /sessions/{id}`.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SessionCloseResponse {
#[allow(dead_code)]
pub ok: bool,
#[serde(default)]
#[allow(dead_code)]
pub already_closed: Option<bool>,
}
/// Reply body from `GET /sessions`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionList {
/// All sessions visible to the calling token.
pub sessions: Vec<SessionState>,
}
/// Request body for `POST /sessions/{id}/turn`.
#[derive(Debug, Serialize)]
struct TurnRequest<'a> {
prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
files: Option<&'a [String]>,
}
/// A handle to one server-side multi-turn session.
///
/// Construct via [`Client::new_session`]. Drop or [`Session::close`] to
/// release the server-side session. `close` consumes the value so use-after-
/// close is a compile error; `Drop` is a best-effort backstop that fires an
/// async DELETE via `tokio::spawn` and logs (does not panic) on failure.
///
/// `Debug` is hand-written and explicitly excludes the embedded [`Client`] so
/// no bearer can leak through `{:?}` formatting.
pub struct Session {
pub(crate) client: Client,
pub(crate) session_id: String,
pub(crate) agent: String,
pub(crate) created_at: i64,
pub(crate) closed: AtomicBool,
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Deliberately omit `client` — its Debug already redacts tokens, but
// the spec-mandated shape is `Session { session_id, agent, closed }`
// to keep the surface minimal and audit-friendly.
f.debug_struct("Session")
.field("session_id", &self.session_id)
.field("agent", &self.agent)
.field("created_at", &self.created_at)
.field("closed", &self.closed.load(Ordering::Acquire))
.finish()
}
}
impl Session {
/// Server-issued session id.
pub fn id(&self) -> &str {
&self.session_id
}
/// Agent slug the server bound to this session.
pub fn agent(&self) -> &str {
&self.agent
}
/// Unix epoch seconds when the session was created server-side.
pub fn created_at(&self) -> i64 {
self.created_at
}
/// Whether the session has been explicitly closed already. Sessions closed
/// only via `Drop`'s spawn are still reported as closed once that future
/// has run; this getter reflects the in-memory flag.
pub fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire)
}
/// Send a turn with no attached files.
///
/// Equivalent to `turn_with_files(prompt, &[])` but skips serializing the
/// `files` field on the wire.
pub async fn turn(&mut self, prompt: impl Into<String>) -> Result<TurnResult, Error> {
self.dispatch_turn(prompt.into(), None).await
}
/// Send a turn that references previously uploaded file tokens.
///
/// `files` is the list of `ff_*` tokens returned by [`Client::upload_file`].
///
/// [`Client::upload_file`]: crate::Client::upload_file
pub async fn turn_with_files(
&mut self,
prompt: impl Into<String>,
files: &[String],
) -> Result<TurnResult, Error> {
self.dispatch_turn(prompt.into(), Some(files)).await
}
async fn dispatch_turn(
&mut self,
prompt: String,
files: Option<&[String]>,
) -> Result<TurnResult, Error> {
if self.closed.load(Ordering::Acquire) {
return Err(Error::Config("session is closed".into()));
}
self.client
.turn_internal(&self.session_id, &TurnRequest { prompt, files })
.await
}
/// Explicitly close the session. Consumes `self` — use-after-close is a
/// compile error.
///
/// If the session is already closed in memory (e.g. via a prior failed
/// close or a prior dispatch path that flagged it), this short-circuits
/// without contacting the server.
pub async fn close(self) -> Result<(), Error> {
// Mark closed before the network call so a panic-mid-await on the
// request future cannot trigger Drop's spawn into a double-close.
if self.closed.swap(true, Ordering::AcqRel) {
return Ok(());
}
self.client.close_session_internal(&self.session_id).await
}
}
impl Drop for Session {
fn drop(&mut self) {
// If close() already ran, nothing to do.
if self.closed.swap(true, Ordering::AcqRel) {
return;
}
// tokio::spawn panics if no runtime is current. Guard against being
// dropped from a sync context (e.g. a forgotten value at the end of a
// sync `main`).
if tokio::runtime::Handle::try_current().is_err() {
tracing::warn!(
session_id = %self.session_id,
"Session dropped outside a tokio runtime; server-side session not closed"
);
return;
}
let client = self.client.clone();
let id = self.session_id.clone();
tokio::spawn(async move {
if let Err(e) = client.close_session_internal(&id).await {
tracing::warn!(
session_id = %id,
error = %e,
"best-effort drop close failed"
);
}
});
}
}

186
vendor/clawdforge/src/types.rs vendored Normal file
View file

@ -0,0 +1,186 @@
//! Wire types matching the clawdforge HTTP API.
//!
//! Field naming note: clawdforge uses snake_case on the wire (matches Python /
//! Pydantic conventions), so these structs use plain `#[derive(Serialize,
//! Deserialize)]` without `rename_all`. If a future endpoint surfaces
//! camelCase, opt in per-field with `#[serde(rename = "...")]`.
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use crate::error::Error;
/// `GET /healthz` response body.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Healthz {
/// Always `true` if the server replied.
pub ok: bool,
/// Whether the `claude` binary was discovered on `PATH`.
pub claude_present: bool,
/// First line of `claude --version` (or `null` if not present).
pub claude_version: Option<String>,
}
/// Request body for `POST /run`.
///
/// All optional fields default to `None` — use `..Default::default()` to fill
/// the rest:
///
/// ```no_run
/// use clawdforge::RunRequest;
/// let r = RunRequest {
/// prompt: "say hi".into(),
/// model: Some("sonnet".into()),
/// ..Default::default()
/// };
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RunRequest {
/// Prompt text. Must be non-empty server-side.
pub prompt: String,
/// Model alias passed to `claude -p --model`. `None` falls back to the
/// server-side default (typically `sonnet`).
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// System prompt appended via `claude -p --append-system-prompt`.
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
/// File tokens previously returned from [`Client::upload_file`].
///
/// [`Client::upload_file`]: crate::Client::upload_file
#[serde(skip_serializing_if = "Option::is_none")]
pub files: Option<Vec<String>>,
/// Subprocess timeout in seconds. Server clamps to `5..=600`.
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u32>,
}
/// Successful response body from `POST /run`.
///
/// `result` is intentionally a [`serde_json::Value`] — clawdforge auto-parses
/// the `claude` reply as JSON when possible and falls back to a raw string
/// otherwise. Use [`RunResult::as_json`] to deserialize into a typed struct
/// or [`RunResult::as_text`] when you expect a string.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RunResult {
/// Always `true` for a 200 response (the server returns 502 on failure).
pub ok: bool,
/// Parsed claude output. JSON object/array/number/bool when the model
/// emitted JSON; string otherwise.
pub result: serde_json::Value,
/// Wall-clock duration of the subprocess.
pub duration_ms: u64,
/// `claude` stop reason, e.g. `"end_turn"`. Sometimes `None` on edge cases.
pub stop_reason: Option<String>,
}
impl RunResult {
/// Deserialize `result` as `T`. Fails with [`Error::Json`] if the server
/// returned a string or a JSON shape that doesn't match `T`.
pub fn as_json<T: DeserializeOwned>(&self) -> Result<T, Error> {
Ok(serde_json::from_value(self.result.clone())?)
}
/// Borrow `result` as a string slice if it was a JSON string.
/// Returns `None` for objects, arrays, numbers, etc.
pub fn as_text(&self) -> Option<&str> {
self.result.as_str()
}
}
/// Failure body from `POST /run` (HTTP 502).
///
/// Surfaced inside [`Error::Api`] via the `body` field as JSON text. Provided
/// here so callers can `serde_json::from_str::<RunFailure>(&body)` to recover
/// structured data.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RunFailure {
/// Always `false` for a failure body.
pub ok: bool,
/// Short error label set by the runner (e.g. `"timeout"`, `"non_zero_exit"`).
pub error: Option<String>,
/// Last 4 KB of `claude` stderr, when available.
pub stderr: Option<String>,
/// Wall-clock duration of the (failed) subprocess.
pub duration_ms: u64,
/// `claude` stop reason if the failure produced one.
pub stop_reason: Option<String>,
}
/// Response body from `POST /files`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FileToken {
/// Opaque token, prefix `ff_`. Pass to [`RunRequest::files`].
pub file_token: String,
/// TTL the server registered (clamped to 60..=86400).
pub ttl_secs: u32,
/// Bytes written to the staging dir.
pub size: u64,
}
/// Response body from `POST /admin/tokens`.
///
/// `token` is the plaintext bearer — only returned at creation time.
///
/// `Debug` is hand-written to redact `token` (the plaintext bearer); `name`
/// and `ip_cidrs` print verbatim.
#[derive(Clone, Deserialize, Serialize)]
pub struct AppToken {
/// App / consumer name.
pub name: String,
/// Plaintext bearer (`cf_...`). Save it now; the server stores only the
/// SHA-256.
pub token: String,
/// CIDRs the token is restricted to. Empty = unrestricted (still subject
/// to the global allowlist).
pub ip_cidrs: Vec<String>,
}
impl std::fmt::Debug for AppToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppToken")
.field("name", &self.name)
.field("token", &"<redacted>")
.field("ip_cidrs", &self.ip_cidrs)
.finish()
}
}
/// Request body for `POST /admin/tokens`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenCreateRequest {
/// `[a-z0-9][a-z0-9_-]{0,63}` — server enforces.
pub name: String,
/// Optional CIDR allowlist for this token.
#[serde(default)]
pub ip_cidrs: Vec<String>,
}
/// One row of `GET /admin/tokens`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppTokenInfo {
/// App / consumer name.
pub name: String,
/// CIDRs this token is restricted to. Empty = no per-token CIDR restriction.
#[serde(default)]
pub ip_cidrs: Vec<String>,
/// Unix epoch seconds (server-controlled field shape — extra fields are
/// captured by `extra`).
#[serde(default)]
pub created_at: Option<i64>,
/// Catch-all for any future fields the server adds.
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
/// Response body from `GET /admin/tokens`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TokenList {
/// All registered app tokens (hashes only — plaintext is shown once at
/// creation).
pub tokens: Vec<AppTokenInfo>,
}

517
vendor/clawdforge/tests/client.rs vendored Normal file
View file

@ -0,0 +1,517 @@
//! Integration tests against an in-process wiremock server.
use std::io::Write;
use std::time::Duration;
use clawdforge::{Client, Error, RunRequest, TokenCreateRequest};
use serde_json::json;
use wiremock::matchers::{body_json, body_string_contains, header, method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_client(server: &MockServer) -> Client {
Client::builder()
.base_url(server.uri())
.token("cf_test_token")
.admin_token("admin_test_token")
.timeout(Duration::from_secs(5))
.build()
.expect("client builds")
}
#[tokio::test]
async fn healthz_returns_payload() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/healthz"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"claude_present": true,
"claude_version": "claude 1.2.3"
})))
.mount(&server)
.await;
let c = make_client(&server);
let h = c.healthz().await.unwrap();
assert!(h.ok);
assert!(h.claude_present);
assert_eq!(h.claude_version.as_deref(), Some("claude 1.2.3"));
}
#[tokio::test]
async fn run_success_with_json_result() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/run"))
.and(header("authorization", "Bearer cf_test_token"))
.and(body_json(json!({
"prompt": "give me json",
"model": "sonnet"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"result": {"hello": "world", "n": 42},
"duration_ms": 1234,
"stop_reason": "end_turn"
})))
.mount(&server)
.await;
let c = make_client(&server);
let r = c
.run(RunRequest {
prompt: "give me json".into(),
model: Some("sonnet".into()),
..Default::default()
})
.await
.unwrap();
assert!(r.ok);
assert_eq!(r.duration_ms, 1234);
assert_eq!(r.stop_reason.as_deref(), Some("end_turn"));
#[derive(serde::Deserialize)]
struct Reply {
hello: String,
n: i32,
}
let parsed: Reply = r.as_json().unwrap();
assert_eq!(parsed.hello, "world");
assert_eq!(parsed.n, 42);
assert!(r.as_text().is_none());
}
#[tokio::test]
async fn run_success_with_text_result() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/run"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"result": "plain string reply",
"duration_ms": 50,
"stop_reason": "end_turn"
})))
.mount(&server)
.await;
let c = make_client(&server);
let r = c
.run(RunRequest {
prompt: "say hi".into(),
..Default::default()
})
.await
.unwrap();
assert_eq!(r.as_text(), Some("plain string reply"));
let json_attempt: Result<serde_json::Map<String, serde_json::Value>, _> = r.as_json();
assert!(
json_attempt.is_err(),
"string should not deserialize as map"
);
}
#[tokio::test]
async fn run_502_surfaces_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/run"))
.respond_with(ResponseTemplate::new(502).set_body_json(json!({
"ok": false,
"error": "claude exited 1",
"stderr": "boom",
"duration_ms": 10,
"stop_reason": null
})))
.mount(&server)
.await;
let c = make_client(&server);
let err = c
.run(RunRequest {
prompt: "fail".into(),
..Default::default()
})
.await
.expect_err("should fail");
match err {
Error::Api { status, body } => {
assert_eq!(status, 502);
assert!(body.contains("claude exited 1"), "body was {body}");
// Demonstrate caller-side recovery via RunFailure.
let parsed: clawdforge::RunFailure =
serde_json::from_str(&body).expect("body is RunFailure JSON");
assert!(!parsed.ok);
assert_eq!(parsed.error.as_deref(), Some("claude exited 1"));
}
other => panic!("unexpected error variant: {other:?}"),
}
}
#[tokio::test]
async fn run_with_files_passes_through() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/run"))
.and(body_json(json!({
"prompt": "use the file",
"files": ["ff_abc", "ff_def"]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"result": "saw 2 files",
"duration_ms": 100,
"stop_reason": "end_turn"
})))
.mount(&server)
.await;
let c = make_client(&server);
let r = c
.run(RunRequest {
prompt: "use the file".into(),
files: Some(vec!["ff_abc".into(), "ff_def".into()]),
..Default::default()
})
.await
.unwrap();
assert_eq!(r.as_text(), Some("saw 2 files"));
}
#[tokio::test]
async fn upload_file_streams_multipart() {
let server = MockServer::start().await;
// wiremock can't easily decode multipart, so we fingerprint the bytes:
// the file's contents (as a UTF-8 substring) and the form field names.
Mock::given(method("POST"))
.and(path("/files"))
.and(header("authorization", "Bearer cf_test_token"))
.and(body_string_contains("hello-from-rust-test"))
.and(body_string_contains("name=\"file\""))
.and(body_string_contains("name=\"ttl_secs\""))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"file_token": "ff_xyz",
"ttl_secs": 1800,
"size": 20
})))
.mount(&server)
.await;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "hello-from-rust-test").unwrap();
tmp.flush().unwrap();
let c = make_client(&server);
let ft = c.upload_file(tmp.path(), Some(1800)).await.unwrap();
assert_eq!(ft.file_token, "ff_xyz");
assert_eq!(ft.ttl_secs, 1800);
assert_eq!(ft.size, 20);
}
#[tokio::test]
async fn admin_create_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/admin/tokens"))
.and(header("authorization", "Bearer admin_test_token"))
.and(body_json(json!({
"name": "cauldron",
"ip_cidrs": ["172.24.0.0/16"]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "cauldron",
"token": "cf_brandnew",
"ip_cidrs": ["172.24.0.0/16"]
})))
.mount(&server)
.await;
let c = make_client(&server);
let t = c
.create_token(TokenCreateRequest {
name: "cauldron".into(),
ip_cidrs: vec!["172.24.0.0/16".into()],
})
.await
.unwrap();
assert_eq!(t.name, "cauldron");
assert_eq!(t.token, "cf_brandnew");
assert_eq!(t.ip_cidrs, vec!["172.24.0.0/16".to_string()]);
}
#[tokio::test]
async fn admin_list_tokens() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/admin/tokens"))
.and(header("authorization", "Bearer admin_test_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tokens": [
{"name": "cauldron", "ip_cidrs": ["172.24.0.0/16"], "created_at": 1700000000},
{"name": "petalparse", "ip_cidrs": [], "created_at": 1700000100, "last_seen": 1700001000}
]
})))
.mount(&server)
.await;
let c = make_client(&server);
let list = c.list_tokens().await.unwrap();
assert_eq!(list.tokens.len(), 2);
assert_eq!(list.tokens[0].name, "cauldron");
// unknown server-added field captured by `extra`.
assert!(list.tokens[1].extra.contains_key("last_seen"));
}
#[tokio::test]
async fn admin_revoke_token() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path_regex(r"^/admin/tokens/.+"))
.and(header("authorization", "Bearer admin_test_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
let c = make_client(&server);
c.revoke_token("cauldron").await.unwrap();
}
#[tokio::test]
async fn unauthorized_response_maps_to_auth_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/run"))
.respond_with(ResponseTemplate::new(401).set_body_string("missing token"))
.mount(&server)
.await;
let c = make_client(&server);
let err = c
.run(RunRequest {
prompt: "nope".into(),
..Default::default()
})
.await
.expect_err("should fail");
assert!(matches!(err, Error::Auth(_)));
}
#[tokio::test]
async fn missing_app_token_short_circuits_run() {
// Build a client without an app token but with admin set.
let server = MockServer::start().await;
let c = Client::builder()
.base_url(server.uri())
.admin_token("admin_only")
.build()
.unwrap();
let err = c
.run(RunRequest {
prompt: "x".into(),
..Default::default()
})
.await
.expect_err("should fail without app token");
match err {
Error::Auth(msg) => assert!(msg.contains("no app token")),
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn error_timeout_constructed_on_reqwest_timeout() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/healthz"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(2_000))
.set_body_json(json!({
"ok": true,
"claude_present": true,
"claude_version": "x"
})),
)
.mount(&server)
.await;
let c = Client::builder()
.base_url(server.uri())
.token("cf_x")
.timeout(Duration::from_millis(150))
.build()
.unwrap();
let err = c.healthz().await.expect_err("should time out");
assert!(matches!(err, Error::Timeout(_)), "got {err:?}");
}
#[tokio::test]
async fn builder_rejects_missing_base_url() {
let err = Client::builder().build().expect_err("should fail");
assert!(matches!(err, Error::Config(_)));
}
#[tokio::test]
async fn builder_rejects_bad_scheme() {
let err = Client::builder()
.base_url("ftp://nope")
.build()
.expect_err("should fail");
assert!(matches!(err, Error::Config(_)));
}
// ---- audit-driven regression tests --------------------------------------
/// H1: 4xx body with multibyte char straddling the truncation cutoff must
/// not panic. Build a 503-byte string where `ü` (2 bytes UTF-8) lands at
/// offset 499..501, so byte 500 is mid-codepoint.
#[tokio::test]
async fn truncate_handles_multibyte_boundary() {
let server = MockServer::start().await;
let mut body = String::new();
for _ in 0..499 {
body.push('a');
}
body.push('ü'); // bytes 499 and 500
for _ in 0..2 {
body.push('b');
}
assert_eq!(body.len(), 503);
assert!(!body.is_char_boundary(500));
Mock::given(method("POST"))
.and(path("/run"))
.respond_with(ResponseTemplate::new(401).set_body_string(body.clone()))
.mount(&server)
.await;
let c = make_client(&server);
let err = c
.run(RunRequest {
prompt: "x".into(),
..Default::default()
})
.await
.expect_err("should fail");
// Just having reached this line — without panicking — is the assertion.
assert!(matches!(err, Error::Auth(_)), "got {err:?}");
}
/// H2: `Debug` on `Client` must not leak app or admin tokens.
#[tokio::test]
async fn client_debug_redacts_bearer() {
let server = MockServer::start().await;
let c = Client::builder()
.base_url(server.uri())
.token("cf_super_secret_app_bearer")
.admin_token("admin_super_secret_bearer")
.build()
.unwrap();
let dbg = format!("{c:?}");
assert!(
!dbg.contains("cf_super_secret_app_bearer"),
"app token leaked: {dbg}"
);
assert!(
!dbg.contains("admin_super_secret_bearer"),
"admin token leaked: {dbg}"
);
assert!(dbg.contains("<redacted>"), "no redaction marker: {dbg}");
// ClientBuilder Debug also redacts.
let builder = Client::builder()
.base_url("http://x")
.token("cf_builder_secret");
let bdbg = format!("{builder:?}");
assert!(
!bdbg.contains("cf_builder_secret"),
"builder token leaked: {bdbg}"
);
assert!(bdbg.contains("<redacted>"), "no redaction marker: {bdbg}");
}
/// H2: `Debug` on `AppToken` must not leak the plaintext `token` field.
#[test]
fn app_token_debug_redacts_token() {
let t = clawdforge::AppToken {
name: "cauldron".into(),
token: "cf_should_not_appear".into(),
ip_cidrs: vec!["172.24.0.0/16".into()],
};
let dbg = format!("{t:?}");
assert!(!dbg.contains("cf_should_not_appear"), "leaked: {dbg}");
assert!(dbg.contains("<redacted>"), "no marker: {dbg}");
// name + ip_cidrs are non-secret and should still print.
assert!(dbg.contains("cauldron"));
assert!(dbg.contains("172.24.0.0/16"));
}
/// H3: `revoke_token` must reject path-traversal sequences before issuing
/// any HTTP request.
#[tokio::test]
async fn revoke_token_rejects_path_traversal() {
let server = MockServer::start().await;
// No mock — if a request escaped client-side validation, wiremock would
// 404 and we'd see Error::Api, not Error::Config.
let c = make_client(&server);
for bad in [
"../foo", "..", "foo/bar", "foo?x=1", "foo#frag", "", "a/../b",
] {
let err = c
.revoke_token(bad)
.await
.expect_err(&format!("revoke_token({bad:?}) should reject"));
assert!(
matches!(err, Error::Config(_)),
"{bad:?} produced wrong variant: {err:?}"
);
}
}
/// M2: a 204 No Content response from `revoke_token` must Ok-out.
#[tokio::test]
async fn revoke_token_accepts_204_no_content() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path_regex(r"^/admin/tokens/.+"))
.and(header("authorization", "Bearer admin_test_token"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let c = make_client(&server);
c.revoke_token("cauldron")
.await
.expect("204 No Content should be Ok");
}
/// M4: `upload_file` with a `max_upload_bytes` cap rejects oversized files
/// before any network I/O.
#[tokio::test]
async fn upload_file_respects_max_upload_bytes() {
let server = MockServer::start().await;
// No /files mock — if the cap fails to short-circuit, the test will see
// a 404 from wiremock instead of Error::Config.
let mut tmp = tempfile::NamedTempFile::new().unwrap();
// Write 1024 bytes; cap at 512.
write!(tmp, "{}", "x".repeat(1024)).unwrap();
tmp.flush().unwrap();
let c = Client::builder()
.base_url(server.uri())
.token("cf_test_token")
.max_upload_bytes(512)
.build()
.unwrap();
let err = c
.upload_file(tmp.path(), Some(1800))
.await
.expect_err("should reject oversize");
assert!(matches!(err, Error::Config(_)), "got {err:?}");
}

425
vendor/clawdforge/tests/sessions.rs vendored Normal file
View file

@ -0,0 +1,425 @@
//! Integration tests for the v0.2 multi-turn Session API.
//!
//! All tests run against an in-process `wiremock` server — no live clawdforge
//! required.
use std::time::Duration;
use clawdforge::{Client, Error, SessionOptions, TurnEvent, TurnResult};
use serde_json::json;
use wiremock::matchers::{body_json, header, method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_client(server: &MockServer) -> Client {
Client::builder()
.base_url(server.uri())
.token("cf_test_token")
.timeout(Duration::from_secs(5))
.build()
.expect("client builds")
}
fn mock_create(session_id: &str) -> Mock {
Mock::given(method("POST"))
.and(path("/sessions"))
.and(header("authorization", "Bearer cf_test_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session_id": session_id,
"agent": "claude",
"created_at": 1_700_000_000_i64,
})))
}
fn mock_delete_ok(session_id: &str) -> Mock {
Mock::given(method("DELETE"))
.and(path(format!("/sessions/{session_id}")))
.and(header("authorization", "Bearer cf_test_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
}
#[tokio::test]
async fn test_new_session_and_close() {
let server = MockServer::start().await;
mock_create("sess_abc").expect(1).mount(&server).await;
mock_delete_ok("sess_abc").expect(1).mount(&server).await;
let c = make_client(&server);
let s = c
.new_session(SessionOptions::default())
.await
.expect("new_session");
assert_eq!(s.id(), "sess_abc");
assert_eq!(s.agent(), "claude");
assert_eq!(s.created_at(), 1_700_000_000);
assert!(!s.is_closed());
s.close().await.expect("close");
// wiremock verifies expectations on Drop of the server.
}
#[tokio::test]
async fn test_turn_round_trip() {
let server = MockServer::start().await;
mock_create("sess_t1").mount(&server).await;
Mock::given(method("POST"))
.and(path("/sessions/sess_t1/turn"))
.and(header("authorization", "Bearer cf_test_token"))
.and(body_json(json!({"prompt": "hello"})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"session_id": "sess_t1",
"turn_index": 1,
"events": [
{"type": "thinking", "content": "..."},
{"type": "text", "content": "hi back"}
],
"stop_reason": "end_turn",
"duration_ms": 250
})))
.expect(1)
.mount(&server)
.await;
// Allow drop-close to land without failing other assertions.
Mock::given(method("DELETE"))
.and(path("/sessions/sess_t1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
let c = make_client(&server);
let mut s = c.new_session(SessionOptions::default()).await.unwrap();
let r: TurnResult = s.turn("hello").await.unwrap();
assert!(r.ok);
assert_eq!(r.session_id, "sess_t1");
assert_eq!(r.turn_index, 1);
assert_eq!(r.events.len(), 2);
assert_eq!(r.stop_reason, "end_turn");
assert_eq!(r.duration_ms, 250);
assert_eq!(r.text(), "hi back");
// Drive the turn_with_files path too.
Mock::given(method("POST"))
.and(path("/sessions/sess_t1/turn"))
.and(body_json(
json!({"prompt": "next", "files": ["ff_one", "ff_two"]}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"session_id": "sess_t1",
"turn_index": 2,
"events": [{"type": "text", "content": "ok"}],
"stop_reason": "end_turn",
"duration_ms": 10
})))
.expect(1)
.mount(&server)
.await;
let r2 = s
.turn_with_files("next", &["ff_one".into(), "ff_two".into()])
.await
.unwrap();
assert_eq!(r2.turn_index, 2);
assert_eq!(r2.text(), "ok");
s.close().await.unwrap();
}
#[tokio::test]
async fn test_close_idempotent_short_circuits() {
let server = MockServer::start().await;
mock_create("sess_idem").mount(&server).await;
// Expect EXACTLY ONE delete — second close() is in-memory.
mock_delete_ok("sess_idem").expect(1).mount(&server).await;
let c = make_client(&server);
let s = c.new_session(SessionOptions::default()).await.unwrap();
let id = s.id().to_string();
s.close().await.unwrap();
// Second close-equivalent: rebuild a Session-shape via reconstructing the
// closed state would require private constructors. Instead, drive the
// semantic check via close_session_internal contract: a fresh Session from
// a *new* create that we close twice. But since `close` consumes self,
// "second close" semantically means a Drop after an explicit close — and
// that path is covered by `test_drop_after_explicit_close_no_double_call`.
//
// What this test asserts is the wiremock `expect(1)` on the DELETE: one
// close => one DELETE => idempotency at the network layer holds.
let _ = id;
}
#[tokio::test]
async fn test_drop_fires_async_close() {
let server = MockServer::start().await;
mock_create("sess_drop").mount(&server).await;
mock_delete_ok("sess_drop").expect(1).mount(&server).await;
let c = make_client(&server);
{
let _s = c.new_session(SessionOptions::default()).await.unwrap();
// _s drops here — Drop spawns the async close.
}
// Yield repeatedly so the spawned future has a chance to run + the HTTP
// request lands at wiremock. wiremock asserts `expect(1)` on Drop of the
// server.
for _ in 0..50 {
tokio::task::yield_now().await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
#[tokio::test]
async fn test_drop_after_explicit_close_no_double_call() {
let server = MockServer::start().await;
mock_create("sess_once").mount(&server).await;
// Exactly ONE delete — explicit close fires it, Drop should short-circuit.
mock_delete_ok("sess_once").expect(1).mount(&server).await;
let c = make_client(&server);
let s = c.new_session(SessionOptions::default()).await.unwrap();
s.close().await.unwrap();
// Yield to give any erroneous spawn a chance to land — if Drop spawned a
// second DELETE, wiremock's expect(1) would fail.
for _ in 0..20 {
tokio::task::yield_now().await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
#[tokio::test]
async fn test_list_sessions() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sessions"))
.and(header("authorization", "Bearer cf_test_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"sessions": [
{
"session_id": "sess_a",
"agent": "claude",
"app_name": "cauldron",
"created_at": 1_700_000_000_i64,
"last_turn_at": 1_700_000_500_i64,
"turn_count": 3,
"closed_at": null
},
{
"session_id": "sess_b",
"agent": "claude",
"app_name": "cauldron",
"created_at": 1_700_000_100_i64,
"last_turn_at": null,
"turn_count": 0,
"closed_at": 1_700_001_000_i64
}
]
})))
.mount(&server)
.await;
let c = make_client(&server);
let list = c.list_sessions().await.unwrap();
assert_eq!(list.sessions.len(), 2);
assert_eq!(list.sessions[0].session_id, "sess_a");
assert_eq!(list.sessions[0].turn_count, 3);
assert_eq!(list.sessions[0].last_turn_at, Some(1_700_000_500));
assert_eq!(list.sessions[1].closed_at, Some(1_700_001_000));
}
#[tokio::test]
async fn test_get_session() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sessions/sess_q"))
.and(header("authorization", "Bearer cf_test_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session_id": "sess_q",
"agent": "claude",
"app_name": "cauldron",
"created_at": 1_700_000_000_i64,
"last_turn_at": 1_700_000_900_i64,
"turn_count": 7,
"closed_at": null
})))
.mount(&server)
.await;
let c = make_client(&server);
let st = c.get_session("sess_q").await.unwrap();
assert_eq!(st.session_id, "sess_q");
assert_eq!(st.agent, "claude");
assert_eq!(st.app_name, "cauldron");
assert_eq!(st.turn_count, 7);
assert!(st.closed_at.is_none());
}
#[tokio::test]
async fn test_cross_token_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"^/sessions/sess_other"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
"detail": "session not found"
})))
.mount(&server)
.await;
let c = make_client(&server);
let err = c
.get_session("sess_other")
.await
.expect_err("cross-token must 404");
match err {
Error::Api { status, body } => {
assert_eq!(status, 404);
assert!(body.contains("session not found"), "body was {body}");
}
other => panic!("expected Error::Api {{ status: 404, .. }}, got {other:?}"),
}
}
#[tokio::test]
async fn test_turn_result_text_concat() {
let r = TurnResult {
ok: true,
session_id: "sess".into(),
turn_index: 1,
events: vec![
TurnEvent {
event_type: "thinking".into(),
content: Some("ignored".into()),
name: None,
args: None,
result: None,
},
TurnEvent {
event_type: "text".into(),
content: Some("hello ".into()),
name: None,
args: None,
result: None,
},
TurnEvent {
event_type: "tool_call".into(),
content: None,
name: Some("Read".into()),
args: Some(json!({"path": "/x"})),
result: Some(json!({"ok": true})),
},
TurnEvent {
event_type: "text".into(),
content: Some("world".into()),
name: None,
args: None,
result: None,
},
TurnEvent {
// text event with None content — must contribute the empty
// string, not panic, not stringify "None".
event_type: "text".into(),
content: None,
name: None,
args: None,
result: None,
},
],
stop_reason: "end_turn".into(),
duration_ms: 99,
};
assert_eq!(r.text(), "hello world");
}
#[tokio::test]
async fn test_session_debug_does_not_leak_token() {
let server = MockServer::start().await;
let secret = "cf_super_secret_session_bearer";
Mock::given(method("POST"))
.and(path("/sessions"))
.and(header("authorization", format!("Bearer {secret}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session_id": "sess_dbg",
"agent": "claude",
"created_at": 1_700_000_000_i64,
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/sessions/sess_dbg"))
.and(header("authorization", format!("Bearer {secret}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
let c = Client::builder()
.base_url(server.uri())
.token(secret)
.build()
.unwrap();
let s = c.new_session(SessionOptions::default()).await.unwrap();
let dbg = format!("{s:?}");
assert!(
!dbg.contains(secret),
"token leaked through Session Debug: {dbg}"
);
// The Session Debug should print these visible bits.
assert!(dbg.contains("sess_dbg"), "session_id missing: {dbg}");
assert!(dbg.contains("agent"), "agent field missing: {dbg}");
assert!(dbg.contains("closed"), "closed field missing: {dbg}");
s.close().await.unwrap();
}
/// Regression: `new_session` with options serializes the `agent` and `meta`
/// fields when set, omits them when None.
#[tokio::test]
async fn test_new_session_options_serialize() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sessions"))
.and(body_json(json!({
"agent": "claude",
"meta": {"trace_id": "t-123"}
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"session_id": "sess_opts",
"agent": "claude",
"created_at": 1
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/sessions/sess_opts"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
let c = make_client(&server);
let s = c
.new_session(SessionOptions {
agent: Some("claude".into()),
meta: Some(json!({"trace_id": "t-123"})),
})
.await
.unwrap();
assert_eq!(s.id(), "sess_opts");
s.close().await.unwrap();
}
/// `get_session` / `close` / `turn` must reject path-traversal session ids
/// before issuing any HTTP request — same defense-in-depth pattern as
/// `revoke_token`.
#[tokio::test]
async fn test_session_id_rejects_path_traversal() {
let server = MockServer::start().await;
let c = make_client(&server);
for bad in ["", "../foo", "..", "foo/bar", "foo?x=1", "foo#frag"] {
let err = c
.get_session(bad)
.await
.expect_err(&format!("get_session({bad:?}) should reject"));
assert!(
matches!(err, Error::Config(_)),
"{bad:?} produced wrong variant: {err:?}"
);
}
}