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 lints: None,
694 });
695 resolve_bound_agents(&bp).unwrap()
696 }
697
698 fn receipt() -> BindReceipt {
699 BindReceipt {
700 agent: "coder".to_string(),
701 request_digest: bound()[0].binding_digest.clone(),
702 provider_id: "operator-main-ai".to_string(),
703 provider_revision: Some("1".to_string()),
704 resolved_model: Some("claude-sonnet-4".to_string()),
705 effective_tools: vec!["Write".to_string(), "Read".to_string()],
706 launch_variant: Some("mse-coder".to_string()),
707 capability_snapshot_digest: None,
708 }
709 }
710
711 #[test]
712 fn requests_are_canonical_and_include_declaration_digest() {
713 let bound = bound();
714 let requests = binding_requests(&bound);
715 assert_eq!(requests[0].agent, "coder");
716 assert_eq!(requests[0].request_digest, bound[0].binding_digest);
717 assert_eq!(requests[0].backend, BindingBackend::WsClaudeCode);
718 assert_eq!(requests[0].binding_target.as_deref(), Some("main-ai"));
719 assert_eq!(requests[0].requested_model.as_deref(), Some("sonnet"));
720 assert_eq!(requests[0].requested_tools, ["Read", "Write"]);
721 assert_eq!(requests[0].launch_variant.as_deref(), Some("mse-coder"));
722 }
723
724 #[tokio::test]
725 async fn accepted_receipt_is_attested_and_changes_digest() {
726 let mut bound = bound();
727 let declaration_digest = bound[0].binding_digest.clone();
728 let unbound = attest_bound_agents(&ReceiptProvider(vec![receipt()]), &mut bound, false)
729 .await
730 .unwrap();
731 assert!(unbound.is_empty());
732 assert_ne!(bound[0].binding_digest, declaration_digest);
733 validate_bound_agent_snapshot(&bound[0]).unwrap();
734 assert_eq!(
735 bound[0].attestation.as_ref().unwrap().effective_tools,
736 ["Read", "Write"]
737 );
738 }
739
740 #[test]
741 fn persisted_snapshot_rejects_binding_digest_drift() {
742 let mut bound = bound().remove(0);
743 bound.agent.profile.as_mut().unwrap().system_prompt = "mutated after persistence".into();
744
745 let error = validate_bound_agent_snapshot(&bound).unwrap_err();
746 assert!(matches!(error, BindingProviderError::Digest(_)));
747 }
748
749 #[tokio::test]
750 async fn persisted_snapshot_rejects_attestation_for_a_different_declaration() {
751 let mut bound = bound();
752 attest_bound_agents(&ReceiptProvider(vec![receipt()]), &mut bound, false)
753 .await
754 .unwrap();
755 bound[0].attestation.as_mut().unwrap().request_digest =
756 crate::blueprint::BindingDigest::sha256("other-declaration");
757 bound[0].recompute_binding_digest().unwrap();
758
759 let error = validate_bound_agent_snapshot(&bound[0]).unwrap_err();
760 assert!(matches!(
761 error,
762 BindingProviderError::RequestDigestMismatch { .. }
763 ));
764 }
765
766 #[tokio::test]
767 async fn persisted_snapshot_accepts_the_legacy_evidence_digest_identity() {
768 let mut receipt = receipt();
769 receipt.capability_snapshot_digest = Some(crate::blueprint::BindingDigest::sha256(
770 "legacy-capabilities",
771 ));
772 let mut bound = bound();
773 attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
774 .await
775 .unwrap();
776 let new_digest = bound[0].binding_digest.clone();
777 let legacy_digest = legacy_evidence_binding_digest(&bound[0]).unwrap();
778 assert_ne!(legacy_digest, new_digest);
779
780 bound[0].binding_digest = legacy_digest;
781 validate_bound_agent_snapshot(&bound[0]).unwrap();
782 }
783
784 #[tokio::test]
785 async fn missing_tool_fails_closed() {
786 let mut bound = bound();
787 let mut receipt = receipt();
788 receipt.effective_tools = vec!["Read".to_string()];
789 let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
792 .await
793 .unwrap_err();
794 assert_eq!(
795 error,
796 BindingProviderError::MissingTools {
797 agent: "coder".to_string(),
798 missing: vec!["Write".to_string()],
799 }
800 );
801 }
802
803 #[tokio::test]
804 async fn missing_receipt_fails_closed() {
805 let mut bound = bound();
806 let error = attest_bound_agents(&ReceiptProvider(vec![]), &mut bound, false)
807 .await
808 .unwrap_err();
809 assert_eq!(
810 error,
811 BindingProviderError::MissingReceipt {
812 agent: "coder".to_string(),
813 }
814 );
815 assert!(bound[0].attestation.is_none());
816 }
817
818 #[tokio::test]
819 async fn stale_request_digest_fails_closed() {
820 let mut bound = bound();
821 let mut receipt = receipt();
822 receipt.request_digest = crate::blueprint::BindingDigest::sha256("stale");
823 let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
824 .await
825 .unwrap_err();
826 assert!(matches!(
827 error,
828 BindingProviderError::RequestDigestMismatch { .. }
829 ));
830 assert!(bound[0].attestation.is_none());
831 }
832
833 #[tokio::test]
834 async fn variant_mismatch_fails_closed() {
835 let mut bound = bound();
836 let mut receipt = receipt();
837 receipt.launch_variant = Some("other".to_string());
838 let error = attest_bound_agents(&ReceiptProvider(vec![receipt]), &mut bound, false)
839 .await
840 .unwrap_err();
841 assert!(matches!(
842 error,
843 BindingProviderError::VariantMismatch { .. }
844 ));
845 }
846
847 #[tokio::test]
848 async fn unbound_outcome_is_collected_in_non_strict_mode() {
849 let mut bound = bound();
850 let unbound = attest_bound_agents(
851 &UnboundProvider("no capability for launch variant"),
852 &mut bound,
853 false,
854 )
855 .await
856 .expect("non-strict must not fail on Unbound");
857 assert_eq!(unbound.len(), 1);
858 assert_eq!(unbound[0].agent, "coder");
859 assert_eq!(unbound[0].reason, "no capability for launch variant");
860 assert!(bound[0].attestation.is_none());
862 }
863
864 #[tokio::test]
865 async fn unbound_outcome_fails_closed_in_strict_mode_with_requirements() {
866 let mut bound = bound();
867 let error = attest_bound_agents(
868 &UnboundProvider("role main-ai has not joined"),
869 &mut bound,
870 true,
871 )
872 .await
873 .unwrap_err();
874 match &error {
875 BindingProviderError::AttestationRequired {
876 agent,
877 variant,
878 tools,
879 ..
880 } => {
881 assert_eq!(agent, "coder");
882 assert_eq!(variant.as_deref(), Some("mse-coder"));
883 assert_eq!(tools, &["Read".to_string(), "Write".to_string()]);
884 }
885 other => panic!("expected AttestationRequired, got {other:?}"),
886 }
887 let message = error.to_string();
890 assert!(message.contains("coder"), "message: {message}");
891 assert!(message.contains("mse-coder"), "message: {message}");
892 assert!(message.contains("Read"), "message: {message}");
893 assert!(bound[0].attestation.is_none());
894 }
895}