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 };
279 Some(BindRequest {
280 agent: bound.agent.name.clone(),
281 request_digest: bound.attestation.as_ref().map_or_else(
282 || bound.binding_digest.clone(),
283 |attestation| attestation.request_digest.clone(),
284 ),
285 backend,
286 binding_target: bound
287 .agent
288 .spec
289 .get("operator_ref")
290 .and_then(|value| value.as_str())
291 .map(str::to_string),
292 requested_model: bound
293 .agent
294 .profile
295 .as_ref()
296 .and_then(|profile| profile.model.clone()),
297 requested_tools,
298 launch_variant,
299 })
300}
301
302#[derive(Serialize)]
303struct LegacyEvidenceAttestation<'a> {
304 request_digest: &'a crate::blueprint::BindingDigest,
305 provider_id: &'a str,
306 #[serde(skip_serializing_if = "Option::is_none")]
307 provider_revision: &'a Option<String>,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 resolved_model: &'a Option<String>,
310 #[serde(skip_serializing_if = "Vec::is_empty")]
311 effective_tools: &'a Vec<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 launch_variant: &'a Option<String>,
314 #[serde(skip_serializing_if = "Option::is_none")]
315 evidence_digest: &'a Option<crate::blueprint::BindingDigest>,
316}
317
318#[derive(Serialize)]
319struct LegacyEvidenceBoundAgentDigestInput<'a> {
320 agent: &'a crate::blueprint::AgentDef,
321 runner: &'a Option<Runner>,
322 context_policy: &'a Option<crate::core::agent_context::ContextPolicy>,
323 runner_source: crate::blueprint::RunnerResolutionSource,
324 attestation: Option<LegacyEvidenceAttestation<'a>>,
325}
326
327fn legacy_evidence_binding_digest(
328 bound: &BoundAgent,
329) -> Result<crate::blueprint::BindingDigest, BindingProviderError> {
330 let attestation = bound
331 .attestation
332 .as_ref()
333 .map(|attestation| LegacyEvidenceAttestation {
334 request_digest: &attestation.request_digest,
335 provider_id: &attestation.provider_id,
336 provider_revision: &attestation.provider_revision,
337 resolved_model: &attestation.resolved_model,
338 effective_tools: &attestation.effective_tools,
339 launch_variant: &attestation.launch_variant,
340 evidence_digest: &attestation.capability_snapshot_digest,
341 });
342 let bytes = serde_json::to_vec(&LegacyEvidenceBoundAgentDigestInput {
343 agent: &bound.agent,
344 runner: &bound.runner,
345 context_policy: &bound.context_policy,
346 runner_source: bound.runner_source,
347 attestation,
348 })
349 .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
350 Ok(crate::blueprint::BindingDigest::sha256(bytes))
351}
352
353pub fn validate_bound_agent_snapshot(bound: &BoundAgent) -> Result<(), BindingProviderError> {
360 let mut expected = bound.clone();
361 expected
362 .recompute_binding_digest()
363 .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
364 let legacy_digest = legacy_evidence_binding_digest(bound)?;
365 if expected.binding_digest != bound.binding_digest && legacy_digest != bound.binding_digest {
366 return Err(BindingProviderError::Digest(format!(
367 "stored BoundAgent '{}' has binding digest '{}', recomputed '{}'",
368 bound.agent.name, bound.binding_digest, expected.binding_digest
369 )));
370 }
371
372 let Some(attestation) = bound.attestation.as_ref() else {
373 return Ok(());
374 };
375
376 let mut declaration = bound.clone();
377 declaration.attestation = None;
378 declaration
379 .recompute_binding_digest()
380 .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
381 let request = binding_request_for_snapshot(&declaration).ok_or_else(|| {
382 BindingProviderError::Provider(format!(
383 "stored BoundAgent '{}' has an attestation but no Runner declaration",
384 bound.agent.name
385 ))
386 })?;
387 let validated = validate_receipt(
388 &request,
389 BindReceipt {
390 agent: bound.agent.name.clone(),
391 request_digest: attestation.request_digest.clone(),
392 provider_id: attestation.provider_id.clone(),
393 provider_revision: attestation.provider_revision.clone(),
394 resolved_model: attestation.resolved_model.clone(),
395 effective_tools: attestation.effective_tools.clone(),
396 launch_variant: attestation.launch_variant.clone(),
397 capability_snapshot_digest: attestation.capability_snapshot_digest.clone(),
398 },
399 )?;
400 if &validated != attestation {
401 return Err(BindingProviderError::Provider(format!(
402 "stored BoundAgent '{}' contains a non-canonical attestation",
403 bound.agent.name
404 )));
405 }
406 Ok(())
407}
408
409pub fn validate_bound_agent_snapshots(
412 bound_agents: &[BoundAgent],
413) -> Result<(), BindingProviderError> {
414 for bound in bound_agents {
415 validate_bound_agent_snapshot(bound)?;
416 }
417 Ok(())
418}
419
420pub async fn attest_bound_agents(
439 provider: &dyn AgentBindingProvider,
440 bound_agents: &mut [BoundAgent],
441 strict: bool,
442) -> Result<Vec<UnboundAgent>, BindingProviderError> {
443 let requests = binding_requests(bound_agents);
444 if requests.is_empty() {
445 return Ok(Vec::new());
446 }
447
448 let outcomes = provider.bind(&requests).await?;
449 let requested_names: HashSet<&str> = requests.iter().map(|r| r.agent.as_str()).collect();
450 let mut by_agent = HashMap::with_capacity(outcomes.len());
451 for outcome in outcomes {
452 let agent = match &outcome {
453 BindOutcome::Bound { receipt } => receipt.agent.clone(),
454 BindOutcome::Unbound { agent, .. } => agent.clone(),
455 };
456 if !requested_names.contains(agent.as_str()) {
457 return Err(BindingProviderError::UnexpectedReceipt { agent });
458 }
459 if by_agent.insert(agent.clone(), outcome).is_some() {
460 return Err(BindingProviderError::DuplicateReceipt { agent });
461 }
462 }
463
464 let mut accepted = Vec::with_capacity(requests.len());
465 let mut unbound = Vec::new();
466 for request in requests {
467 let outcome = by_agent.remove(&request.agent).ok_or_else(|| {
468 BindingProviderError::MissingReceipt {
469 agent: request.agent.clone(),
470 }
471 })?;
472 match outcome {
473 BindOutcome::Bound { receipt } => {
474 let attestation = validate_receipt(&request, receipt)?;
475 accepted.push((request.agent, attestation));
476 }
477 BindOutcome::Unbound { reason, .. } => {
478 if strict {
479 return Err(BindingProviderError::AttestationRequired {
480 agent: request.agent,
481 reason,
482 variant: request.launch_variant,
483 tools: request.requested_tools,
484 model: request.requested_model,
485 });
486 }
487 unbound.push(UnboundAgent {
488 agent: request.agent,
489 reason,
490 });
491 }
492 }
493 }
494
495 for (agent, attestation) in accepted {
496 let bound = bound_agents
497 .iter_mut()
498 .find(|bound| bound.agent.name == agent)
499 .expect("BindRequest is constructed from BoundAgent");
500 bound
501 .set_attestation(attestation)
502 .map_err(|error| BindingProviderError::Digest(error.to_string()))?;
503 }
504 Ok(unbound)
505}
506
507fn validate_receipt(
508 request: &BindRequest,
509 receipt: BindReceipt,
510) -> Result<BindingAttestation, BindingProviderError> {
511 if receipt.request_digest != request.request_digest {
512 return Err(BindingProviderError::RequestDigestMismatch {
513 agent: request.agent.clone(),
514 requested: request.request_digest.clone(),
515 effective: receipt.request_digest,
516 });
517 }
518 if receipt.provider_id.trim().is_empty() {
519 return Err(BindingProviderError::EmptyProviderId {
520 agent: request.agent.clone(),
521 });
522 }
523 if let Some(requested) = &request.requested_model {
524 if receipt
525 .resolved_model
526 .as_deref()
527 .map_or(true, str::is_empty)
528 {
529 return Err(BindingProviderError::MissingResolvedModel {
530 agent: request.agent.clone(),
531 requested: requested.clone(),
532 });
533 }
534 }
535
536 let effective_tools = canonical_tools(&receipt.effective_tools);
537 let effective_set: BTreeSet<&str> = effective_tools.iter().map(String::as_str).collect();
538 let missing: Vec<String> = request
539 .requested_tools
540 .iter()
541 .filter(|tool| !effective_set.contains(tool.as_str()))
542 .cloned()
543 .collect();
544 if !missing.is_empty() {
545 return Err(BindingProviderError::MissingTools {
546 agent: request.agent.clone(),
547 missing,
548 });
549 }
550 if let Some(requested) = &request.launch_variant {
551 if receipt.launch_variant.as_ref() != Some(requested) {
552 return Err(BindingProviderError::VariantMismatch {
553 agent: request.agent.clone(),
554 requested: requested.clone(),
555 effective: receipt.launch_variant,
556 });
557 }
558 }
559
560 Ok(BindingAttestation {
561 request_digest: request.request_digest.clone(),
562 provider_id: receipt.provider_id,
563 provider_revision: receipt.provider_revision,
564 resolved_model: receipt.resolved_model,
565 effective_tools,
566 launch_variant: receipt.launch_variant,
567 capability_snapshot_digest: receipt.capability_snapshot_digest,
568 })
569}
570
571fn canonical_tools(tools: &[String]) -> Vec<String> {
572 tools
573 .iter()
574 .filter(|tool| !tool.is_empty())
575 .cloned()
576 .collect::<BTreeSet<_>>()
577 .into_iter()
578 .collect()
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use crate::blueprint::{
585 current_schema_version, resolve_bound_agents, AgentDef, AgentKind, AgentProfile, Blueprint,
586 BlueprintMetadata, CompilerHints, CompilerStrategy,
587 };
588 use mlua_flow_ir::Node as FlowNode;
589 use serde_json::json;
590
591 struct ReceiptProvider(Vec<BindReceipt>);
592
593 #[async_trait]
594 impl AgentBindingProvider for ReceiptProvider {
595 async fn bind(
596 &self,
597 _requests: &[BindRequest],
598 ) -> Result<Vec<BindOutcome>, BindingProviderError> {
599 Ok(self
600 .0
601 .iter()
602 .cloned()
603 .map(|receipt| BindOutcome::Bound { receipt })
604 .collect())
605 }
606 }
607
608 struct UnboundProvider(&'static str);
611
612 #[async_trait]
613 impl AgentBindingProvider for UnboundProvider {
614 async fn bind(
615 &self,
616 requests: &[BindRequest],
617 ) -> Result<Vec<BindOutcome>, BindingProviderError> {
618 Ok(requests
619 .iter()
620 .map(|request| BindOutcome::Unbound {
621 agent: request.agent.clone(),
622 reason: self.0.to_string(),
623 })
624 .collect())
625 }
626 }
627
628 fn bound() -> Vec<BoundAgent> {
629 let mut bp = Blueprint {
630 schema_version: current_schema_version(),
631 id: "binding-test".into(),
632 flow: FlowNode::Seq { children: vec![] },
633 agents: vec![],
634 operators: vec![],
635 metas: vec![],
636 hints: CompilerHints::default(),
637 strategy: CompilerStrategy::default(),
638 metadata: BlueprintMetadata::default(),
639 spawner_hints: Default::default(),
640 default_agent_kind: AgentKind::Operator,
641 default_operator_kind: None,
642 default_init_ctx: None,
643 default_agent_ctx: None,
644 default_context_policy: None,
645 projection_placement: None,
646 audits: vec![],
647 degradation_policy: None,
648 runners: vec![],
649 default_runner: None,
650 check_policy: None,
651 blueprint_ref_includes: vec![],
652 };
653 bp.agents.push(AgentDef {
654 name: "coder".to_string(),
655 kind: AgentKind::Operator,
656 spec: json!({ "operator_ref": "main-ai" }),
657 profile: Some(AgentProfile {
658 model: Some("sonnet".to_string()),
659 ..Default::default()
660 }),
661 meta: None,
662 runner: Some(Runner::WsClaudeCode {
663 variant: "mse-coder".to_string(),
664 tools: vec!["Write".to_string(), "Read".to_string()],
665 }),
666 runner_ref: None,
667 verdict: None,
668 });
669 resolve_bound_agents(&bp).unwrap()
670 }
671
672 fn receipt() -> BindReceipt {
673 BindReceipt {
674 agent: "coder".to_string(),
675 request_digest: bound()[0].binding_digest.clone(),
676 provider_id: "operator-main-ai".to_string(),
677 provider_revision: Some("1".to_string()),
678 resolved_model: Some("claude-sonnet-4".to_string()),
679 effective_tools: vec!["Write".to_string(), "Read".to_string()],
680 launch_variant: Some("mse-coder".to_string()),
681 capability_snapshot_digest: None,
682 }
683 }
684
685 #[test]
686 fn requests_are_canonical_and_include_declaration_digest() {
687 let bound = bound();
688 let requests = binding_requests(&bound);
689 assert_eq!(requests[0].agent, "coder");
690 assert_eq!(requests[0].request_digest, bound[0].binding_digest);
691 assert_eq!(requests[0].backend, BindingBackend::WsClaudeCode);
692 assert_eq!(requests[0].binding_target.as_deref(), Some("main-ai"));
693 assert_eq!(requests[0].requested_model.as_deref(), Some("sonnet"));
694 assert_eq!(requests[0].requested_tools, ["Read", "Write"]);
695 assert_eq!(requests[0].launch_variant.as_deref(), Some("mse-coder"));
696 }
697
698 #[tokio::test]
699 async fn accepted_receipt_is_attested_and_changes_digest() {
700 let mut bound = bound();
701 let declaration_digest = bound[0].binding_digest.clone();
702 let unbound = attest_bound_agents(&ReceiptProvider(vec![receipt()]), &mut bound, false)
703 .await
704 .unwrap();
705 assert!(unbound.is_empty());
706 assert_ne!(bound[0].binding_digest, declaration_digest);
707 validate_bound_agent_snapshot(&bound[0]).unwrap();
708 assert_eq!(
709 bound[0].attestation.as_ref().unwrap().effective_tools,
710 ["Read", "Write"]
711 );
712 }
713
714 #[test]
715 fn persisted_snapshot_rejects_binding_digest_drift() {
716 let mut bound = bound().remove(0);
717 bound.agent.profile.as_mut().unwrap().system_prompt = "mutated after persistence".into();
718
719 let error = validate_bound_agent_snapshot(&bound).unwrap_err();
720 assert!(matches!(error, BindingProviderError::Digest(_)));
721 }
722
723 #[tokio::test]
724 async fn persisted_snapshot_rejects_attestation_for_a_different_declaration() {
725 let mut bound = bound();
726 attest_bound_agents(&ReceiptProvider(vec![receipt()]), &mut bound, false)
727 .await
728 .unwrap();
729 bound[0].attestation.as_mut().unwrap().request_digest =
730 crate::blueprint::BindingDigest::sha256("other-declaration");
731 bound[0].recompute_binding_digest().unwrap();
732
733 let error = validate_bound_agent_snapshot(&bound[0]).unwrap_err();
734 assert!(matches!(
735 error,
736 BindingProviderError::RequestDigestMismatch { .. }
737 ));
738 }
739
740 #[tokio::test]
741 async fn persisted_snapshot_accepts_the_legacy_evidence_digest_identity() {
742 let mut receipt = receipt();
743 receipt.capability_snapshot_digest = Some(crate::blueprint::BindingDigest::sha256(
744 "legacy-capabilities",
745 ));
746 let mut bound = bound();
747 attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
748 .await
749 .unwrap();
750 let new_digest = bound[0].binding_digest.clone();
751 let legacy_digest = legacy_evidence_binding_digest(&bound[0]).unwrap();
752 assert_ne!(legacy_digest, new_digest);
753
754 bound[0].binding_digest = legacy_digest;
755 validate_bound_agent_snapshot(&bound[0]).unwrap();
756 }
757
758 #[tokio::test]
759 async fn missing_tool_fails_closed() {
760 let mut bound = bound();
761 let mut receipt = receipt();
762 receipt.effective_tools = vec!["Read".to_string()];
763 let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
766 .await
767 .unwrap_err();
768 assert_eq!(
769 error,
770 BindingProviderError::MissingTools {
771 agent: "coder".to_string(),
772 missing: vec!["Write".to_string()],
773 }
774 );
775 }
776
777 #[tokio::test]
778 async fn missing_receipt_fails_closed() {
779 let mut bound = bound();
780 let error = attest_bound_agents(&ReceiptProvider(vec![]), &mut bound, false)
781 .await
782 .unwrap_err();
783 assert_eq!(
784 error,
785 BindingProviderError::MissingReceipt {
786 agent: "coder".to_string(),
787 }
788 );
789 assert!(bound[0].attestation.is_none());
790 }
791
792 #[tokio::test]
793 async fn stale_request_digest_fails_closed() {
794 let mut bound = bound();
795 let mut receipt = receipt();
796 receipt.request_digest = crate::blueprint::BindingDigest::sha256("stale");
797 let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
798 .await
799 .unwrap_err();
800 assert!(matches!(
801 error,
802 BindingProviderError::RequestDigestMismatch { .. }
803 ));
804 assert!(bound[0].attestation.is_none());
805 }
806
807 #[tokio::test]
808 async fn variant_mismatch_fails_closed() {
809 let mut bound = bound();
810 let mut receipt = receipt();
811 receipt.launch_variant = Some("other".to_string());
812 let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
813 .await
814 .unwrap_err();
815 assert!(matches!(
816 error,
817 BindingProviderError::VariantMismatch { .. }
818 ));
819 }
820
821 #[tokio::test]
822 async fn unbound_outcome_is_collected_in_non_strict_mode() {
823 let mut bound = bound();
824 let unbound = attest_bound_agents(
825 &UnboundProvider("no capability for launch variant"),
826 &mut bound,
827 false,
828 )
829 .await
830 .expect("non-strict must not fail on Unbound");
831 assert_eq!(unbound.len(), 1);
832 assert_eq!(unbound[0].agent, "coder");
833 assert_eq!(unbound[0].reason, "no capability for launch variant");
834 assert!(bound[0].attestation.is_none());
836 }
837
838 #[tokio::test]
839 async fn unbound_outcome_fails_closed_in_strict_mode_with_requirements() {
840 let mut bound = bound();
841 let error = attest_bound_agents(
842 &UnboundProvider("role main-ai has not joined"),
843 &mut bound,
844 true,
845 )
846 .await
847 .unwrap_err();
848 match &error {
849 BindingProviderError::AttestationRequired {
850 agent,
851 variant,
852 tools,
853 ..
854 } => {
855 assert_eq!(agent, "coder");
856 assert_eq!(variant.as_deref(), Some("mse-coder"));
857 assert_eq!(tools, &["Read".to_string(), "Write".to_string()]);
858 }
859 other => panic!("expected AttestationRequired, got {other:?}"),
860 }
861 let message = error.to_string();
864 assert!(message.contains("coder"), "message: {message}");
865 assert!(message.contains("mse-coder"), "message: {message}");
866 assert!(message.contains("Read"), "message: {message}");
867 assert!(bound[0].attestation.is_none());
868 }
869}