1use crate::blueprint::{
9 AgentProviderManifest, BindOutcome, BindReceipt, BindRequest, BindingAttestation,
10 BindingBackend, BoundAgent, Runner,
11};
12use async_trait::async_trait;
13use serde::Serialize;
14use std::collections::{BTreeSet, HashMap, HashSet};
15use thiserror::Error;
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum LegacyWorkerBindingPolicy {
23 #[default]
26 Allow,
27 Reject,
29}
30
31#[async_trait]
33pub trait AgentBindingProvider: Send + Sync {
34 async fn bind(
43 &self,
44 requests: &[BindRequest],
45 ) -> Result<Vec<BindOutcome>, BindingProviderError>;
46
47 fn pinned_to_session(
61 &self,
62 _session_id: &str,
63 ) -> Option<std::sync::Arc<dyn AgentBindingProvider>> {
64 None
65 }
66}
67
68#[derive(Debug, Clone)]
75pub struct ManifestBindingProvider {
76 manifest: AgentProviderManifest,
77}
78
79impl ManifestBindingProvider {
80 pub fn new(manifest: AgentProviderManifest) -> Self {
82 Self { manifest }
83 }
84
85 pub fn manifest(&self) -> &AgentProviderManifest {
87 &self.manifest
88 }
89
90 fn outcome_for(&self, request: &BindRequest) -> Result<BindOutcome, BindingProviderError> {
91 if request.backend == BindingBackend::AgentBlockInProcess {
92 return Err(BindingProviderError::Provider(format!(
93 "manifest provider '{}' cannot bind in-process agent '{}'",
94 self.manifest.provider_id, request.agent
95 )));
96 }
97 let mut matches = self
98 .manifest
99 .capabilities
100 .iter()
101 .filter(|capability| capability.launch_variant == request.launch_variant);
102 let Some(capability) = matches.next() else {
103 return Ok(BindOutcome::Unbound {
108 agent: request.agent.clone(),
109 reason: format!(
110 "manifest provider '{}' has no capability for launch variant {:?}",
111 self.manifest.provider_id, request.launch_variant
112 ),
113 });
114 };
115 if matches.next().is_some() {
116 return Err(BindingProviderError::Provider(format!(
120 "manifest provider '{}' declares duplicate capabilities for launch variant {:?}",
121 self.manifest.provider_id, request.launch_variant
122 )));
123 }
124 Ok(BindOutcome::Bound {
125 receipt: BindReceipt {
126 agent: request.agent.clone(),
127 request_digest: request.request_digest.clone(),
128 provider_id: self.manifest.provider_id.clone(),
129 provider_revision: self.manifest.provider_revision.clone(),
130 resolved_model: capability.resolved_model.clone(),
131 effective_tools: capability.effective_tools.clone(),
132 launch_variant: capability.launch_variant.clone(),
133 capability_snapshot_digest: capability.capability_snapshot_digest.clone(),
134 },
135 })
136 }
137}
138
139#[async_trait]
140impl AgentBindingProvider for ManifestBindingProvider {
141 async fn bind(
142 &self,
143 requests: &[BindRequest],
144 ) -> Result<Vec<BindOutcome>, BindingProviderError> {
145 requests
146 .iter()
147 .map(|request| self.outcome_for(request))
148 .collect()
149 }
150}
151
152#[derive(Debug, Error, Clone, PartialEq, Eq)]
154pub enum BindingProviderError {
155 #[error("binding provider failed: {0}")]
157 Provider(String),
158 #[error("binding provider returned duplicate receipt for agent '{agent}'")]
160 DuplicateReceipt {
161 agent: String,
163 },
164 #[error("binding provider returned no receipt for agent '{agent}'")]
166 MissingReceipt {
167 agent: String,
169 },
170 #[error("binding provider returned unexpected receipt for agent '{agent}'")]
172 UnexpectedReceipt {
173 agent: String,
175 },
176 #[error(
178 "binding receipt for agent '{agent}' attests request digest '{effective}', expected '{requested}'"
179 )]
180 RequestDigestMismatch {
181 agent: String,
183 requested: crate::blueprint::BindingDigest,
185 effective: crate::blueprint::BindingDigest,
187 },
188 #[error("binding receipt for agent '{agent}' has an empty provider_id")]
190 EmptyProviderId {
191 agent: String,
193 },
194 #[error(
197 "binding receipt for agent '{agent}' omitted resolved_model for requested model '{requested}'"
198 )]
199 MissingResolvedModel {
200 agent: String,
202 requested: String,
204 },
205 #[error("binding receipt for agent '{agent}' is missing requested tools: {missing:?}")]
207 MissingTools {
208 agent: String,
210 missing: Vec<String>,
212 },
213 #[error(
215 "binding receipt for agent '{agent}' resolved launch variant {effective:?}, expected '{requested}'"
216 )]
217 VariantMismatch {
218 agent: String,
220 requested: String,
222 effective: Option<String>,
224 },
225 #[error("binding digest recompute failed: {0}")]
228 Digest(String),
229 #[error(
234 "strict_binding requires an attestation for agent '{agent}' \
235 (requested launch variant {variant:?}, tools {tools:?}, model {model:?}) \
236 but the provider returned Unbound: {reason}"
237 )]
238 AttestationRequired {
239 agent: String,
241 reason: String,
243 variant: Option<String>,
245 tools: Vec<String>,
247 model: Option<String>,
249 },
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct UnboundAgent {
259 pub agent: String,
261 pub reason: String,
263}
264
265pub fn binding_requests(bound_agents: &[BoundAgent]) -> Vec<BindRequest> {
267 bound_agents
268 .iter()
269 .filter_map(binding_request_for_snapshot)
270 .collect()
271}
272
273pub fn binding_request_for_snapshot(bound: &BoundAgent) -> Option<BindRequest> {
281 let runner = bound.runner.as_ref()?;
282 let (backend, requested_tools, launch_variant) = match runner {
283 Runner::WsOperator { variant, tools } => (
284 BindingBackend::WsOperator,
285 canonical_tools(tools),
286 Some(variant.clone()),
287 ),
288 Runner::WsClaudeCode { variant, tools } => (
289 BindingBackend::WsClaudeCode,
290 canonical_tools(tools),
291 Some(variant.clone()),
292 ),
293 Runner::AgentBlockInProcess { tools } => (
294 BindingBackend::AgentBlockInProcess,
295 canonical_tools(tools),
296 None,
297 ),
298 Runner::Subprocess { .. } => return None,
302 };
303 Some(BindRequest {
304 agent: bound.agent.name.clone(),
305 request_digest: bound.attestation.as_ref().map_or_else(
306 || bound.binding_digest.clone(),
307 |attestation| attestation.request_digest.clone(),
308 ),
309 backend,
310 binding_target: bound
316 .agent
317 .spec
318 .get("operator_ref")
319 .and_then(|value| value.as_str())
320 .and_then(|value| crate::types::OperatorRef::new(value).ok()),
321 requested_model: bound
322 .agent
323 .profile
324 .as_ref()
325 .and_then(|profile| profile.model.clone()),
326 requested_tools,
327 launch_variant,
328 })
329}
330
331#[derive(Serialize)]
332struct LegacyEvidenceAttestation<'a> {
333 request_digest: &'a crate::blueprint::BindingDigest,
334 provider_id: &'a str,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 provider_revision: &'a Option<String>,
337 #[serde(skip_serializing_if = "Option::is_none")]
338 resolved_model: &'a Option<String>,
339 #[serde(skip_serializing_if = "Vec::is_empty")]
340 effective_tools: &'a Vec<String>,
341 #[serde(skip_serializing_if = "Option::is_none")]
342 launch_variant: &'a Option<String>,
343 #[serde(skip_serializing_if = "Option::is_none")]
344 evidence_digest: &'a Option<crate::blueprint::BindingDigest>,
345}
346
347#[derive(Serialize)]
348struct LegacyEvidenceBoundAgentDigestInput<'a> {
349 agent: &'a crate::blueprint::AgentDef,
350 runner: &'a Option<Runner>,
351 context_policy: &'a Option<crate::core::agent_context::ContextPolicy>,
352 runner_source: crate::blueprint::RunnerResolutionSource,
353 attestation: Option<LegacyEvidenceAttestation<'a>>,
354}
355
356fn legacy_evidence_binding_digest(
357 bound: &BoundAgent,
358) -> Result<crate::blueprint::BindingDigest, BindingProviderError> {
359 let attestation = bound
360 .attestation
361 .as_ref()
362 .map(|attestation| LegacyEvidenceAttestation {
363 request_digest: &attestation.request_digest,
364 provider_id: &attestation.provider_id,
365 provider_revision: &attestation.provider_revision,
366 resolved_model: &attestation.resolved_model,
367 effective_tools: &attestation.effective_tools,
368 launch_variant: &attestation.launch_variant,
369 evidence_digest: &attestation.capability_snapshot_digest,
370 });
371 let bytes = serde_json::to_vec(&LegacyEvidenceBoundAgentDigestInput {
372 agent: &bound.agent,
373 runner: &bound.runner,
374 context_policy: &bound.context_policy,
375 runner_source: bound.runner_source,
376 attestation,
377 })
378 .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
379 Ok(crate::blueprint::BindingDigest::sha256(bytes))
380}
381
382pub fn validate_bound_agent_snapshot(bound: &BoundAgent) -> Result<(), BindingProviderError> {
389 let mut expected = bound.clone();
390 expected
391 .recompute_binding_digest()
392 .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
393 let legacy_digest = legacy_evidence_binding_digest(bound)?;
394 if expected.binding_digest != bound.binding_digest && legacy_digest != bound.binding_digest {
395 return Err(BindingProviderError::Digest(format!(
396 "stored BoundAgent '{}' has binding digest '{}', recomputed '{}'",
397 bound.agent.name, bound.binding_digest, expected.binding_digest
398 )));
399 }
400
401 let Some(attestation) = bound.attestation.as_ref() else {
402 return Ok(());
403 };
404
405 let mut declaration = bound.clone();
406 declaration.attestation = None;
407 declaration
408 .recompute_binding_digest()
409 .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
410 let request = binding_request_for_snapshot(&declaration).ok_or_else(|| {
411 BindingProviderError::Provider(format!(
412 "stored BoundAgent '{}' has an attestation but no Runner declaration",
413 bound.agent.name
414 ))
415 })?;
416 let validated = validate_receipt(
417 &request,
418 BindReceipt {
419 agent: bound.agent.name.clone(),
420 request_digest: attestation.request_digest.clone(),
421 provider_id: attestation.provider_id.clone(),
422 provider_revision: attestation.provider_revision.clone(),
423 resolved_model: attestation.resolved_model.clone(),
424 effective_tools: attestation.effective_tools.clone(),
425 launch_variant: attestation.launch_variant.clone(),
426 capability_snapshot_digest: attestation.capability_snapshot_digest.clone(),
427 },
428 )?;
429 if &validated != attestation {
430 return Err(BindingProviderError::Provider(format!(
431 "stored BoundAgent '{}' contains a non-canonical attestation",
432 bound.agent.name
433 )));
434 }
435 Ok(())
436}
437
438pub fn validate_bound_agent_snapshots(
441 bound_agents: &[BoundAgent],
442) -> Result<(), BindingProviderError> {
443 for bound in bound_agents {
444 validate_bound_agent_snapshot(bound)?;
445 }
446 Ok(())
447}
448
449pub async fn attest_bound_agents(
468 provider: &dyn AgentBindingProvider,
469 bound_agents: &mut [BoundAgent],
470 strict: bool,
471) -> Result<Vec<UnboundAgent>, BindingProviderError> {
472 let requests = binding_requests(bound_agents);
473 if requests.is_empty() {
474 return Ok(Vec::new());
475 }
476
477 let outcomes = provider.bind(&requests).await?;
478 let requested_names: HashSet<&str> = requests.iter().map(|r| r.agent.as_str()).collect();
479 let mut by_agent = HashMap::with_capacity(outcomes.len());
480 for outcome in outcomes {
481 let agent = match &outcome {
482 BindOutcome::Bound { receipt } => receipt.agent.clone(),
483 BindOutcome::Unbound { agent, .. } => agent.clone(),
484 };
485 if !requested_names.contains(agent.as_str()) {
486 return Err(BindingProviderError::UnexpectedReceipt { agent });
487 }
488 if by_agent.insert(agent.clone(), outcome).is_some() {
489 return Err(BindingProviderError::DuplicateReceipt { agent });
490 }
491 }
492
493 let mut accepted = Vec::with_capacity(requests.len());
494 let mut unbound = Vec::new();
495 for request in requests {
496 let outcome = by_agent.remove(&request.agent).ok_or_else(|| {
497 BindingProviderError::MissingReceipt {
498 agent: request.agent.clone(),
499 }
500 })?;
501 match outcome {
502 BindOutcome::Bound { receipt } => {
503 let attestation = validate_receipt(&request, receipt)?;
504 accepted.push((request.agent, attestation));
505 }
506 BindOutcome::Unbound { reason, .. } => {
507 if strict {
508 return Err(BindingProviderError::AttestationRequired {
509 agent: request.agent,
510 reason,
511 variant: request.launch_variant,
512 tools: request.requested_tools,
513 model: request.requested_model,
514 });
515 }
516 unbound.push(UnboundAgent {
517 agent: request.agent,
518 reason,
519 });
520 }
521 }
522 }
523
524 for (agent, attestation) in accepted {
525 let bound = bound_agents
526 .iter_mut()
527 .find(|bound| bound.agent.name == agent)
528 .expect("BindRequest is constructed from BoundAgent");
529 bound
530 .set_attestation(attestation)
531 .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
532 }
533 Ok(unbound)
534}
535
536fn validate_receipt(
537 request: &BindRequest,
538 receipt: BindReceipt,
539) -> Result<BindingAttestation, BindingProviderError> {
540 if receipt.request_digest != request.request_digest {
541 return Err(BindingProviderError::RequestDigestMismatch {
542 agent: request.agent.clone(),
543 requested: request.request_digest.clone(),
544 effective: receipt.request_digest,
545 });
546 }
547 if receipt.provider_id.trim().is_empty() {
548 return Err(BindingProviderError::EmptyProviderId {
549 agent: request.agent.clone(),
550 });
551 }
552 if let Some(requested) = &request.requested_model {
553 if receipt
554 .resolved_model
555 .as_deref()
556 .map_or(true, str::is_empty)
557 {
558 return Err(BindingProviderError::MissingResolvedModel {
559 agent: request.agent.clone(),
560 requested: requested.clone(),
561 });
562 }
563 }
564
565 let effective_tools = canonical_tools(&receipt.effective_tools);
566 let effective_set: BTreeSet<&str> = effective_tools.iter().map(String::as_str).collect();
567 let missing: Vec<String> = request
568 .requested_tools
569 .iter()
570 .filter(|tool| !effective_set.contains(tool.as_str()))
571 .cloned()
572 .collect();
573 if !missing.is_empty() {
574 return Err(BindingProviderError::MissingTools {
575 agent: request.agent.clone(),
576 missing,
577 });
578 }
579 if let Some(requested) = &request.launch_variant {
580 if receipt.launch_variant.as_ref() != Some(requested) {
581 return Err(BindingProviderError::VariantMismatch {
582 agent: request.agent.clone(),
583 requested: requested.clone(),
584 effective: receipt.launch_variant,
585 });
586 }
587 }
588
589 Ok(BindingAttestation {
590 request_digest: request.request_digest.clone(),
591 provider_id: receipt.provider_id,
592 provider_revision: receipt.provider_revision,
593 resolved_model: receipt.resolved_model,
594 effective_tools,
595 launch_variant: receipt.launch_variant,
596 capability_snapshot_digest: receipt.capability_snapshot_digest,
597 })
598}
599
600fn canonical_tools(tools: &[String]) -> Vec<String> {
601 tools
602 .iter()
603 .filter(|tool| !tool.is_empty())
604 .cloned()
605 .collect::<BTreeSet<_>>()
606 .into_iter()
607 .collect()
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use crate::blueprint::{
614 current_schema_version, resolve_bound_agents, AgentDef, AgentKind, AgentProfile, Blueprint,
615 BlueprintMetadata, CompilerHints, CompilerStrategy,
616 };
617 use mlua_flow_ir::Node as FlowNode;
618 use serde_json::json;
619
620 struct ReceiptProvider(Vec<BindReceipt>);
621
622 #[async_trait]
623 impl AgentBindingProvider for ReceiptProvider {
624 async fn bind(
625 &self,
626 _requests: &[BindRequest],
627 ) -> Result<Vec<BindOutcome>, BindingProviderError> {
628 Ok(self
629 .0
630 .iter()
631 .cloned()
632 .map(|receipt| BindOutcome::Bound { receipt })
633 .collect())
634 }
635 }
636
637 struct UnboundProvider(&'static str);
640
641 #[async_trait]
642 impl AgentBindingProvider for UnboundProvider {
643 async fn bind(
644 &self,
645 requests: &[BindRequest],
646 ) -> Result<Vec<BindOutcome>, BindingProviderError> {
647 Ok(requests
648 .iter()
649 .map(|request| BindOutcome::Unbound {
650 agent: request.agent.clone(),
651 reason: self.0.to_string(),
652 })
653 .collect())
654 }
655 }
656
657 fn bound() -> Vec<BoundAgent> {
658 let mut bp = Blueprint {
659 schema_version: current_schema_version(),
660 id: "binding-test".into(),
661 flow: FlowNode::Seq { children: vec![] },
662 agents: vec![],
663 operators: vec![],
664 metas: vec![],
665 hints: CompilerHints::default(),
666 strategy: CompilerStrategy::default(),
667 metadata: BlueprintMetadata::default(),
668 spawner_hints: Default::default(),
669 default_agent_kind: AgentKind::Operator,
670 default_operator_kind: None,
671 default_init_ctx: None,
672 default_agent_ctx: None,
673 default_context_policy: None,
674 projection_placement: None,
675 audits: vec![],
676 degradation_policy: None,
677 runners: vec![],
678 default_runner: None,
679 subprocesses: vec![],
680 check_policy: None,
681 blueprint_ref_includes: vec![],
682 };
683 bp.agents.push(AgentDef {
684 name: "coder".to_string(),
685 kind: AgentKind::Operator,
686 spec: json!({ "operator_ref": "main-ai" }),
687 profile: Some(AgentProfile {
688 model: Some("sonnet".to_string()),
689 ..Default::default()
690 }),
691 meta: None,
692 runner: Some(Runner::WsClaudeCode {
693 variant: "mse-coder".to_string(),
694 tools: vec!["Write".to_string(), "Read".to_string()],
695 }),
696 runner_ref: None,
697 verdict: None,
698 lints: None,
699 });
700 resolve_bound_agents(&bp).unwrap()
701 }
702
703 fn receipt() -> BindReceipt {
704 BindReceipt {
705 agent: "coder".to_string(),
706 request_digest: bound()[0].binding_digest.clone(),
707 provider_id: "operator-main-ai".to_string(),
708 provider_revision: Some("1".to_string()),
709 resolved_model: Some("claude-sonnet-4".to_string()),
710 effective_tools: vec!["Write".to_string(), "Read".to_string()],
711 launch_variant: Some("mse-coder".to_string()),
712 capability_snapshot_digest: None,
713 }
714 }
715
716 #[test]
717 fn requests_are_canonical_and_include_declaration_digest() {
718 let bound = bound();
719 let requests = binding_requests(&bound);
720 assert_eq!(requests[0].agent, "coder");
721 assert_eq!(requests[0].request_digest, bound[0].binding_digest);
722 assert_eq!(requests[0].backend, BindingBackend::WsClaudeCode);
723 assert_eq!(
724 requests[0].binding_target.as_ref().map(|t| t.as_str()),
725 Some("main-ai")
726 );
727 assert_eq!(requests[0].requested_model.as_deref(), Some("sonnet"));
728 assert_eq!(requests[0].requested_tools, ["Read", "Write"]);
729 assert_eq!(requests[0].launch_variant.as_deref(), Some("mse-coder"));
730 }
731
732 #[tokio::test]
733 async fn accepted_receipt_is_attested_and_changes_digest() {
734 let mut bound = bound();
735 let declaration_digest = bound[0].binding_digest.clone();
736 let unbound = attest_bound_agents(&ReceiptProvider(vec![receipt()]), &mut bound, false)
737 .await
738 .unwrap();
739 assert!(unbound.is_empty());
740 assert_ne!(bound[0].binding_digest, declaration_digest);
741 validate_bound_agent_snapshot(&bound[0]).unwrap();
742 assert_eq!(
743 bound[0].attestation.as_ref().unwrap().effective_tools,
744 ["Read", "Write"]
745 );
746 }
747
748 #[test]
749 fn persisted_snapshot_rejects_binding_digest_drift() {
750 let mut bound = bound().remove(0);
751 bound.agent.profile.as_mut().unwrap().system_prompt = "mutated after persistence".into();
752
753 let error = validate_bound_agent_snapshot(&bound).unwrap_err();
754 assert!(matches!(error, BindingProviderError::Digest(_)));
755 }
756
757 #[tokio::test]
758 async fn persisted_snapshot_rejects_attestation_for_a_different_declaration() {
759 let mut bound = bound();
760 attest_bound_agents(&ReceiptProvider(vec![receipt()]), &mut bound, false)
761 .await
762 .unwrap();
763 bound[0].attestation.as_mut().unwrap().request_digest =
764 crate::blueprint::BindingDigest::sha256("other-declaration");
765 bound[0].recompute_binding_digest().unwrap();
766
767 let error = validate_bound_agent_snapshot(&bound[0]).unwrap_err();
768 assert!(matches!(
769 error,
770 BindingProviderError::RequestDigestMismatch { .. }
771 ));
772 }
773
774 #[tokio::test]
775 async fn persisted_snapshot_accepts_the_legacy_evidence_digest_identity() {
776 let mut receipt = receipt();
777 receipt.capability_snapshot_digest = Some(crate::blueprint::BindingDigest::sha256(
778 "legacy-capabilities",
779 ));
780 let mut bound = bound();
781 attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
782 .await
783 .unwrap();
784 let new_digest = bound[0].binding_digest.clone();
785 let legacy_digest = legacy_evidence_binding_digest(&bound[0]).unwrap();
786 assert_ne!(legacy_digest, new_digest);
787
788 bound[0].binding_digest = legacy_digest;
789 validate_bound_agent_snapshot(&bound[0]).unwrap();
790 }
791
792 #[tokio::test]
793 async fn missing_tool_fails_closed() {
794 let mut bound = bound();
795 let mut receipt = receipt();
796 receipt.effective_tools = vec!["Read".to_string()];
797 let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
800 .await
801 .unwrap_err();
802 assert_eq!(
803 error,
804 BindingProviderError::MissingTools {
805 agent: "coder".to_string(),
806 missing: vec!["Write".to_string()],
807 }
808 );
809 }
810
811 #[tokio::test]
812 async fn missing_receipt_fails_closed() {
813 let mut bound = bound();
814 let error = attest_bound_agents(&ReceiptProvider(vec![]), &mut bound, false)
815 .await
816 .unwrap_err();
817 assert_eq!(
818 error,
819 BindingProviderError::MissingReceipt {
820 agent: "coder".to_string(),
821 }
822 );
823 assert!(bound[0].attestation.is_none());
824 }
825
826 #[tokio::test]
827 async fn stale_request_digest_fails_closed() {
828 let mut bound = bound();
829 let mut receipt = receipt();
830 receipt.request_digest = crate::blueprint::BindingDigest::sha256("stale");
831 let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
832 .await
833 .unwrap_err();
834 assert!(matches!(
835 error,
836 BindingProviderError::RequestDigestMismatch { .. }
837 ));
838 assert!(bound[0].attestation.is_none());
839 }
840
841 #[tokio::test]
842 async fn variant_mismatch_fails_closed() {
843 let mut bound = bound();
844 let mut receipt = receipt();
845 receipt.launch_variant = Some("other".to_string());
846 let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
847 .await
848 .unwrap_err();
849 assert!(matches!(
850 error,
851 BindingProviderError::VariantMismatch { .. }
852 ));
853 }
854
855 #[tokio::test]
856 async fn unbound_outcome_is_collected_in_non_strict_mode() {
857 let mut bound = bound();
858 let unbound = attest_bound_agents(
859 &UnboundProvider("no capability for launch variant"),
860 &mut bound,
861 false,
862 )
863 .await
864 .expect("non-strict must not fail on Unbound");
865 assert_eq!(unbound.len(), 1);
866 assert_eq!(unbound[0].agent, "coder");
867 assert_eq!(unbound[0].reason, "no capability for launch variant");
868 assert!(bound[0].attestation.is_none());
870 }
871
872 #[tokio::test]
873 async fn unbound_outcome_fails_closed_in_strict_mode_with_requirements() {
874 let mut bound = bound();
875 let error = attest_bound_agents(
876 &UnboundProvider("role main-ai has not joined"),
877 &mut bound,
878 true,
879 )
880 .await
881 .unwrap_err();
882 match &error {
883 BindingProviderError::AttestationRequired {
884 agent,
885 variant,
886 tools,
887 ..
888 } => {
889 assert_eq!(agent, "coder");
890 assert_eq!(variant.as_deref(), Some("mse-coder"));
891 assert_eq!(tools, &["Read".to_string(), "Write".to_string()]);
892 }
893 other => panic!("expected AttestationRequired, got {other:?}"),
894 }
895 let message = error.to_string();
898 assert!(message.contains("coder"), "message: {message}");
899 assert!(message.contains("mse-coder"), "message: {message}");
900 assert!(message.contains("Read"), "message: {message}");
901 assert!(bound[0].attestation.is_none());
902 }
903}