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