use std::time::Duration;
use serde::Deserialize;
use serde_json::{Value, json};
use tracing::{debug, info, warn};
use crate::client::VtaClient;
use crate::error::VtaError;
const INBOUND_POLL_SECS: u64 = 20;
const DEFAULT_HEARTBEAT_SECS: u64 = 300;
#[derive(Clone)]
pub struct AgentConfig {
pub client_did: String,
pub private_key_multibase: String,
pub vta_did: String,
pub mediator_did: String,
pub rest_url: Option<String>,
pub display_name: String,
pub service_kind: String,
pub platform: Option<String>,
pub hpke_public_key: Option<String>,
pub heartbeat_secs: u64,
}
impl std::fmt::Debug for AgentConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentConfig")
.field("client_did", &self.client_did)
.field("private_key_multibase", &"<redacted>")
.field("vta_did", &self.vta_did)
.field("mediator_did", &self.mediator_did)
.field("rest_url", &self.rest_url)
.field("display_name", &self.display_name)
.field("service_kind", &self.service_kind)
.field("platform", &self.platform)
.field("hpke_public_key", &self.hpke_public_key)
.field("heartbeat_secs", &self.heartbeat_secs)
.finish()
}
}
impl AgentConfig {
pub fn new(
client_did: impl Into<String>,
private_key_multibase: impl Into<String>,
vta_did: impl Into<String>,
mediator_did: impl Into<String>,
) -> Self {
Self {
client_did: client_did.into(),
private_key_multibase: private_key_multibase.into(),
vta_did: vta_did.into(),
mediator_did: mediator_did.into(),
rest_url: None,
display_name: "agent".to_string(),
service_kind: "ai-agent".to_string(),
platform: None,
hpke_public_key: None,
heartbeat_secs: DEFAULT_HEARTBEAT_SECS,
}
}
pub fn display_name(mut self, v: impl Into<String>) -> Self {
self.display_name = v.into();
self
}
pub fn service_kind(mut self, v: impl Into<String>) -> Self {
self.service_kind = v.into();
self
}
pub fn platform(mut self, v: impl Into<String>) -> Self {
self.platform = Some(v.into());
self
}
pub fn rest_url(mut self, v: impl Into<String>) -> Self {
self.rest_url = Some(v.into());
self
}
pub fn hpke_public_key(mut self, v: impl Into<String>) -> Self {
self.hpke_public_key = Some(v.into());
self
}
pub fn heartbeat_secs(mut self, secs: u64) -> Self {
self.heartbeat_secs = secs.max(1);
self
}
pub fn for_attach(display_name: impl Into<String>) -> Self {
Self::new("", "", "", "").display_name(display_name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentControl {
Continue,
Stop,
}
#[derive(Debug, Clone)]
pub struct InboundMessage {
pub id: String,
pub typ: String,
pub claimed_from: Option<String>,
pub verified_signer: Option<String>,
pub body: Value,
}
impl InboundMessage {
pub fn parse(json: &str) -> Result<Self, VtaError> {
#[derive(Deserialize)]
struct Wire {
#[serde(default)]
id: String,
#[serde(rename = "type", default)]
typ: String,
#[serde(default)]
from: Option<String>,
#[serde(default)]
body: Value,
}
let w: Wire = serde_json::from_str(json)?;
Ok(Self {
id: w.id,
typ: w.typ,
claimed_from: w.from,
verified_signer: None,
body: w.body,
})
}
pub async fn verify(mut self, resolver: &crate::trust_task_proof::TrustTaskVmResolver) -> Self {
self.verified_signer = verified_issuer(&self.body, resolver).await;
self
}
}
async fn verified_issuer(
body: &Value,
resolver: &crate::trust_task_proof::TrustTaskVmResolver,
) -> Option<String> {
let doc: trust_tasks_rs::TrustTask<Value> = serde_json::from_value(body.clone()).ok()?;
doc.proof.as_ref()?;
let signer = crate::trust_task_proof::verify_trust_task_proof_with(&doc, resolver)
.await
.ok()?;
let signer = signer.split('#').next().unwrap_or(&signer).to_string();
(doc.issuer.as_deref() == Some(signer.as_str())).then_some(signer)
}
pub struct AgentSession {
client: VtaClient,
config: AgentConfig,
}
impl AgentSession {
pub async fn enroll(config: AgentConfig) -> Result<Self, VtaError> {
let client = VtaClient::connect_didcomm(
&config.client_did,
&config.private_key_multibase,
&config.vta_did,
&config.mediator_did,
config.rest_url.clone(),
)
.await?;
let session = Self::from_client(client, config);
if let Err(e) = session.ensure_enrolled().await {
session.client.shutdown().await;
return Err(e);
}
Ok(session)
}
pub fn from_client(client: VtaClient, config: AgentConfig) -> Self {
Self { client, config }
}
pub async fn ensure_enrolled(&self) -> Result<(), VtaError> {
let consumer_kind = json!({ "kind": "service", "serviceKind": self.config.service_kind });
match self
.client
.device_register(
consumer_kind,
&self.config.display_name,
self.config.platform.as_deref(),
self.config.hpke_public_key.as_deref(),
)
.await
{
Ok(_) => info!(name = %self.config.display_name, "agent device registered"),
Err(e) if is_already_registered(&e) => {
debug!(name = %self.config.display_name, "agent already registered; reusing binding");
}
Err(e) => return Err(e),
}
Ok(())
}
pub fn client(&self) -> &VtaClient {
&self.client
}
pub async fn run<H, Fut>(&self, mut handler: H) -> Result<(), VtaError>
where
H: FnMut(InboundMessage) -> Fut,
Fut: std::future::Future<Output = AgentControl>,
{
let mut ticker = tokio::time::interval(Duration::from_secs(self.config.heartbeat_secs));
ticker.tick().await;
let resolver = crate::trust_task_proof::TrustTaskVmResolver::from_optional(
affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
)
.await
.ok(),
);
let result = loop {
tokio::select! {
_ = ticker.tick() => {
if let Err(e) = self.client.device_heartbeat(self.config.platform.as_deref()).await {
warn!(error = %e, "agent heartbeat failed; retrying next tick");
}
}
inbound = self.client.receive_next(INBOUND_POLL_SECS) => {
match inbound {
Ok(Some(json)) => match InboundMessage::parse(&json) {
Ok(msg) => {
let msg = msg.verify(&resolver).await;
if handler(msg).await == AgentControl::Stop {
break Ok(());
}
}
Err(e) => warn!(error = %e, "dropping unparseable inbound message"),
},
Ok(None) => {} Err(e) => break Err(e),
}
}
}
};
self.client.shutdown().await;
result
}
pub async fn shutdown(&self) {
self.client.shutdown().await;
}
}
fn is_already_registered(e: &VtaError) -> bool {
if matches!(e, VtaError::Conflict(_)) {
return true;
}
let msg = e.to_string();
msg.contains("alreadyRegistered") || msg.contains("already_registered")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_defaults_then_builders() {
let cfg = AgentConfig::new("did:key:zA", "zKey", "did:key:zVta", "did:key:zMed")
.display_name("nano-claw")
.platform("linux")
.heartbeat_secs(0); assert_eq!(cfg.service_kind, "ai-agent");
assert_eq!(cfg.display_name, "nano-claw");
assert_eq!(cfg.platform.as_deref(), Some("linux"));
assert_eq!(cfg.heartbeat_secs, 1, "heartbeat clamps to >= 1");
}
#[test]
fn inbound_message_parses_didcomm_envelope() {
let json = r#"{
"id": "urn:uuid:abc",
"type": "https://trusttasks.org/spec/push/wake/0.2",
"from": "did:key:zVta",
"body": { "reason": "work-available" }
}"#;
let msg = InboundMessage::parse(json).unwrap();
assert_eq!(msg.id, "urn:uuid:abc");
assert_eq!(msg.typ, "https://trusttasks.org/spec/push/wake/0.2");
assert_eq!(msg.claimed_from.as_deref(), Some("did:key:zVta"));
assert!(
msg.verified_signer.is_none(),
"parse alone vouches for nobody"
);
assert_eq!(msg.body["reason"], "work-available");
}
async fn signed_doc_by(seed: u8, issuer_override: Option<&str>) -> (String, Value) {
use affinidi_tdk::secrets_resolver::secrets::Secret;
let mut secret = Secret::generate_ed25519(None, Some(&[seed; 32]));
let mb = secret.get_public_keymultibase().unwrap();
let did = format!("did:key:{mb}");
secret.id = format!("{did}#{mb}");
let mut doc = json!({
"id": "urn:uuid:00000000-0000-0000-0000-00000000000a",
"type": "https://trusttasks.org/spec/push/wake/0.2",
"issuer": issuer_override.unwrap_or(&did),
"recipient": "did:key:zAgent",
"issuedAt": "2026-01-01T00:00:00Z",
"payload": {},
});
let proof = affinidi_data_integrity::DataIntegrityProof::sign(
&doc,
&secret,
affinidi_data_integrity::SignOptions::new(),
)
.await
.unwrap();
doc["proof"] = serde_json::to_value(proof).unwrap();
(did, doc)
}
fn envelope(from: &str, body: Value) -> String {
json!({ "id": "m", "type": "t", "from": from, "body": body }).to_string()
}
#[tokio::test]
async fn verified_signer_comes_from_the_document_proof_only() {
let resolver = crate::trust_task_proof::TrustTaskVmResolver::did_key_only();
let (did, doc) = signed_doc_by(0x11, None).await;
let msg = InboundMessage::parse(&envelope("did:key:zSomeoneElse", doc))
.unwrap()
.verify(&resolver)
.await;
assert_eq!(msg.verified_signer.as_deref(), Some(did.as_str()));
assert_eq!(msg.claimed_from.as_deref(), Some("did:key:zSomeoneElse"));
let msg = InboundMessage::parse(&envelope(&did, json!({ "type": "x", "payload": {} })))
.unwrap()
.verify(&resolver)
.await;
assert!(msg.verified_signer.is_none());
let (_did, doc) = signed_doc_by(0x12, Some("did:key:z6MkVictim")).await;
let msg = InboundMessage::parse(&envelope("did:key:z6MkVictim", doc))
.unwrap()
.verify(&resolver)
.await;
assert!(msg.verified_signer.is_none());
}
#[test]
fn inbound_message_is_lenient_about_missing_fields() {
let msg = InboundMessage::parse(r#"{"type":"x/1.0"}"#).unwrap();
assert_eq!(msg.typ, "x/1.0");
assert!(msg.id.is_empty());
assert!(msg.claimed_from.is_none());
assert!(msg.body.is_null());
}
#[test]
fn from_client_wraps_without_enrolling() {
let client = VtaClient::new("http://localhost:9999");
let session = AgentSession::from_client(
client,
AgentConfig::for_attach("vta-mcp").service_kind("ai-agent"),
);
assert_eq!(session.config.display_name, "vta-mcp");
assert_eq!(session.config.service_kind, "ai-agent");
let _ = session.client();
}
#[test]
fn already_registered_is_detected_across_transports() {
assert!(is_already_registered(&VtaError::Conflict("dup".into())));
assert!(is_already_registered(&VtaError::Protocol(
"trust task rejected: device/register:alreadyRegistered — ...".into()
)));
assert!(!is_already_registered(&VtaError::Protocol(
"something else".into()
)));
}
#[test]
fn already_registered_accepts_both_registry_spellings() {
for code in [
"device/register:alreadyRegistered",
"device/register:already_registered",
] {
assert!(
is_already_registered(&VtaError::Protocol(format!(
"trust task rejected: {code} — a DeviceBinding already exists for did:key:zA"
))),
"matcher must accept {code}"
);
}
}
}