skald/vendor/clawdforge/src/client.rs
Sulkta 8cbc9e04eb v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan
The Rust SDK already existed at Sulkta-OSS/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-OSS/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().
2026-05-13 10:18:56 -07:00

515 lines
18 KiB
Rust

//! 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])
}
}