Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .cargo/audit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,6 @@ ignore = [
# upstream issue. See `vex/instant-rustsec-2024-0384.json`.
"RUSTSEC-2024-0384",

# lru 0.13 IterMut Stacked Borrows soundness — pulled transitively via
# chitchat (workspace SWIM/scuttlebutt). Springtale doesn't call
# lru::IterMut directly; chitchat is on the dependabot ignore list (wire
# format critical). See `vex/lru-rustsec-2026-0002.json`.
"RUSTSEC-2026-0002",

# proc-macro-error2 2.0.1 unmaintained — author confirmed end-of-life on
# 2026-06-07. Build-time only, pulled transitively via tabled_derive ->
# tabled (springtale-cli table rendering). No runtime code, no CVE. Tracking
Expand Down
37 changes: 20 additions & 17 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ crossbeam-deque = "0.8"

# ── Cooperation (Phase M) ─────────────────────────────────────────────────────
dyn-clone = "1" # DynClone supertrait for Box<dyn DynamicRole>
chitchat = "0.10" # §8 scuttlebutt gossip for awareness
chitchat = "0.13" # §8 scuttlebutt gossip for awareness
foca = { version = "1.0", default-features = false, features = ["std", "tracing", "bincode-codec"] } # §8 SWIM liveness
bincode = "2" # foca 1.0's BincodeCodec wire format (matches foca's internal dep)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
//! Catalogue snapshot — the recipe library projected into the shape
//! the NLU engine scores and slot-fills against.
//!
//! Built fresh each turn from `list_recipes` (≈60 built-ins; cheap).
//! Built fresh each turn from `list_recipes` — the whole builtin
//! catalogue plus the user's own recipes; cheap. No count is written
//! down here: the catalogue is whatever `list_recipes` returns, and
//! every hardcoded figure this comment has carried has gone stale.
//! Every recipe becomes an [`IntentDoc`]: stemmed name/tag/description
//! token bags for intent scoring, plus a [`SlotSpec`] per input field
//! carrying a precomputed [`Gazetteer`] for `Select` fields so the
Expand Down
3 changes: 3 additions & 0 deletions crates/springtale-connector/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ pub enum ConnectorError {
#[error("WASM memory limit exceeded")]
MemoryLimitExceeded,

#[error("WASM execution exceeded wall-clock timeout ({limit_secs}s)")]
Timeout { limit_secs: u64 },

#[error("WASM binary hash mismatch")]
WasmHashMismatch,

Expand Down
131 changes: 100 additions & 31 deletions crates/springtale-connector/src/wasm/connector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,29 @@ pub struct WasmConnectorHost {
sandbox_limits: SandboxLimits,
}

/// Classify a Wasmtime call failure into a typed sandbox error.
///
/// Fuel exhaustion and epoch (wall-clock) interruption are separate
/// limits with separate operator responses, so each gets its own
/// `ConnectorError` variant rather than a formatted `Sandbox` string
/// that callers would have to substring-match.
fn trap_to_error(
err: &wasmtime::Error,
limits: &SandboxLimits,
fuel_remaining: u64,
) -> ConnectorError {
match err.downcast_ref::<wasmtime::Trap>() {
Some(wasmtime::Trap::OutOfFuel) => ConnectorError::FuelExhausted {
used: limits.fuel.saturating_sub(fuel_remaining),
limit: limits.fuel,
},
Some(wasmtime::Trap::Interrupt) => ConnectorError::Timeout {
limit_secs: limits.timeout_secs,
},
_ => ConnectorError::Sandbox(format!("execution failed: {err}")),
}
}

impl WasmConnectorHost {
/// Create a new WASM connector host from compiled module + manifest,
/// registering the module against a shared tier cache.
Expand Down Expand Up @@ -231,37 +254,28 @@ impl ConnectorHost for WasmConnectorHost {
.copy_from_slice(input_bytes);

// Call guest execute function
let result_ptr = execute_fn
.call(
&mut store,
(
i32::try_from(action_offset)
.map_err(|_| ConnectorError::Sandbox("action offset exceeds i32".into()))?,
i32::try_from(action_bytes.len())
.map_err(|_| ConnectorError::Sandbox("action length exceeds i32".into()))?,
i32::try_from(input_offset)
.map_err(|_| ConnectorError::Sandbox("input offset exceeds i32".into()))?,
i32::try_from(input_bytes.len())
.map_err(|_| ConnectorError::Sandbox("input length exceeds i32".into()))?,
),
)
.map_err(|e| {
// Check if this was a fuel exhaustion or epoch timeout
let msg = e.to_string();
if msg.contains("fuel") {
ConnectorError::Sandbox(format!(
"connector exceeded instruction limit ({} fuel)",
self.sandbox_limits.fuel
))
} else if msg.contains("epoch") {
ConnectorError::Sandbox(format!(
"connector exceeded timeout ({}s)",
self.sandbox_limits.timeout_secs
))
} else {
ConnectorError::Sandbox(format!("execution failed: {e}"))
}
})?;
let call_result = execute_fn.call(
&mut store,
(
i32::try_from(action_offset)
.map_err(|_| ConnectorError::Sandbox("action offset exceeds i32".into()))?,
i32::try_from(action_bytes.len())
.map_err(|_| ConnectorError::Sandbox("action length exceeds i32".into()))?,
i32::try_from(input_offset)
.map_err(|_| ConnectorError::Sandbox("input offset exceeds i32".into()))?,
i32::try_from(input_bytes.len())
.map_err(|_| ConnectorError::Sandbox("input length exceeds i32".into()))?,
),
);
let result_ptr = match call_result {
Ok(ptr) => ptr,
Err(e) => {
// `get_fuel` is read before the store is dropped so the
// fuel error can report what the guest actually burned.
let remaining = store.get_fuel().unwrap_or(0);
return Err(trap_to_error(&e, &self.sandbox_limits, remaining));
}
};

// Read result from guest memory
// Convention: result_ptr points to a JSON string in guest memory
Expand Down Expand Up @@ -362,6 +376,61 @@ impl ConnectorHost for WasmConnectorHost {
}
}

/// Test-only execution helpers.
///
/// The production path (`execute_checked`) requires a guest that follows
/// the four-argument `execute` ABI. The sandbox-limit tests need to run
/// a bare export under the same store, limiter, fuel budget and epoch
/// deadline, so these two helpers reuse `create_store` and the tier
/// cache and stop short of the JSON marshalling.
#[cfg(test)]
impl WasmConnectorHost {
/// Fresh store + instance at the checker's default tier.
fn instantiate_for_test(
&self,
) -> Result<(Store<HostState>, wasmtime::Instance), ConnectorError> {
let checker = CapabilityChecker::new();
let mut store = self.create_store(&checker);
let instance =
self.tier_cache
.instantiate_at_tier(self.module_key(), checker.tier(), &mut store)?;
Ok((store, instance))
}

/// Call a zero-argument, zero-result export under the sandbox limits.
/// Returns the typed error when a limit fires.
pub(crate) fn execute_raw(&self, export: &str) -> Result<(), ConnectorError> {
let (mut store, instance) = self.instantiate_for_test()?;
let func = instance
.get_typed_func::<(), ()>(&mut store, export)
.map_err(|e| ConnectorError::Sandbox(format!("missing '{export}' export: {e}")))?;
match func.call(&mut store, ()) {
Ok(()) => Ok(()),
Err(e) => {
let remaining = store.get_fuel().unwrap_or(0);
Err(trap_to_error(&e, &self.sandbox_limits, remaining))
}
}
}

/// Run a zero-argument export, then report the guest's linear memory
/// size in bytes — what the `StoreLimits` memory cap governs.
pub(crate) fn memory_size_after(&self, export: &str) -> Result<usize, ConnectorError> {
let (mut store, instance) = self.instantiate_for_test()?;
let func = instance
.get_typed_func::<(), ()>(&mut store, export)
.map_err(|e| ConnectorError::Sandbox(format!("missing '{export}' export: {e}")))?;
if let Err(e) = func.call(&mut store, ()) {
let remaining = store.get_fuel().unwrap_or(0);
return Err(trap_to_error(&e, &self.sandbox_limits, remaining));
}
let memory = instance
.get_memory(&mut store, "memory")
.ok_or_else(|| ConnectorError::Sandbox("guest has no 'memory' export".into()))?;
Ok(memory.data_size(&store))
}
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
Expand Down
3 changes: 3 additions & 0 deletions crates/springtale-connector/src/wasm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ pub mod runtime;
pub mod tier;
pub mod wasi;

#[cfg(test)]
mod tests;

pub use connector::WasmConnectorHost;
pub use limits::SandboxLimits;
pub use runtime::WasmEngine;
Expand Down
Loading
Loading