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().
This commit is contained in:
parent
79f4393f00
commit
8cbc9e04eb
17 changed files with 3340 additions and 19 deletions
517
vendor/clawdforge/tests/client.rs
vendored
Normal file
517
vendor/clawdforge/tests/client.rs
vendored
Normal 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
425
vendor/clawdforge/tests/sessions.rs
vendored
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue