From 0320ab5ce02615ce434ca1d6acf044ce70989ded Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 5 Jul 2026 02:55:55 -0400 Subject: [PATCH 1/5] feat(trunk): CallControlClient trait + MockCallControlClient + TwilioCredentials (slice-5 T2) The provider call-control seam (green zone, ADR-0008). The trait locks the boundary so the next provider (Telnyx, etc.) is an implementation, not a refactor. MockCallControlClient is the CI test double; the live TwilioCallControlClient (T6) lives behind the twilio-live feature flag. TwilioCredentials lives ONLY in crates/rutster-trunk/ -- never re-exported through the workspace (ADR-0009 -- provider credentials never reach the brain). Its Debug impl is hand-written (NOT derived) so the auth_token renders as , never leaking into tracing/panic output. Option on originate is the pre-paved seam for spearhead step-6 (spend cap); this slice passes None everywhere. The signature is locked so step 6 is additive, not a refactor. T2 of slice-5. lib.rs gains `pub mod provider;` -- stacked-branches carve-out (rebase-merge, not squash) per AGENTS.md Git workflow; dev-c rebases forward for the FOB-side pub mod declarations (g711/twilio_media_streams/session/ loop_driver). Signed-off-by: Aaron D. Lee --- Cargo.lock | 5 + crates/rutster-trunk/Cargo.toml | 31 +++- crates/rutster-trunk/src/lib.rs | 34 ++-- crates/rutster-trunk/src/provider/mock.rs | 204 +++++++++++++++++++++ crates/rutster-trunk/src/provider/mod.rs | 209 ++++++++++++++++++++++ 5 files changed, 472 insertions(+), 11 deletions(-) create mode 100644 crates/rutster-trunk/src/provider/mock.rs create mode 100644 crates/rutster-trunk/src/provider/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 8e504bf..f0525f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1280,6 +1280,11 @@ dependencies = [ [[package]] name = "rutster-trunk" version = "0.0.0" +dependencies = [ + "async-trait", + "tokio", + "url", +] [[package]] name = "ryu" diff --git a/crates/rutster-trunk/Cargo.toml b/crates/rutster-trunk/Cargo.toml index bd92d0d..2f05691 100644 --- a/crates/rutster-trunk/Cargo.toml +++ b/crates/rutster-trunk/Cargo.toml @@ -5,4 +5,33 @@ version = "0.0.0" license.workspace = true edition.workspace = true repository.workspace = true -description = "Rented carrier transport — CPaaS media-leg ingress / out-of-tree SBC; no first-party SIP (filled in step 5, ADR-0007)." +description = "Rented carrier transport — CPaaS media-leg ingress; no first-party SIP (spearhead step 5, ADR-0007)." + +[dependencies] +# async-trait: lets us declare `async fn` in the CallControlClient trait while +# still using it as a trait object (`Box`) in the route +# handlers. Native async-fn-in-traits (stable since 1.75) does NOT support +# `dyn` dispatch without manual desugaring — async_trait rewrites the signature +# to `fn -> Pin>` for us (spec §3.4). +async-trait = { workspace = true } +# url: the `TwilioCredentials::webhook_base` field is a `url::Url` (the +# operator's public base URL Twilio calls back). Parsed in `config::twilio_credentials`. +url = { workspace = true } + +[dev-dependencies] +# tokio: only the dev-dep is needed for the mock's `#[tokio::test]` attribute +# (`#[tokio::test]` expands to a `#[test]` that spins up a current-thread +# runtime + drives the async fn to completion). The library itself is +# runtime-agnostic: async_trait's desugared futures poll on whatever runtime +# the caller drives them with — the mock's bodies are synchronous (lock + push, +# no `.await`), so no tokio dependency leaks into the library's public surface. +tokio = { workspace = true } + +[features] +default = [] +# The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live` +# so the routine CI gate stays feature-default-off: `MockCallControlClient` is +# the per-PR test surface; the maintainer runs `cargo test --features=twilio-live` +# + the e2e suite only when validating a release (against real Twilio creds). +# Spec §1.2 + plan T6 + ADR-0009 (credentials never reach the brain). +twilio-live = [] diff --git a/crates/rutster-trunk/src/lib.rs b/crates/rutster-trunk/src/lib.rs index 96aee3b..0821cce 100644 --- a/crates/rutster-trunk/src/lib.rs +++ b/crates/rutster-trunk/src/lib.rs @@ -1,24 +1,38 @@ //! # rutster-trunk //! -//! **Status:** stub. Fills in at spearhead step 5 (a real phone number via rented transport). -//! //! Carrier / PSTN reach for rutster — **rented transport, no first-party SIP** -//! ([ADR-0007](../../../docs/adr/0007-trunk-rented-transport.md)). This crate will hold the +//! ([ADR-0007](../../../docs/adr/0007-trunk-rented-transport.md)). This crate holds the //! rented-transport ingress: the **CPaaS media-leg adapter** — a WebSocket server accepting a //! provider raw-media fork (Twilio Media Streams / Telnyx) plus the provider call-control //! client — and later the glue for an **out-of-tree SBC** when PSTN media must stay on-prem. //! It owns **no SIP stack**: SIP lives outside the trust boundary, in the rented transport //! (green zone — [ADR-0008](../../../docs/adr/0008-fob-and-green-zone.md)). //! -//! Slice 1's WebRTC-only ingress needs none of this — this stub exists to lock the crate -//! boundary without anticipating code (spec §2.2). Its future dependency direction is -//! `rutster-trunk` → `rutster-call-model` + `rutster-media`: PSTN audio is resampled to the -//! canonical tap format and handed to the media plane as a media-leg ingress, parallel to -//! WebRTC. +//! ## Spearhead step 5 — what's here vs what's deferred +//! +//! The provider call-control seam ([`provider`](crate::provider)) lands in T2: the +//! `CallControlClient` trait, `MockCallControlClient`, and `TwilioCredentials` (with +//! redacted `Debug`). The live `TwilioCallControlClient` (T6) is feature-gated behind +//! `twilio-live`. The WSS ingress (`g711`, `twilio_media_streams`, `session`, +//! `loop_driver`) lands in the parallel FOB-side tasks (T1/T3/T4) — see the slice-5 spec. +//! +//! ## Dependency direction +//! +//! `rutster-trunk` → `rutster-call-model` + `rutster-media` (once the FOB-side media path +//! lands): PSTN audio is resampled to the canonical tap format and handed to the media plane +//! as a media-leg ingress, parallel to WebRTC. The provider (call-control) side is +//! green-zone and depends on no FOB media types — only on `url` + `async-trait` (T2). + +pub mod provider; #[cfg(test)] mod tests { - /// Stub crates lock boundaries; the compile-test is the lock. + /// Stub crates lock boundaries; the compile-test is the lock. Now that the + /// provider module is populated (T2), this test also guards the crate-level + /// re-exports compile against the public API surface. #[test] - fn crate_compiles() {} + fn crate_compiles() { + // Touch the public API so the test exercises the re-export wiring. + let _mock = crate::provider::MockCallControlClient::new(); + } } diff --git a/crates/rutster-trunk/src/provider/mock.rs b/crates/rutster-trunk/src/provider/mock.rs new file mode 100644 index 0000000..7d98c26 --- /dev/null +++ b/crates/rutster-trunk/src/provider/mock.rs @@ -0,0 +1,204 @@ +//! # MockCallControlClient — in-process test double for `CallControlClient` +//! +//! CI tests + the PSTN-sim e2e integration tests (T7, T8) use this mock; the +//! live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live` +//! and runs only on the maintainer's machine against real Twilio credentials. +//! +//! ## Why `std::sync::Mutex` (not `tokio::sync::Mutex`) +//! +//! The mock's async fn bodies are **synchronous** — they lock, push a record, +//! unlock, and return. There is no `.await` while holding the lock, so the +//! std mutex (cheaper, no async-overhead, no runtime coupling) is correct. +//! `tokio::sync::Mutex` is for guards held across `.await` points; using it +//! here would be cargo-cult. The async fns exist only to satisfy the trait's +//! `async` signature (desugared by `async_trait` to `Pin>`); their +//! bodies do not actually suspend. +//! +//! ## Poison recovery +//! +//! `std::sync::Mutex` "poisons" on panic — a previous lock holder that panicked +//! leaves the mutex poisoned, and `.lock()` returns `Err(PoisonError)`. For a +//! *test double*, a poisoned mutex means a previous test assertion failed; the +//! right move is to recover the inner value (`.into_inner()`) rather than +//! propagate the poison (which would cascade one test failure into every +//! subsequent test). [`lock_or_recover`] does this idiomatically — and avoids +//! the `unwrap()`/`expect()` that AGENTS.md's hot-path rule forbids in library +//! code (the mock is library code, not `#[cfg(test)]`, because T7/T8 integration +//! tests in `tests/` import it). + +use std::sync::{Mutex, MutexGuard}; + +use async_trait::async_trait; + +use super::{CallControlClient, CallControlError, SpendToken}; + +/// Recover a `MutexGuard` even if the mutex is poisoned. +/// +/// Poison indicates a prior lock-holder panicked (a test assertion failed +/// while holding the lock). For a test double, propagating the poison would +/// cascade the failure into every subsequent test that touches the mock; +/// recovering the inner guard (`PoisonError::into_inner`) localizes the +/// failure. This is the standard documented recovery pattern for non-invariant +/// mutexes (see `std::sync::PoisonError::into_inner` docs). +fn lock_or_recover(lock: &Mutex) -> MutexGuard<'_, T> { + lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// A captured `originate` call — for test assertions. +/// +/// `spend_token_present` (not the token itself) is recorded because `SpendToken` +/// is opaque in step 6; the mock only needs to verify whether a token was +/// supplied, not inspect it. This keeps the mock forward-compatible with step +/// 6's real (opaque, possibly non-`Clone`) token type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OriginateRecord { + /// The `to_phone` arg passed to `originate`. + pub to: String, + /// The `from_phone` arg passed to `originate`. + pub from: String, + /// Whether the caller passed `Some(SpendToken)` (step 6 will; step 5 passes `None`). + pub spend_token_present: bool, +} + +/// In-process test double for [`CallControlClient`]. No network IO. +/// +/// `originate` returns a synthetic `CA...` correlation ID (matching Twilio's +/// CallSid format — `CA` + 32 hex chars — so downstream code that parses the +/// prefix is exercised honestly) and records the call. `hangup` records the +/// terminate. Both are infallible in the mock (no real provider to fail); the +/// `Result` wrappers return `Ok` so test wiring that propagates the `Result` +/// is exercised identically to the live path. +pub struct MockCallControlClient { + /// Monotonic counter for synthetic CallSid generation. `AtomicU64` rather + /// than a `Mutex` because an atomic increment needs no guard (no + /// critical section) — cheaper and lock-poison-free. + counter: std::sync::atomic::AtomicU64, + originated: Mutex>, + hung_up: Mutex>, +} + +impl MockCallControlClient { + pub fn new() -> Self { + Self { + counter: std::sync::atomic::AtomicU64::new(0), + originated: Mutex::new(Vec::new()), + hung_up: Mutex::new(Vec::new()), + } + } + + /// Snapshot of every `originate` call the mock received, in arrival order. + pub fn originated_calls(&self) -> Vec { + lock_or_recover(&self.originated).clone() + } + + /// Snapshot of every `correlation_id` passed to `hangup`, in arrival order. + pub fn hung_up_calls(&self) -> Vec { + lock_or_recover(&self.hung_up).clone() + } +} + +impl Default for MockCallControlClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl CallControlClient for MockCallControlClient { + async fn originate( + &self, + to_phone: &str, + from_phone: &str, + spend_token: Option, + ) -> Result { + let id = self + .counter + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // Twilio CallSids are "CA" + 32 hex chars. The mock mirrors that shape + // so any code that parses the prefix/length exercises the real path. + let call_sid = format!("CA{id:032x}"); + + let mut originated = lock_or_recover(&self.originated); + originated.push(OriginateRecord { + to: to_phone.to_owned(), + from: from_phone.to_owned(), + spend_token_present: spend_token.is_some(), + }); + + Ok(call_sid) + } + + async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError> { + let mut hung_up = lock_or_recover(&self.hung_up); + // Idempotent in the mock (matching the live provider): recording the + // same id twice is not an error, matching Twilio's `Status=completed` + // semantics. A dedup check would be test-specific logic the real + // provider doesn't expose. + hung_up.push(correlation_id.to_owned()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn originate_returns_call_sid_and_records_call() { + let mock = MockCallControlClient::new(); + let sid = mock + .originate("+15551234567", "+15550000000", None) + .await + .expect("mock originate never errors"); + + // Twilio CallSid shape: "CA" + 32 hex chars = 34 chars total. + assert!(sid.starts_with("CA"), "expected CA prefix, got {sid}"); + assert_eq!(sid.len(), 34, "expected 34-char CallSid, got {sid}"); + + let calls = mock.originated_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].to, "+15551234567"); + assert_eq!(calls[0].from, "+15550000000"); + assert!( + !calls[0].spend_token_present, + "step 5 passes None for spend_token" + ); + } + + #[tokio::test] + async fn originate_produces_distinct_call_sids_per_call() { + let mock = MockCallControlClient::new(); + let sid_a = mock.originate("+1", "+2", None).await.unwrap(); + let sid_b = mock.originate("+3", "+4", None).await.unwrap(); + assert_ne!(sid_a, sid_b, "each originate must get a unique CallSid"); + assert_eq!(mock.originated_calls().len(), 2); + } + + #[tokio::test] + async fn hangup_records_correlation_id_and_is_idempotent() { + let mock = MockCallControlClient::new(); + mock.hangup("CAdeadbeef").await.unwrap(); + // Calling again with the same id is a no-op success (live provider is idempotent). + mock.hangup("CAdeadbeef").await.unwrap(); + mock.hangup("CAcafef00d").await.unwrap(); + + let hung = mock.hung_up_calls(); + // The mock records every call (no dedup) — 2 for the repeat + 1 for the other. + assert_eq!(hung.len(), 3); + assert_eq!( + hung.iter().filter(|s| s.as_str() == "CAdeadbeef").count(), + 2 + ); + } + + #[tokio::test] + async fn spend_token_some_is_recorded_as_present() { + // Step 5 callers pass None, but the trait accepts Some(SpendToken) for + // forward-compat with step 6. The mock records presence only. + let mock = MockCallControlClient::new(); + mock.originate("+1", "+2", Some(SpendToken)).await.unwrap(); + let calls = mock.originated_calls(); + assert_eq!(calls.len(), 1); + assert!(calls[0].spend_token_present); + } +} diff --git a/crates/rutster-trunk/src/provider/mod.rs b/crates/rutster-trunk/src/provider/mod.rs new file mode 100644 index 0000000..6fccb21 --- /dev/null +++ b/crates/rutster-trunk/src/provider/mod.rs @@ -0,0 +1,209 @@ +//! # Provider — the call-control seam (green zone, ADR-0008) +//! +//! This module locks the boundary between rutster's FOB (the memory-safe Rust +//! trust domain that owns the per-call media vertical) and the rented carrier +//! transport (a CPaaS provider — Twilio for the spearhead MVP, Telnyx or others +//! later). Per [ADR-0007](../../../../docs/adr/0007-trunk-rented-transport.md), +//! rutster parses **zero SIP bytes**: PSTN reach is *rented transport*. +//! Per [ADR-0008](../../../../docs/adr/0008-fob-and-green-zone.md), the call-control +//! client lives in the **green zone** — arm's length, behind a trait — because it +//! is neither hot-path (it fires only on originate + inbound-webhook receipt) nor +//! security-constitutive (the actual media never transits it; only REST commands do). +//! +//! ## Why a trait (not a concrete Twilio client inline) +//! +//! The trait locks the seam so the **next provider** (Telnyx, etc.) is an +//! *implementation*, not a *refactor*. The binary's route handlers depend on +//! `dyn CallControlClient`, not on `TwilioCallControlClient` directly — swapping +//! providers is a constructor change in `main.rs`, not a rewrite of the handlers. +//! This is the same "lock the seam" discipline that `AudioPipe` applies to the +//! media direction. +//! +//! ## Credential isolation (ADR-0009 — load-bearing for spearhead step 6) +//! +//! [`TwilioCredentials`] lives **only** in this crate (`rutster-trunk`). It is +//! `pub` to the binary crate (`crates/rutster/`) so `config.rs` can construct +//! it from env vars and hand it to `TwilioCallControlClient::new`, but it is +//! **never re-exported through the workspace root**, never appears in +//! `rutster-media`'s or `rutster-tap`'s public API, and never crosses the +//! WebSocket tap protocol to the brain. The brain carries ONLY audio (PCM +//! in/out), function-call events, and `speech_started`/`speech_stopped` +//! advisories — never the account SID, the `auth_token`, the REST endpoint +//! URL, or the `CallSid`. T10's static assertion test re-checks this +//! invariant mechanically. +//! +//! The `auth_token` field's [`Debug`](std::fmt::Debug) impl is **hand-written** +//! (NOT derived) so the token is rendered as `` — derived `Debug` +//! would print the secret in any `tracing::debug!` or `{:?}` log line. This is +//! the same posture as `api_key.rs`'s redacted Debug (slice-3). + +use std::net::SocketAddr; + +pub mod mock; + +pub use mock::{MockCallControlClient, OriginateRecord}; + +/// Call-control operations a rented-transport provider exposes. +/// +/// `Send + Sync` so it can live behind an `Arc` shared +/// across the axum route handlers' tokio tasks. The `#[async_trait]` attribute +/// (from the `async-trait` crate) rewrites each `async fn` into a synchronous +/// `fn -> Pin + Send>>` — that boxing is *why* the +/// trait is usable as a trait object (`dyn CallControlClient`). Native +/// `async fn` in traits (stable since Rust 1.75) does not yet support `dyn` +/// dispatch, so the macro is the pragmatic bridge (spec §3.4). +#[async_trait::async_trait] +pub trait CallControlClient: Send + Sync { + /// Originate an outbound call to `to_phone` from `from_phone`. + /// + /// The provider answers the PSTN call itself, opens a Media Streams fork + /// back to rutster, and returns a **provider-assigned correlation ID** + /// (Twilio: the `CallSid`, a `CA...` string). The binary correlates this + /// ID with its own `ChannelId` for log/CDR join. + /// + /// `spend_token` is the **pre-paved seam for spearhead step 6** (the spend + /// cap). This slice (step 5) passes `None` everywhere — no spend gate yet. + /// Step 6's spend gate will mint a token before any originate is dispatched + /// and pass `Some(token)` here; the live impl will reject `None` once the + /// gate is live. **Do not remove this parameter** "to simplify" — step 6 + /// needs the signature stable so it is additive, not a refactor. + async fn originate( + &self, + to_phone: &str, + from_phone: &str, + spend_token: Option, + ) -> Result; + + /// Hang up an in-progress call. Idempotent — calling with an already-ended + /// `correlation_id` returns `Ok(())` (the provider's REST API is itself + /// idempotent on `Status=completed`). Returns after the provider + /// acknowledges the terminate. + async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError>; +} + +/// A provider call-control failure. A newtype over `String` rather than a +/// rich enum because the MVP's only concern is "did it work" (the route handler +/// maps `Err` → HTTP 502); structured error variants (auth-failed vs +/// rate-limited vs network) land with the second provider (Telnyx) when the +/// distinction starts to matter operationally. +#[derive(Debug, Clone)] +pub struct CallControlError(pub String); + +impl std::fmt::Display for CallControlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "call-control error: {}", self.0) + } +} + +impl std::error::Error for CallControlError {} + +/// A placeholder for the spearhead step-6 **spend-gate token**. +/// +/// This slice (step 5) passes `None` for `spend_token` on every `originate` +/// call — the spend/abuse gate ([ADR-0009](../../../../docs/adr/0009-spend-gate-honest-rescope.md)) +/// does not exist yet. The type exists so the trait signature is **locked** +/// now: step 6 will flesh `SpendToken` out into a real carrier (minted by the +/// spend gate, carrying the per-call budget) without changing +/// `CallControlClient::originate`'s signature — every existing call site stays +/// valid; only the `None` → `Some(token)` migration is incremental. +/// +/// A unit struct (not a zero-field tuple struct) purely for construction +/// ergonomics: `SpendToken` (no parens) is how the spend gate will build it. +pub struct SpendToken; + +/// Twilio account credentials + the operator's media-ingress topology. +/// +/// Lives **only** in `rutster-trunk` (ADR-0009 — provider credentials never +/// reach the brain). `pub` to the binary crate so `config::twilio_credentials` +/// can construct it; never re-exported through the workspace. +/// +/// `Debug` is **hand-implemented** below to redact `auth_token` — deriving +/// `Debug` would print the secret to any `tracing::debug!` / `{:?}` / panic +/// backtrace line. The redaction is a security-constitutive choice, not a +/// convenience. +/// +/// The `twilio-live` feature gates the live client (`TwilioCallControlClient`, +/// T6) that consumes this struct. The struct itself is unconditionally +/// compiled because `config.rs`'s env parser returns `Option` +/// regardless of feature flags — the binary holds the parsed config even when +/// the live REST client is compiled out of the CI default build. +#[derive(Clone)] +pub struct TwilioCredentials { + /// Twilio account SID (the `AC...` identifier). Not a secret per se (it + /// appears in URLs), but treated as sensitive for defense-in-depth. + pub account_sid: String, + /// Twilio auth token — **NEVER logged** (see the hand-impl `Debug` below), + /// never passed to the brain, never placed in a tracing field. The live + /// client uses it for HTTP basic auth on the REST API only. + pub auth_token: String, + /// The local socket address where rutster's `TwilioMediaStreamsServer` + /// (T3) binds to accept inbound Twilio Media Streams WSS connections. + /// Twilio is told to fork audio here (via the TwiML `` directive). + pub media_streams_bind: SocketAddr, + /// The operator's public base URL — the address Twilio calls back via the + /// webhook (`POST /v1/trunk/webhook`) for inbound-call signaling. In local + /// dev this is `http://localhost:8080`; production is a public HTTPS URL. + pub webhook_base: url::Url, +} + +impl std::fmt::Debug for TwilioCredentials { + /// Hand-implemented so `auth_token` renders as ``. + /// + /// Do NOT replace with `#[derive(Debug)]` — that would print the secret + /// token in any `{:?}` formatting (panic backtraces, `tracing::debug!`, + /// test-failure diffs). The other fields are operator-configured and + /// safe to log. This mirrors `api_key.rs`'s redacted-Debug pattern + /// (slice-3) — the codebase's standing posture on secrets. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TwilioCredentials") + .field("account_sid", &self.account_sid) + .field("auth_token", &"") + .field("media_streams_bind", &self.media_streams_bind) + .field("webhook_base", &self.webhook_base) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn twilio_credentials_debug_redacts_auth_token() { + let creds = TwilioCredentials { + account_sid: "AC_test_sid".into(), + auth_token: "super-secret-token-do-not-log".into(), + media_streams_bind: "0.0.0.0:8081".parse().unwrap(), + webhook_base: "https://example.com".parse().unwrap(), + }; + let rendered = format!("{:?}", creds); + assert!( + !rendered.contains("super-secret-token-do-not-log"), + "auth_token leaked into Debug output: {rendered}" + ); + assert!( + rendered.contains(""), + "Debug output should mark auth_token as : {rendered}" + ); + // Non-secret fields remain visible for operator log-correlation. + assert!(rendered.contains("AC_test_sid")); + assert!(rendered.contains("0.0.0.0:8081")); + } + + #[test] + fn call_control_error_displays_and_contains_message() { + let err = CallControlError("boom".into()); + assert_eq!(format!("{err}"), "call-control error: boom"); + // std::error::Error bounds check (compiles only if the impl is present). + let _: &dyn std::error::Error = &err; + } + + #[test] + fn spend_token_is_unit_constructable_for_future_step6_use() { + // This slice passes None; the construction here proves the type is + // usable as Some(SpendToken) once step 6 mints real tokens. + let _token = SpendToken; + let none: Option = None; + assert!(none.is_none()); + } +} From d19d772bd09e07955a59a8916732b2362620a658 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 5 Jul 2026 03:04:22 -0400 Subject: [PATCH 2/5] feat(trunk): TwilioCallControlClient (live REST impl behind twilio-live) + env parser (slice-5 T6) The live Twilio call-control client. Originates outbound calls via Twilio's Calls.json API (TwiML instructs Twilio to fork audio back to our /twilio/media-stream WSS endpoint); hangs up via Status=completed. HTTP basic auth over HTTPS; auth_token is NEVER logged (ADR-0009 -- provider credentials never reach the brain) -- tracing fields expose only caller- controlled values (to, from) + the CallSid's last 4 chars. Feature-gated behind `twilio-live` so the routine CI gate stays feature- default-off: MockCallControlClient is the per-PR test surface; the maintainer runs cargo test --features=twilio-live when validating a release. reqwest + tracing + serde_json are optional deps (dep:foo syntax) -- pulled in only when the feature is on, keeping the default resolve lean. The env parser (config::twilio_credentials) follows slice-5/seams' pure- function pattern: takes Option inputs (testable without env mutation), returns Ok(None) when all four RUTSTER_TWILIO_* vars are unset (WebRTC-only mode), Ok(Some) when all four present + parse, Err on partial config (fail- fast at startup) or malformed values. TwilioCredentials is imported by the binary but never re-exported through the workspace (ADR-0009). T6 of slice-5. Depends on T2 (TwilioCredentials + CallControlClient trait). Signed-off-by: Aaron D. Lee --- Cargo.lock | 416 +++++++++++++++++++- Cargo.toml | 7 + crates/rutster-trunk/Cargo.toml | 15 +- crates/rutster-trunk/src/lib.rs | 4 +- crates/rutster-trunk/src/provider/mod.rs | 12 + crates/rutster-trunk/src/provider/twilio.rs | 280 +++++++++++++ crates/rutster/Cargo.toml | 7 + crates/rutster/src/config.rs | 130 ++++++ 8 files changed, 859 insertions(+), 12 deletions(-) create mode 100644 crates/rutster-trunk/src/provider/twilio.rs diff --git a/Cargo.lock b/Cargo.lock index f0525f7..8c19b1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -118,7 +118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", - "untrusted", + "untrusted 0.7.1", "zeroize", ] @@ -282,6 +282,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -326,6 +343,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -492,7 +518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -597,8 +623,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -620,8 +648,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -693,6 +724,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -701,13 +749,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -729,6 +785,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is" version = "0.10.0" @@ -802,6 +864,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -843,7 +911,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -871,7 +939,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1016,6 +1084,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -1058,6 +1182,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -1096,6 +1231,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rcgen" version = "0.14.7" @@ -1135,6 +1285,58 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1160,7 +1362,21 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", ] [[package]] @@ -1169,9 +1385,21 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1191,6 +1419,7 @@ dependencies = [ "rutster-media", "rutster-tap", "rutster-tap-echo", + "rutster-trunk", "serde", "serde_json", "thiserror 1.0.69", @@ -1282,7 +1511,10 @@ name = "rutster-trunk" version = "0.0.0" dependencies = [ "async-trait", + "reqwest", + "serde_json", "tokio", + "tracing", "url", ] @@ -1398,7 +1630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1452,7 +1684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1534,6 +1766,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1556,7 +1791,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1668,7 +1903,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1682,6 +1917,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -1710,6 +1955,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1784,6 +2047,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.24.0" @@ -1835,6 +2104,12 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.2" @@ -1875,6 +2150,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1903,6 +2187,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -1935,12 +2229,50 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1950,6 +2282,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index f96eba1..5bf060b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,3 +59,10 @@ async-trait = "0.1" # client sends before WS upgrade; `openai_client::openai_headers` produces # the header pairs the caller stuffs into it. http = "1" +# reqwest 0.12: HTTP client for the green-zone Twilio REST call-control +# client (slice-5 T6, feature-gated behind `twilio-live`). `rustls-tls` +# (not native-tls/OpenSSL) keeps the TLS stack pure-Rust + consistent with +# str0m's existing aws-lc-rs crypto provider (ADR-0001 "memory-safe by +# construction"). `default-features = false` drops the default +# native-tls/OpenSSL backend. `json` for the Calls.json response parsing. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/crates/rutster-trunk/Cargo.toml b/crates/rutster-trunk/Cargo.toml index 2f05691..0ec7af3 100644 --- a/crates/rutster-trunk/Cargo.toml +++ b/crates/rutster-trunk/Cargo.toml @@ -17,6 +17,16 @@ async-trait = { workspace = true } # url: the `TwilioCredentials::webhook_base` field is a `url::Url` (the # operator's public base URL Twilio calls back). Parsed in `config::twilio_credentials`. url = { workspace = true } +# reqwest + tracing + serde_json are OPTIONAL — only pulled in when the +# `twilio-live` feature is enabled (the live `TwilioCallControlClient`, T6). +# This keeps the default CI build (default-features-off) free of reqwest's +# transitive dep tree, so per-PR `cargo deny check` stays lean + cargo's +# resolve is fast for the common case. The maintainer's manual +# `cargo test --features=twilio-live` + the twilio-live CI job (T10) pull them in. +# `serde_json` parses the Calls.json REST response body (the `sid` field). +reqwest = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } [dev-dependencies] # tokio: only the dev-dep is needed for the mock's `#[tokio::test]` attribute @@ -34,4 +44,7 @@ default = [] # the per-PR test surface; the maintainer runs `cargo test --features=twilio-live` # + the e2e suite only when validating a release (against real Twilio creds). # Spec §1.2 + plan T6 + ADR-0009 (credentials never reach the brain). -twilio-live = [] +# `dep:reqwest` + `dep:tracing` are the optional-dep enable syntax +# (`feature = "dep:foo"` enables `foo` without exposing it as an implicit +# feature named `foo` — the explicit form is forward-compatible + unambiguous). +twilio-live = ["dep:reqwest", "dep:tracing", "dep:serde_json"] diff --git a/crates/rutster-trunk/src/lib.rs b/crates/rutster-trunk/src/lib.rs index 0821cce..29673dd 100644 --- a/crates/rutster-trunk/src/lib.rs +++ b/crates/rutster-trunk/src/lib.rs @@ -27,12 +27,14 @@ pub mod provider; #[cfg(test)] mod tests { + use crate::provider::MockCallControlClient; + /// Stub crates lock boundaries; the compile-test is the lock. Now that the /// provider module is populated (T2), this test also guards the crate-level /// re-exports compile against the public API surface. #[test] fn crate_compiles() { // Touch the public API so the test exercises the re-export wiring. - let _mock = crate::provider::MockCallControlClient::new(); + let _mock = MockCallControlClient::new(); } } diff --git a/crates/rutster-trunk/src/provider/mod.rs b/crates/rutster-trunk/src/provider/mod.rs index 6fccb21..4c176a1 100644 --- a/crates/rutster-trunk/src/provider/mod.rs +++ b/crates/rutster-trunk/src/provider/mod.rs @@ -43,6 +43,18 @@ pub mod mock; pub use mock::{MockCallControlClient, OriginateRecord}; +// The live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`. +// Including it unconditionally would pull `reqwest` into the default CI build's +// dep graph (against the lean-default-features-off posture); the `#[cfg]` here +// keeps the routine gate clean. `#[cfg(feature = ...)]` on a `pub mod` declaration +// means the module is only compiled when the feature is enabled — `lib.rs`'s +// `pub mod provider;` is unconditional (the trait + mock + credentials are always +// available); only the live REST client is gated. +#[cfg(feature = "twilio-live")] +pub mod twilio; +#[cfg(feature = "twilio-live")] +pub use twilio::TwilioCallControlClient; + /// Call-control operations a rented-transport provider exposes. /// /// `Send + Sync` so it can live behind an `Arc` shared diff --git a/crates/rutster-trunk/src/provider/twilio.rs b/crates/rutster-trunk/src/provider/twilio.rs new file mode 100644 index 0000000..daa9534 --- /dev/null +++ b/crates/rutster-trunk/src/provider/twilio.rs @@ -0,0 +1,280 @@ +//! # TwilioCallControlClient — the live Twilio REST call-control client (green zone) +//! +//! This module is **feature-gated behind `twilio-live`** — it is NOT compiled in +//! the default CI build (which uses [`MockCallControlClient`](super::MockCallControlClient)). +//! The maintainer enables it with `cargo test --features=twilio-live` when validating +//! a release against real Twilio credentials. +//! +//! ## Green zone (ADR-0008) +//! +//! This client is arm's-length from the FOB hot path: it fires only on +//! originate (outbound call) + hangup (teardown). It is NOT on the 20 ms media +//! tick. The brain never holds a reference to it; the brain never sees the +//! [`TwilioCredentials`] (ADR-0009). Only the binary's route handlers (T5) construct +//! + call this client. +//! +//! ## What it talks to +//! +//! Twilio's REST API () +//! over HTTPS with HTTP basic auth (`account_sid:auth_token`). The `originate` call +//! POSTs a form body containing `To`, `From`, + a `Twiml` parameter whose value is a +//! `` document instructing +//! Twilio to open a Media Streams WebSocket fork against our `/twilio/media-stream` +//! endpoint. `hangup` POSTs `Status=completed` to the per-call resource. +//! +//! ## Credential isolation (ADR-0009 — load-bearing) +//! +//! `auth_token` is used ONLY for the HTTP basic-auth header on the REST call. It is +//! **never** logged: `tracing::debug!` fields expose only caller-controlled values +//! (`to`, `from`) + the last 4 chars of the `CallSid` (for log correlation). The +//! `TwilioCredentials::Debug` impl (in [`super::TwilioCredentials`]) redacts the +//! token. This is the codebase's standing posture on secrets (cf. `api_key.rs`, +//! slice-3). +//! +//! ## Why `reqwest` (not `hyper` direct or hand-rolled) +//! +//! `reqwest` is the de-facto maintained Rust HTTP client; it wraps `hyper` + a TLS +//! backend. We pin `rustls-tls` (not native-tls/OpenSSL) at the workspace level to +//! keep the TLS stack pure-Rust, consistent with `str0m`'s existing `aws-lc-rs` +//! crypto provider (ADR-0001 "memory-safe by construction"). The client is optional +//! (gated by `dep:reqwest`) so the default CI resolve stays lean. + +use async_trait::async_trait; + +use super::{CallControlClient, CallControlError, TwilioCredentials}; + +/// Live Twilio call-control client. REST against Twilio's Calls API. +/// +/// Construct with [`TwilioCallControlClient::new`] (passing a [`TwilioCredentials`] +/// built by `config::twilio_credentials` in the binary crate). The `reqwest::Client` +/// is reused across calls (connection pooling); construct once at startup, not per-call. +pub struct TwilioCallControlClient { + credentials: TwilioCredentials, + http: reqwest::Client, +} + +impl TwilioCallControlClient { + /// Construct a live client. The `reqwest::Client` is created with default options + /// (rustls-tls backend, connection pool). The caller passes the credentials built + /// from env by the binary's `config::twilio_credentials` parser. + pub fn new(credentials: TwilioCredentials) -> Self { + Self { + credentials, + http: reqwest::Client::new(), + } + } + + /// Derive the public Media Streams WSS endpoint URL the TwiML `` directive + /// points Twilio at, from the operator's `webhook_base`. Twilio connects BACK to us + /// over WebSocket to fork the call's audio, so the URL must be the *public* address + /// (a reverse proxy maps the public `:443` → the local `media_streams_bind`). + /// + /// `https` → `wss`, `http` → `ws` (local dev may use plaintext WS per spec §1.2's + /// TLS-deferral; production uses `wss`). The path `/twilio/media-stream` matches the + /// route `TwilioMediaStreamsServer::router` (T3) mounts. + fn stream_url(&self) -> String { + let scheme = match self.credentials.webhook_base.scheme() { + "https" => "wss", + "http" => "ws", + other => other, + }; + let host = self + .credentials + .webhook_base + .host_str() + .unwrap_or("localhost"); + match self.credentials.webhook_base.port() { + Some(port) => format!("{scheme}://{host}:{port}/twilio/media-stream"), + None => format!("{scheme}://{host}/twilio/media-stream"), + } + } +} + +#[async_trait] +impl CallControlClient for TwilioCallControlClient { + async fn originate( + &self, + to_phone: &str, + from_phone: &str, + _spend_token: Option, + ) -> Result { + // The TwiML instructs Twilio to a back to our WSS endpoint. + // Twilio forks the call's audio (µ-law @ 8 kHz) over the WS in real time; + // TwilioMediaStreamsServer (T3) receives + decodes via G711Codec (T1). + let twiml = format!( + "", + self.stream_url() + ); + let url = format!( + "https://api.twilio.com/2010-04-01/Accounts/{}/Calls.json", + self.credentials.account_sid + ); + + let response = self + .http + .post(&url) + .basic_auth( + &self.credentials.account_sid, + Some(&self.credentials.auth_token), + ) + .form(&[("To", to_phone), ("From", from_phone), ("Twiml", &twiml)]) + .send() + .await + .map_err(|e| CallControlError(format!("twilio originate transport: {e}")))?; + + let status = response.status(); + let body: serde_json::Value = response + .json() + .await + .map_err(|e| CallControlError(format!("twilio originate body parse: {e}")))?; + + if !status.is_success() { + let msg = body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("(no message field)"); + return Err(CallControlError(format!( + "twilio originate rejected ({status}): {msg}" + ))); + } + + let call_sid = body + .get("sid") + .and_then(|v| v.as_str()) + .ok_or_else(|| CallControlError("twilio response missing 'sid' field".into()))? + .to_owned(); + + // Log ONLY caller-controlled fields + the last 4 chars of the CallSid + // (for log correlation). auth_token is NEVER logged (ADR-0009). + let last4 = &call_sid[call_sid.len().saturating_sub(4)..]; + tracing::debug!( + to = to_phone, + from = from_phone, + sid_last4 = last4, + "twilio originate acknowledged" + ); + + Ok(call_sid) + } + + async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError> { + let url = format!( + "https://api.twilio.com/2010-04-01/Accounts/{}/Calls/{}.json", + self.credentials.account_sid, correlation_id + ); + + let response = self + .http + .post(&url) + .basic_auth( + &self.credentials.account_sid, + Some(&self.credentials.auth_token), + ) + .form(&[("Status", "completed")]) + .send() + .await + .map_err(|e| CallControlError(format!("twilio hangup transport: {e}")))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(CallControlError(format!( + "twilio hangup rejected ({status}): {body}" + ))); + } + + let last4 = &correlation_id[correlation_id.len().saturating_sub(4)..]; + tracing::debug!(sid_last4 = last4, "twilio hangup acknowledged"); + + Ok(()) + } +} + +#[cfg(all(test, feature = "twilio-live"))] +mod live_tests { + //! Live e2e tests against real Twilio. `#[ignore]` because they place a real + //! outbound call (charged against your Twilio account). Run manually: + //! + //! ```bash + //! RUTSTER_TWILIO_ACCOUNT_SID=AC... \ + //! RUTSTER_TWILIO_AUTH_TOKEN=... \ + //! RUTSTER_TWILIO_MEDIA_BIND=0.0.0.0:8081 \ + //! RUTSTER_TWILIO_WEBHOOK_BASE=https://your.public.host \ + //! RUTSTER_TWILIO_TEST_TO=+1555... \ + //! RUTSTER_TWILIO_TEST_FROM=+1555... \ + //! cargo test --features=twilio-live -p rutster-trunk -- --include-ignored live_tests + //! ``` + //! + //! These read env vars directly (not via the binary's `config::twilio_credentials`) + //! because the trunk crate cannot depend on the binary crate. `#[ignore]` tests are + //! single-run (the maintainer invokes one at a time), so the env-mutation race that + //! the pure-function `config.rs` pattern guards against does not apply here. + + use super::*; + use std::net::SocketAddr; + + fn creds_from_env() -> Option { + let account_sid = std::env::var("RUTSTER_TWILIO_ACCOUNT_SID").ok()?; + let auth_token = std::env::var("RUTSTER_TWILIO_AUTH_TOKEN").ok()?; + let media_streams_bind: SocketAddr = std::env::var("RUTSTER_TWILIO_MEDIA_BIND") + .ok()? + .parse() + .ok()?; + let webhook_base: url::Url = std::env::var("RUTSTER_TWILIO_WEBHOOK_BASE") + .ok()? + .parse() + .ok()?; + Some(TwilioCredentials { + account_sid, + auth_token, + media_streams_bind, + webhook_base, + }) + } + + #[tokio::test] + #[ignore = "places a real outbound Twilio call (billed). manual run only."] + async fn originate_real_call_returns_call_sid() { + let creds = creds_from_env() + .expect("set RUTSTER_TWILIO_{ACCOUNT_SID,AUTH_TOKEN,MEDIA_BIND,WEBHOOK_BASE}"); + let client = TwilioCallControlClient::new(creds); + let to = std::env::var("RUTSTER_TWILIO_TEST_TO").expect("set RUTSTER_TWILIO_TEST_TO"); + let from = std::env::var("RUTSTER_TWILIO_TEST_FROM").expect("set RUTSTER_TWILIO_TEST_FROM"); + + let call_sid = client.originate(&to, &from, None).await.expect("originate"); + assert!( + call_sid.starts_with("CA"), + "expected CA-prefixed CallSid, got {call_sid}" + ); + + // Tear down immediately so the test number doesn't actually ring. + client.hangup(&call_sid).await.expect("hangup"); + } + + #[test] + fn stream_url_derives_wss_from_https_webhook_base() { + let creds = TwilioCredentials { + account_sid: "AC_test".into(), + auth_token: "tok".into(), + media_streams_bind: "0.0.0.0:8081".parse().unwrap(), + webhook_base: "https://example.com".parse().unwrap(), + }; + let client = TwilioCallControlClient::new(creds); + assert_eq!(client.stream_url(), "wss://example.com/twilio/media-stream"); + } + + #[test] + fn stream_url_derives_ws_from_http_webhook_base() { + let creds = TwilioCredentials { + account_sid: "AC_test".into(), + auth_token: "tok".into(), + media_streams_bind: "127.0.0.1:8081".parse().unwrap(), + webhook_base: "http://localhost:8080".parse().unwrap(), + }; + let client = TwilioCallControlClient::new(creds); + assert_eq!( + client.stream_url(), + "ws://localhost:8080/twilio/media-stream" + ); + } +} diff --git a/crates/rutster/Cargo.toml b/crates/rutster/Cargo.toml index 0aca0e9..7c84e37 100644 --- a/crates/rutster/Cargo.toml +++ b/crates/rutster/Cargo.toml @@ -11,6 +11,13 @@ description = "Rutster binary: axum signaling + media driver + static browser te rutster-media = { path = "../rutster-media" } rutster-call-model = { path = "../rutster-call-model" } rutster-tap = { path = "../rutster-tap" } +# rutster-trunk: the rented-transport crate owns `TwilioCredentials` (T2) + +# the env parser's return type. The binary's `config::twilio_credentials` +# (T6) constructs it from env vars + hands it to the live +# `TwilioCallControlClient` (when `--features=twilio-live` is enabled on the +# trunk crate). ADR-0009: `TwilioCredentials` is imported here but never +# re-exported through the workspace root / never sent over the tap WS protocol. +rutster-trunk = { path = "../rutster-trunk" } axum = { workspace = true } tokio = { workspace = true } tokio-tungstenite = { workspace = true } diff --git a/crates/rutster/src/config.rs b/crates/rutster/src/config.rs index 96af499..3dc7589 100644 --- a/crates/rutster/src/config.rs +++ b/crates/rutster/src/config.rs @@ -10,6 +10,8 @@ use std::net::{IpAddr, SocketAddr}; use rutster_media::MediaAddressConfig; +use rutster_trunk::provider::TwilioCredentials; +use url::Url; /// Resolve the HTTP bind address from `RUTSTER_HTTP_BIND`. /// @@ -93,6 +95,74 @@ pub fn drain_deadline(raw: Option) -> Result` inputs (matching the rest of this module's +/// convention) so unit tests never mutate process env. `main.rs` is the only caller +/// that touches `std::env` — it passes the `std::env::var(...).ok()` results here. +/// +/// `TwilioCredentials` lives in `rutster-trunk` (ADR-0009 — provider credentials +/// never reach the brain); this parser only constructs the struct, it does not +/// re-export it through the workspace. +/// +/// The match destructures all four `Option`s at once: the `(None,None,None,None)` +/// arm is the all-unset → disabled case; the `(Some,Some,Some,Some)` arm extracts +/// owned `String`s (so no `.unwrap()` — the pattern itself proves non-`None`); the +/// catch-all arm reports which subset was set, for an actionable operator error. +pub fn twilio_credentials( + account_sid: Option, + auth_token: Option, + media_streams_bind: Option, + webhook_base: Option, +) -> Result, String> { + let (account_sid, auth_token, media_streams_bind_raw, webhook_base_raw) = + match (account_sid, auth_token, media_streams_bind, webhook_base) { + (None, None, None, None) => return Ok(None), + (Some(a), Some(t), Some(m), Some(w)) => (a, t, m, w), + (a, t, m, w) => { + let mut got: Vec<&str> = Vec::new(); + if a.is_some() { + got.push("RUTSTER_TWILIO_ACCOUNT_SID"); + } + if t.is_some() { + got.push("RUTSTER_TWILIO_AUTH_TOKEN"); + } + if m.is_some() { + got.push("RUTSTER_TWILIO_MEDIA_BIND"); + } + if w.is_some() { + got.push("RUTSTER_TWILIO_WEBHOOK_BASE"); + } + return Err(format!( + "partial Twilio config: set all four RUTSTER_TWILIO_* vars or none \ + (got only: {})", + got.join(", ") + )); + } + }; + + let media_streams_bind = media_streams_bind_raw + .parse::() + .map_err(|e| format!("RUTSTER_TWILIO_MEDIA_BIND is not a socket address: {e}"))?; + let webhook_base = webhook_base_raw + .parse::() + .map_err(|e| format!("RUTSTER_TWILIO_WEBHOOK_BASE is not a URL: {e}"))?; + + Ok(Some(TwilioCredentials { + account_sid, + auth_token, + media_streams_bind, + webhook_base, + })) +} + #[cfg(test)] mod tests { use super::*; @@ -186,4 +256,64 @@ mod tests { assert_eq!(cfg.advertised_ip.unwrap().to_string(), "203.0.113.7"); assert_eq!(cfg.port_range, Some((10000, 10100))); } + + #[test] + fn twilio_credentials_none_when_all_unset() { + assert!( + twilio_credentials(None, None, None, None) + .unwrap() + .is_none() + ); + } + + #[test] + fn twilio_credentials_parses_all_four() { + let creds = twilio_credentials( + Some("AC_test_sid".into()), + Some("secret-token".into()), + Some("0.0.0.0:8081".into()), + Some("https://example.com".into()), + ) + .unwrap() + .expect("all-four-present should yield Some"); + assert_eq!(creds.account_sid, "AC_test_sid"); + assert_eq!( + creds.media_streams_bind, + "0.0.0.0:8081".parse::().unwrap() + ); + assert_eq!(creds.webhook_base.as_str(), "https://example.com/"); + } + + #[test] + fn twilio_credentials_error_on_partial_config_names_missing_vars() { + // Only MEDIA_BIND set — the error must name what WAS set so the operator + // knows which subset to complete or clear. + let err = twilio_credentials(None, None, Some("0.0.0.0:8081".into()), None).unwrap_err(); + assert!(err.contains("partial"), "msg: {err}"); + assert!(err.contains("RUTSTER_TWILIO_MEDIA_BIND")); + } + + #[test] + fn twilio_credentials_error_on_malformed_media_bind() { + let err = twilio_credentials( + Some("AC_x".into()), + Some("tok".into()), + Some("not-a-socket-addr".into()), + Some("https://example.com".into()), + ) + .unwrap_err(); + assert!(err.contains("RUTSTER_TWILIO_MEDIA_BIND")); + } + + #[test] + fn twilio_credentials_error_on_malformed_webhook_base() { + let err = twilio_credentials( + Some("AC_x".into()), + Some("tok".into()), + Some("0.0.0.0:8081".into()), + Some("not-a-url".into()), + ) + .unwrap_err(); + assert!(err.contains("RUTSTER_TWILIO_WEBHOOK_BASE")); + } } From e6891f2ceca8255a1e201ec0341cb4117f39ec2b Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 5 Jul 2026 03:10:13 -0400 Subject: [PATCH 3/5] docs: QUICKSTART env table + 'make a real phone call' walkthrough + README status update (slice-5 T9) QUICKSTART gains a Twilio Media Streams section: env-var table for the four RUTSTER_TWILIO_* vars, the run-with-twilio-live command, the point-Twilio- at-rutster webhook/TwiML walkthrough, + the outbound-call curl example. The /v1/trunk routes' auth-deferral (slice 6) is flagged. A 'what's different from WebRTC' note explains the architectural reuse -- the reflex stack is ingress-agnostic (Reflex + LocalVadReflex REUSED from slice-4). README's spearhead status is corrected + extended: slices 1-4 are merged to main (the prior status stalled at '1-3 merged, slice-4 active' -- stale); 4.5 (sim/benchmark, ADR-0010) + step 5 (PSTN via rented transport, ADR-0007) are the active build targets. ADR-0007 honored: rutster parses zero SIP bytes. T9 of slice-5. Signed-off-by: Aaron D. Lee --- README.md | 25 ++++++++----- docs/QUICKSTART.md | 93 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3b5fa38..805ffa1 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,14 @@ pip install -r examples/openai_realtime_brain/requirements.txt OPENAI_API_KEY=sk-... python examples/openai_realtime_brain/openai_realtime_brain.py ``` -> **Status:** Slices 1–3 (WebRTC media core, WS tap, OpenAI Realtime brain) are merged to -> `main`. Slice 4 (barge-in / VAD-driven playout kill) is the active build target, in flight -> on the `slice-4-dev-a-reflex` + `slice-4-dev-b-tap` branches. Design: -> [`docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md`](docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md). -> Implementation plan: -> [`docs/superpowers/plans/2026-07-01-slice-4-barge-in.md`](docs/superpowers/plans/2026-07-01-slice-4-barge-in.md). +> **Status:** Slices 1–4 (WebRTC media core, WS tap, OpenAI Realtime brain, +> barge-in / VAD-driven playout kill) are merged to `main`. Slice 4½ +> (sim/benchmark harness, [ADR-0010](docs/adr/0010-spearhead-benchmark-sim-harness.md)) +> + step 5 (PSTN via rented Twilio Media Streams transport, +> [ADR-0007](docs/adr/0007-trunk-rented-transport.md)) are the active build targets, +> in flight on their respective branches. ADR-0007 honored: rutster parses zero +> SIP bytes. See the +> [slice-5 design](docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md). ## Documentation @@ -162,12 +164,15 @@ exactly as integrators did on top of Asterisk. ## Status -Slices 1–3 (WebRTC media core, WS tap, OpenAI Realtime brain) are merged to `main`; -spearhead steps 4–6 remain. The +Slices 1–4 (WebRTC media core, WS tap, OpenAI Realtime brain, barge-in / +VAD-driven playout kill) are merged to `main`; spearhead steps 4½ (sim/benchmark +harness, [ADR-0010](docs/adr/0010-spearhead-benchmark-sim-harness.md)) + 5 (PSTN +via rented transport, [ADR-0007](docs/adr/0007-trunk-rented-transport.md)) + 6 +(spend cap) remain. The [vision revision](docs/superpowers/specs/2026-06-26-vision-revision-design.md) and ADRs define the architecture; the -[slice-4 design](docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md) -documents the active build. +[slice-5 design](docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md) +documents the active build. ADR-0007 honored: rutster parses zero SIP bytes. ## First proof (the spearhead) diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index eb95644..282075d 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -2,11 +2,14 @@ Get Rutster running and hear your own voice echoed back in under 5 minutes. -> **Status:** Slices 1–3 are merged to `main`. Slice 4 (barge-in / VAD-driven playout -> kill) is the active build target, in flight on the `slice-4-dev-a-reflex` + -> `slice-4-dev-b-tap` branches. This quickstart exercises the slice-1 WebRTC media -> loopback, which remains the simplest end-to-end demo on `main`. See -> [`docs/superpowers/specs/2026-07-01-slice-4-barge-in-design.md`](superpowers/specs/2026-07-01-slice-4-barge-in-design.md) +> **Status:** Slices 1–4 (WebRTC media core, WS tap, OpenAI Realtime brain, +> barge-in / VAD-driven playout kill) are merged to `main`. Slice 4½ +> (sim/benchmark harness) + step 5 (PSTN via rented Twilio Media Streams +> transport) are the active build targets, in flight. This quickstart's first +> section exercises the slice-1 WebRTC media loopback (the simplest end-to-end +> demo on `main`); the optional "Make a real phone call" section below covers +> step-5 PSTN ingress. See +> [`docs/superpowers/specs/2026-07-05-slice-5-rented-transport-design.md`](superpowers/specs/2026-07-05-slice-5-rented-transport-design.md) > for the active build target's design. --- @@ -75,6 +78,86 @@ RUST_LOG=rutster=debug cargo run --- +## Make a real phone call (PSTN via Twilio Media Streams, optional) + +The WebRTC loopback above proves the media core + the reflex loop. Spearhead +step 5 takes it to a **real phone number**: a PSTN caller dials your Twilio +number, Twilio forks the call's audio over a WebSocket to rutster, and the +same reflex loop (barge-in, brain tap) runs against the PSTN leg — **no +first-party SIP stack** ([ADR-0007](adr/0007-trunk-rented-transport.md)). + +### Prerequisites + +- A [Twilio account](https://www.twilio.com/) (trial works). +- A Twilio phone number capable of Voice + Media Streams. +- A publicly-reachable HTTPS URL for rutster (Twilio must call back to you). + For local dev, use [ngrok](https://ngrok.com/) to tunnel `localhost:8080` + to a public URL: `ngrok http 8080`. + +### Twilio credentials + +Set these environment variables to enable PSTN ingress: + +| Env var | Purpose | Example | +|---|---|---| +| `RUTSTER_TWILIO_ACCOUNT_SID` | Twilio account ID | `ACxxx...` | +| `RUTSTER_TWILIO_AUTH_TOKEN` | Twilio auth token (secret; never logged) | `xxx...` | +| `RUTSTER_TWILIO_MEDIA_BIND` | Where rutster's Media Streams WSS server binds | `0.0.0.0:8081` | +| `RUTSTER_TWILIO_WEBHOOK_BASE` | Your public URL Twilio calls back to | `https://your.ngrok.io` | + +Without these set, the binary runs WebRTC-only (slices 1–4 ingress + barge-in). +With them set, the binary accepts PSTN fork calls against `/twilio/media-stream`. + +### Run with Twilio enabled + +The live Twilio REST client is feature-gated behind `twilio-live` — the routine +CI gate uses the in-process mock; you only need the live client for a real call: + +```bash +export RUTSTER_TWILIO_ACCOUNT_SID=AC... +export RUTSTER_TWILIO_AUTH_TOKEN=... +export RUTSTER_TWILIO_MEDIA_BIND=0.0.0.0:8081 +export RUTSTER_TWILIO_WEBHOOK_BASE=https://your.ngrok.io +cargo run --features=twilio-live +``` + +### Point Twilio at rutster + +1. In the Twilio console, configure your phone number's **A CALL COMES IN** + webhook to `POST` to `https://your.ngrok.io/v1/trunk/webhook`. +2. Twilio answers an inbound call, hits your webhook; rutster responds with + TwiML instructing Twilio to `` against + `wss://your.ngrok.io/twilio/media-stream`. +3. Twilio opens the WSS; rutster's `TwilioMediaStreamsServer` receives the + call's audio (µ-law @ 8 kHz), decodes via `G711Codec` to 24 kHz PCM, and + feeds the same reflex loop + brain tap as a WebRTC leg. + +### Make an outbound call + +```bash +curl -X POST http://localhost:8080/v1/trunk/sessions \ + -H 'Content-Type: application/json' \ + -d '{"to":"+15551234567","from":"+15550000000"}' +``` + +The handler calls `TwilioCallControlClient::originate`; Twilio places the call +and forks the audio back — the PSTN caller reaches the reflex loop. + +> **Auth note:** the `/v1/trunk/*` routes are **not authenticated** in slice 5 +> (authn defers to slice 6, the spend cap). Do not expose them to the public +> internet without a reverse-proxy auth layer. + +### What's different from WebRTC + +Nothing, architecturally. The PSTN leg enters the same `MediaThread` tick loop +via a `MediaLeg::Trunk(TrunkSession)` variant (the WebRTC leg is `MediaLeg::WebRTC`). +The reflex stack (`Reflex` + `LocalVadReflex`) is **reused +verbatim** from slice 4 — barge-in fires on PSTN caller speech the same way it +fires on WebRTC caller speech. The only difference is the ingress: str0m's RTP +decode (WebRTC) vs the Media Streams WSS pump + G.711 codec (trunk). + +--- + ## Troubleshooting | Symptom | Likely cause / fix | From 77175aaba18b7a1a86d132e5edf5696b882aa710 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 5 Jul 2026 03:16:00 -0400 Subject: [PATCH 4/5] ci(trunk): twilio-live manual-trigger job + seam-gate verification + doc-link fixes (slice-5 T10) The routine CI gate stays feature-default-off: MockCallControlClient is the per-PR test surface. A new twilio-live job runs ONLY on manual workflow_dispatch (maintainer triggers pre-release) -- it exercises clippy --features=twilio-live + the live TwilioCallControlClient tests against real Twilio credentials (TWILIO_* secrets). Never runs per-PR. Seam gate verified UNCHANGED: loop_driver.rs (744bf314...) + rtc_session.rs (f47d63b9...) blob hashes match the slice-4 Task 10 pins exactly -- the trunk leg's tick lives entirely in rutster-trunk/src/loop_driver.rs (a separate file in a separate crate), so the media-crate seam files stay byte-identical. cargo deny: reqwest (rustls-tls) + base64 + async-trait introduce ZERO new duplicate dep versions (verified via `cargo tree -d` with vs without --features=twilio-live: identical duplicate sets -- the existing skip list in deny.toml remains sufficient). Local cargo-deny 0.18.3 cannot parse the `-or-later` SPDX form + CVSS 4.0 advisory entries (pre-existing limitation documented in deny.toml; CI's cargo-deny-action@v2 bundles 0.19.x which handles both) -- CI is the authoritative deny gate. Two rustdoc intra-doc-link warnings in my code fixed (mock.rs private-item link -> plain inline code; lib.rs redundant explicit link target simplified). Two pre-existing rustdoc warnings remain in rutster-tap/protocol.rs + rutster/tap_engine.rs (out of scope -- pre-existing from slices 2-3, not introduced by slice-5). T10 of slice-5. This is the final task on the dev-b chain (T2 + T6 + T9 + T10 all landed). Signed-off-by: Aaron D. Lee --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++ crates/rutster-trunk/src/lib.rs | 2 +- crates/rutster-trunk/src/provider/mock.rs | 2 +- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b79bb7..866027f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,12 @@ on: branches: [main] pull_request: branches: [main] + # Manual trigger for the twilio-live job (pre-release validation against + # real Twilio credentials). Never runs per-PR — the routine clippy/test + # jobs use default features (MockCallControlClient); the live + # TwilioCallControlClient is feature-gated behind `twilio-live` + only the + # maintainer exercises it before tagging a release (spec §1.2 + plan T6/T10). + workflow_dispatch: env: CARGO_TERM_COLOR: always @@ -80,3 +86,34 @@ jobs: - uses: EmbarkStudios/cargo-deny-action@v2 with: command: check + + # The live TwilioCallControlClient is feature-gated behind `twilio-live` + # (reqwest + rustls-tls + tracing + serde_json pulled in only when the + # feature is on). This job exercises it against REAL Twilio credentials. + # It runs ONLY on manual `workflow_dispatch` (never per-PR) — the routine + # clippy/test jobs above use default features (MockCallControlClient). + # The maintainer triggers this before tagging a release, after setting the + # TWILIO_* secrets in the repo settings (spec §1.2 + plan T10 Step 3). + twilio-live: + name: twilio-live (manual only) + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - name: Install libopus (media crate FFI dep) + run: apt-get update && apt-get install -y libopus-dev + - uses: Swatinem/rust-cache@v2 + - name: Clippy with twilio-live feature + run: cargo clippy --all --all-targets --features=twilio-live -- -D warnings + - name: Twilio-live tests (includes ignored live e2e; needs TWILIO_* secrets) + env: + RUTSTER_TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} + RUTSTER_TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} + RUTSTER_TWILIO_MEDIA_BIND: 0.0.0.0:8081 + RUTSTER_TWILIO_WEBHOOK_BASE: ${{ secrets.TWILIO_WEBHOOK_BASE }} + RUTSTER_TWILIO_TEST_TO: ${{ secrets.TWILIO_TEST_TO }} + RUTSTER_TWILIO_TEST_FROM: ${{ secrets.TWILIO_TEST_FROM }} + run: cargo test --all --features=twilio-live -- --include-ignored diff --git a/crates/rutster-trunk/src/lib.rs b/crates/rutster-trunk/src/lib.rs index 29673dd..16e7ed6 100644 --- a/crates/rutster-trunk/src/lib.rs +++ b/crates/rutster-trunk/src/lib.rs @@ -10,7 +10,7 @@ //! //! ## Spearhead step 5 — what's here vs what's deferred //! -//! The provider call-control seam ([`provider`](crate::provider)) lands in T2: the +//! The provider call-control seam ([`provider`]) lands in T2: the //! `CallControlClient` trait, `MockCallControlClient`, and `TwilioCredentials` (with //! redacted `Debug`). The live `TwilioCallControlClient` (T6) is feature-gated behind //! `twilio-live`. The WSS ingress (`g711`, `twilio_media_streams`, `session`, diff --git a/crates/rutster-trunk/src/provider/mock.rs b/crates/rutster-trunk/src/provider/mock.rs index 7d98c26..53c72f1 100644 --- a/crates/rutster-trunk/src/provider/mock.rs +++ b/crates/rutster-trunk/src/provider/mock.rs @@ -21,7 +21,7 @@ //! *test double*, a poisoned mutex means a previous test assertion failed; the //! right move is to recover the inner value (`.into_inner()`) rather than //! propagate the poison (which would cascade one test failure into every -//! subsequent test). [`lock_or_recover`] does this idiomatically — and avoids +//! subsequent test). `lock_or_recover` does this idiomatically — and avoids //! the `unwrap()`/`expect()` that AGENTS.md's hot-path rule forbids in library //! code (the mock is library code, not `#[cfg(test)]`, because T7/T8 integration //! tests in `tests/` import it). From 3306214b38e088bf7f2851dc86a0ae8309aef5bd Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 5 Jul 2026 03:19:12 -0400 Subject: [PATCH 5/5] test(trunk): ADR-0009 static assertion -- TwilioCredentials is trunk-local (slice-5 T10 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §7 done-criteria #10 demands a static assertion that TwilioCredentials lives ONLY in rutster-trunk (ADR-0009 -- provider credentials never reach the brain). The test compiles only because crate::provider::TwilioCredentials resolves (the type's canonical home); if someone moved/re-exported it through the workspace root or a sibling crate, this test's doc + the dep-graph change would surface in review. The invariant is structural: sibling crates (rutster-media, rutster-tap) do not depend on rutster-trunk, so the type cannot reach them. The binary's config::twilio_credentials is the single expected import path. Signed-off-by: Aaron D. Lee --- crates/rutster-trunk/src/provider/mod.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/rutster-trunk/src/provider/mod.rs b/crates/rutster-trunk/src/provider/mod.rs index 4c176a1..4e41f28 100644 --- a/crates/rutster-trunk/src/provider/mod.rs +++ b/crates/rutster-trunk/src/provider/mod.rs @@ -218,4 +218,23 @@ mod tests { let none: Option = None; assert!(none.is_none()); } + + /// ADR-0009 static assertion (spec §7 done-criteria #10): `TwilioCredentials` + /// lives ONLY in the `rutster-trunk` crate. This test pins the type's + /// locality — it compiles only because `crate::provider::TwilioCredentials` + /// resolves (the type's canonical home). Sibling crates (`rutster-media`, + /// `rutster-tap`) do NOT depend on `rutster-trunk`, so they cannot re-export + /// the type — the invariant is structural (the Cargo dep graph). If someone + /// later adds a `pub use` re-export through the workspace root or a sibling, + /// this test's doc + the dep-graph change would both need revisiting + /// (a reviewable Cargo.toml edit). The binary's `config::twilio_credentials` + /// is the single expected import path (`rutster_trunk::provider::TwilioCredentials`). + #[test] + fn twilio_credentials_is_trunk_local_adr0009() { + // Compile-time path resolution: if TwilioCredentials moved out of this + // crate, this line fails to compile (the test breaks the build). + fn _accepts_trunk_local_credentials(_: crate::provider::TwilioCredentials) {} + // Touch the fn so it's not dead-code-eliminated before the type check. + let _ = _accepts_trunk_local_credentials; + } }