use crate::engine::wasm::bindings::astrid::sys::host::{
self as sys, CallerContext, CapabilityCheckRequest, CapabilityCheckResponse, ErrorCode,
LogLevel,
};
use crate::engine::wasm::host::util;
use crate::engine::wasm::host_state::HostState;
const RANDOM_BYTES_CAP: u64 = 4096;
const SLEEP_NS_CAP: u64 = 60_000_000_000;
impl sys::Host for HostState {
fn get_config(&mut self, key: String) -> Result<Option<String>, ErrorCode> {
if self.secret_env.contains(&key) {
let value = resolve_secret(self, &key);
return Ok(if value.is_empty() { None } else { Some(value) });
}
if let Some(overlay) = self.invocation_env_overlay.as_ref()
&& let Some(value) = overlay.get(&key)
{
return Ok(Some(value.clone()));
}
match self.config.get(&key) {
None => Ok(None),
Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
Some(v) => Ok(Some(serde_json::to_string(v).unwrap_or_default())),
}
}
fn get_caller(&mut self) -> Result<CallerContext, ErrorCode> {
if let Some(ref msg) = self.caller_context {
Ok(CallerContext {
principal: msg.principal.clone(),
source_id: msg.source_id.to_string(),
timestamp: msg.timestamp.to_rfc3339(),
})
} else {
Ok(CallerContext {
principal: None,
source_id: String::new(),
timestamp: String::new(),
})
}
}
fn log(&mut self, level: LogLevel, message: String) {
let capsule_id = self.capsule_id.as_str().to_owned();
let log_file = self.effective_capsule_log().cloned();
let level_str = match level {
LogLevel::Trace => "TRACE",
LogLevel::Debug => "DEBUG",
LogLevel::Info => "INFO",
LogLevel::Warn => "WARN",
LogLevel::Error => "ERROR",
};
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or_else(|_| "0".to_string(), |d| format!("{:.3}", d.as_secs_f64()));
let wrote_to_file = if let Some(log_file) = log_file {
use std::io::Write;
match log_file.lock() {
Ok(mut f) => {
match writeln!(f, "{timestamp} {level_str} [{capsule_id}] {message}") {
Ok(()) => true,
Err(e) => {
tracing::warn!(
capsule = %capsule_id,
error = %e,
"capsule log write failed; falling back to tracing subscriber"
);
false
},
}
},
Err(e) => {
tracing::warn!(
capsule = %capsule_id,
error = %e,
"capsule log mutex poisoned; falling back to tracing subscriber"
);
false
},
}
} else {
false
};
if should_emit_to_daemon_log(wrote_to_file, level) {
match level {
LogLevel::Trace => tracing::trace!(plugin = %capsule_id, "{message}"),
LogLevel::Debug => tracing::debug!(plugin = %capsule_id, "{message}"),
LogLevel::Info => tracing::info!(plugin = %capsule_id, "{message}"),
LogLevel::Warn => tracing::warn!(plugin = %capsule_id, "{message}"),
LogLevel::Error => tracing::error!(plugin = %capsule_id, "{message}"),
}
}
}
fn signal_ready(&mut self) {
if let Some(tx) = &self.ready_tx {
let _ = tx.send(true);
tracing::debug!(capsule = %self.capsule_id, "Capsule signaled ready");
}
}
fn clock_ms(&mut self) -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0u64, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
fn clock_monotonic_ns(&mut self) -> u64 {
use std::sync::OnceLock;
use std::time::Instant;
static ANCHOR: OnceLock<Instant> = OnceLock::new();
let anchor = *ANCHOR.get_or_init(Instant::now);
u64::try_from(anchor.elapsed().as_nanos()).unwrap_or(u64::MAX)
}
fn sleep_ns(&mut self, duration_ns: u64) -> Result<(), ErrorCode> {
if duration_ns > SLEEP_NS_CAP {
return Err(ErrorCode::TooLarge);
}
let cancel = self.cancel_token.clone();
let rt = self.runtime_handle.clone();
let sem = self.blocking_semaphore.clone();
let duration = std::time::Duration::from_nanos(duration_ns);
let cancelled = util::bounded_block_on(&rt, &sem, async move {
tokio::select! {
() = tokio::time::sleep(duration) => false,
() = cancel.cancelled() => true,
}
});
if cancelled {
Err(ErrorCode::Cancelled)
} else {
Ok(())
}
}
fn random_bytes(&mut self, length: u64) -> Result<Vec<u8>, ErrorCode> {
if length > RANDOM_BYTES_CAP {
return Err(ErrorCode::TooLarge);
}
let len = usize::try_from(length).map_err(|_| ErrorCode::TooLarge)?;
use rand::RngCore;
let mut buf = vec![0u8; len];
rand::rngs::OsRng
.try_fill_bytes(&mut buf)
.map_err(|e| ErrorCode::Unknown(format!("entropy source unavailable: {e}")))?;
Ok(buf)
}
fn check_capsule_capability(
&mut self,
request: CapabilityCheckRequest,
) -> Result<CapabilityCheckResponse, ErrorCode> {
let registry = self.capsule_registry.clone();
let rt_handle = self.runtime_handle.clone();
let blocking_semaphore = self.blocking_semaphore.clone();
let registry = registry.ok_or(ErrorCode::RegistryUnavailable)?;
let Ok(source_uuid) = uuid::Uuid::parse_str(&request.source_uuid) else {
return Ok(CapabilityCheckResponse { allowed: false });
};
let allowed = util::bounded_block_on(&rt_handle, &blocking_semaphore, async {
let reg = registry.read().await;
let Some(capsule_id) = reg.find_by_uuid(&source_uuid) else {
return false;
};
let Some(capsule) = reg.get(capsule_id) else {
return false;
};
capsule.manifest().capabilities.has(&request.capability)
});
Ok(CapabilityCheckResponse { allowed })
}
fn enumerate_capabilities(&mut self) -> Vec<String> {
self.capability_names.clone()
}
}
fn resolve_secret(state: &HostState, key: &str) -> String {
use astrid_storage::{FileSecretStore, SecretStore};
let capsule = state.capsule_id.as_str();
let principal = state.effective_principal();
let Ok(home) = astrid_core::dirs::AstridHome::resolve() else {
tracing::warn!(
security_event = true,
%principal,
capsule,
key,
"AstridHome::resolve failed during secret lookup"
);
return String::new();
};
let secrets_dir = home.secrets_dir();
let try_get = |scope: &str| -> Option<String> {
let store = FileSecretStore::new(secrets_dir.join(scope).join(capsule));
match store.get(key) {
Ok(value) => value,
Err(e) => {
tracing::warn!(
security_event = true,
%principal,
capsule,
key,
scope,
error = %e,
"file secret-store read failed for secret-typed env key"
);
None
},
}
};
if let Some(v) = try_get(principal.as_str()) {
return v;
}
if let Some(v) = try_get("__host__") {
return v;
}
String::new()
}
fn should_emit_to_daemon_log(wrote_to_file: bool, level: LogLevel) -> bool {
!wrote_to_file || matches!(level, LogLevel::Error)
}
#[cfg(test)]
mod log_chain_tests {
use std::sync::Arc;
use super::*;
use crate::engine::wasm::bindings::astrid::sys::host::Host as SysHost;
use crate::engine::wasm::test_fixtures::{minimal_host_state, open_log};
fn make_host_state() -> crate::engine::wasm::host_state::HostState {
minimal_host_state(tokio::runtime::Handle::current())
}
#[tokio::test]
async fn log_routes_to_invocation_file_when_installed() {
let tmp = tempfile::tempdir().unwrap();
let owner_log_path = tmp.path().join("owner.log");
let alice_log_path = tmp.path().join("alice.log");
let owner_log = open_log(&owner_log_path);
let alice_log = open_log(&alice_log_path);
let mut state = make_host_state();
state.capsule_log = Some(owner_log);
state.invocation_capsule_log = Some(alice_log);
state.log(LogLevel::Info, "hello from alice".into());
let alice_contents = std::fs::read_to_string(&alice_log_path).unwrap();
let owner_contents = std::fs::read_to_string(&owner_log_path).unwrap();
assert!(alice_contents.contains("hello from alice"));
assert!(!owner_contents.contains("hello from alice"));
}
#[tokio::test]
async fn log_falls_back_to_load_time_file_when_no_invocation() {
let tmp = tempfile::tempdir().unwrap();
let owner_log_path = tmp.path().join("owner.log");
let owner_log = open_log(&owner_log_path);
let mut state = make_host_state();
state.capsule_log = Some(owner_log);
state.log(LogLevel::Warn, "single-tenant line".into());
let contents = std::fs::read_to_string(&owner_log_path).unwrap();
assert!(contents.contains("single-tenant line"));
assert!(contents.contains("WARN"));
}
#[tokio::test]
async fn log_isolates_writes_across_sequential_invocations() {
let tmp = tempfile::tempdir().unwrap();
let alice_path = tmp.path().join("alice.log");
let bob_path = tmp.path().join("bob.log");
let mut state = make_host_state();
state.invocation_capsule_log = Some(open_log(&alice_path));
state.log(LogLevel::Info, "alice-msg".into());
state.invocation_capsule_log = None;
state.invocation_capsule_log = Some(open_log(&bob_path));
state.log(LogLevel::Info, "bob-msg".into());
state.invocation_capsule_log = None;
let alice = std::fs::read_to_string(&alice_path).unwrap();
let bob = std::fs::read_to_string(&bob_path).unwrap();
assert!(alice.contains("alice-msg") && !alice.contains("bob-msg"));
assert!(bob.contains("bob-msg") && !bob.contains("alice-msg"));
}
#[tokio::test]
async fn log_survives_poisoned_mutex_without_dropping_message() {
let tmp = tempfile::tempdir().unwrap();
let log_path = tmp.path().join("poisoned.log");
let log_file = open_log(&log_path);
let poisoner = Arc::clone(&log_file);
let _ = std::thread::spawn(move || {
let _guard = poisoner.lock().unwrap();
panic!("intentional panic to poison mutex");
})
.join();
assert!(log_file.is_poisoned(), "precondition: mutex is poisoned");
let mut state = make_host_state();
state.capsule_log = Some(log_file);
state.log(LogLevel::Error, "post-poison line".into());
}
#[test]
fn error_logs_always_surface_to_daemon_log() {
assert!(should_emit_to_daemon_log(true, LogLevel::Error));
assert!(should_emit_to_daemon_log(false, LogLevel::Error));
assert!(!should_emit_to_daemon_log(true, LogLevel::Warn));
assert!(!should_emit_to_daemon_log(true, LogLevel::Info));
assert!(should_emit_to_daemon_log(false, LogLevel::Warn));
assert!(should_emit_to_daemon_log(false, LogLevel::Info));
}
}
#[cfg(test)]
mod capability_introspection_tests {
use crate::engine::wasm::bindings::astrid::sys::host::Host as SysHost;
use crate::engine::wasm::test_fixtures::minimal_host_state;
#[tokio::test]
async fn enumerate_returns_load_time_snapshot() {
let mut state = minimal_host_state(tokio::runtime::Handle::current());
assert!(
state.enumerate_capabilities().is_empty(),
"fail-closed default holds nothing"
);
state.capability_names = vec!["host_process".to_string(), "net_connect".to_string()];
assert_eq!(
state.enumerate_capabilities(),
vec!["host_process".to_string(), "net_connect".to_string()],
);
}
}
#[cfg(test)]
mod get_config_tests {
use std::collections::HashMap;
use crate::engine::wasm::bindings::astrid::sys::host::Host as SysHost;
use crate::engine::wasm::test_fixtures::minimal_host_state;
fn make_host_state() -> crate::engine::wasm::host_state::HostState {
minimal_host_state(tokio::runtime::Handle::current())
}
#[tokio::test]
async fn overlay_value_wins_over_manifest_default() {
let mut state = make_host_state();
state.config.insert(
"base_url".into(),
serde_json::Value::String("https://api.openai.com".into()),
);
let mut overlay = HashMap::new();
overlay.insert("base_url".into(), "http://localhost:1234".into());
state.invocation_env_overlay = Some(overlay);
let value = state.get_config("base_url".into()).expect("host call");
assert_eq!(value.as_deref(), Some("http://localhost:1234"));
}
#[tokio::test]
async fn manifest_default_used_when_overlay_missing_key() {
let mut state = make_host_state();
state.config.insert(
"base_url".into(),
serde_json::Value::String("https://api.openai.com".into()),
);
let mut overlay = HashMap::new();
overlay.insert("model".into(), "qwen3.5".into());
state.invocation_env_overlay = Some(overlay);
let value = state.get_config("base_url".into()).expect("host call");
assert_eq!(value.as_deref(), Some("https://api.openai.com"));
}
#[tokio::test]
async fn no_overlay_falls_back_to_manifest_default() {
let mut state = make_host_state();
state
.config
.insert("model".into(), serde_json::Value::String("gpt-5.4".into()));
assert!(state.invocation_env_overlay.is_none());
let value = state.get_config("model".into()).expect("host call");
assert_eq!(value.as_deref(), Some("gpt-5.4"));
}
}