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