Skip to main content

mlua_swarm_server/
binding.rs

1//! Server-side implementation of the platform-neutral agent binding IF.
2//!
3//! Operator/MainAI manifests are looked up through logical role aliases.
4//! The provider returns untrusted receipts; validation and digest ownership
5//! remain in `mlua-swarm` Core.
6
7use crate::operator_ws::login::OperatorSessionEntry;
8use async_trait::async_trait;
9use mlua_swarm::{
10    AgentBindingProvider, BindOutcome, BindReceipt, BindRequest, BindingBackend,
11    BindingProviderError, ManifestBindingProvider, SessionId,
12};
13use std::collections::HashMap;
14use std::sync::Arc;
15use tokio::sync::Mutex;
16
17/// Binding provider backed by live Operator login records.
18pub struct OperatorSessionBindingProvider {
19    operator_sessions: Arc<Mutex<HashMap<SessionId, Arc<OperatorSessionEntry>>>>,
20    roles_to_sid: Arc<Mutex<HashMap<String, SessionId>>>,
21    /// Run-scoped session pin (`operator_sid` on the launch). `Some`
22    /// resolves manifests through this session and never consults
23    /// `roles_to_sid`; `None` is the process-global role lookup every
24    /// unpinned launch keeps using.
25    pinned_sid: Option<SessionId>,
26}
27
28impl OperatorSessionBindingProvider {
29    /// Bind the provider to the same session and role maps used by the
30    /// Operator REST/WebSocket login flow.
31    pub fn new(
32        operator_sessions: Arc<Mutex<HashMap<SessionId, Arc<OperatorSessionEntry>>>>,
33        roles_to_sid: Arc<Mutex<HashMap<String, SessionId>>>,
34    ) -> Self {
35        Self {
36            operator_sessions,
37            roles_to_sid,
38            pinned_sid: None,
39        }
40    }
41
42    /// Resolve the session this request attests through: the launch pin when
43    /// there is one, otherwise the session currently holding the request's
44    /// logical `target` role.
45    ///
46    /// The pinned arm reports `Unbound` (not an error) when the sid names no
47    /// live session — same tier as an unjoined role, and the launch's own
48    /// fail-loud line is the compiler's pinned spawner lookup, which rejects
49    /// the same condition outright.
50    async fn resolve_sid(&self, target: &str) -> Result<SessionId, String> {
51        match &self.pinned_sid {
52            Some(sid) => Ok(sid.clone()),
53            None => self
54                .roles_to_sid
55                .lock()
56                .await
57                .get(target)
58                .cloned()
59                .ok_or_else(|| format!("no Operator session owns binding target '{target}'")),
60        }
61    }
62
63    async fn bind_operator(
64        &self,
65        request: &BindRequest,
66    ) -> Result<BindOutcome, BindingProviderError> {
67        // A WS-backed agent with no logical binding target is a Blueprint
68        // declaration error, not a transient capability gap — keep it
69        // fail-closed rather than reporting `Unbound`.
70        let target = request.binding_target.as_deref().ok_or_else(|| {
71            BindingProviderError::Provider(format!(
72                "agent '{}' uses {:?} but declares no logical binding target",
73                request.agent, request.backend
74            ))
75        })?;
76        // (a) role not joined, (b) session gone, (c) no capability_manifest:
77        // the execution environment simply has nothing to attest yet. These
78        // are `Unbound` (observed, not fatal) — the non-strict launch runs
79        // DeclarationOnly and `strict_binding` decides whether they fail.
80        // (a)/(b) would fail again at real spawn-time routing anyway, so the
81        // binding stage does not pre-gate them.
82        let sid = match self.resolve_sid(target).await {
83            Ok(sid) => sid,
84            Err(reason) => {
85                return Ok(BindOutcome::Unbound {
86                    agent: request.agent.clone(),
87                    reason,
88                });
89            }
90        };
91        // How this run reached that sid — a launch pin or the role map.
92        // Named in every `Unbound` reason below so a driver reading a
93        // degradation entry can tell "my pinned session is gone" from "the
94        // role has no holder".
95        let via = match &self.pinned_sid {
96            Some(_) => format!("run-scoped pin (declared binding target '{target}')"),
97            None => format!("binding target '{target}'"),
98        };
99        let Some(entry) = self.operator_sessions.lock().await.get(&sid).cloned() else {
100            return Ok(BindOutcome::Unbound {
101                agent: request.agent.clone(),
102                reason: format!("Operator session '{sid}' for {via} disappeared"),
103            });
104        };
105        let Some(manifest) = entry.capability_manifest.as_ref() else {
106            return Ok(BindOutcome::Unbound {
107                agent: request.agent.clone(),
108                reason: format!(
109                    "Operator session '{sid}' for {via} supplied no capability_manifest"
110                ),
111            });
112        };
113        // (d) manifest lacks the requested variant surfaces as `Unbound` from
114        // the delegated `ManifestBindingProvider`; a duplicate variant stays
115        // an error there. Either way the single outcome is passed straight
116        // through.
117        ManifestBindingProvider::new(manifest.clone())
118            .bind(std::slice::from_ref(request))
119            .await?
120            .pop()
121            .ok_or_else(|| {
122                BindingProviderError::Provider(format!(
123                    "Operator provider '{}' returned no outcome for agent '{}'",
124                    manifest.provider_id, request.agent
125                ))
126            })
127    }
128}
129
130#[async_trait]
131impl AgentBindingProvider for OperatorSessionBindingProvider {
132    async fn bind(
133        &self,
134        requests: &[BindRequest],
135    ) -> Result<Vec<BindOutcome>, BindingProviderError> {
136        let mut outcomes = Vec::with_capacity(requests.len());
137        for request in requests {
138            let outcome = match request.backend {
139                BindingBackend::WsOperator | BindingBackend::WsClaudeCode => {
140                    self.bind_operator(request).await?
141                }
142                // In-process AgentBlock still echoes a receipt (Core
143                // validates it); the registry-backed real attest is a future
144                // follow-up.
145                BindingBackend::AgentBlockInProcess => BindOutcome::Bound {
146                    receipt: BindReceipt {
147                        agent: request.agent.clone(),
148                        request_digest: request.request_digest.clone(),
149                        provider_id: "mse-agent-block-in-process".to_string(),
150                        provider_revision: Some(env!("CARGO_PKG_VERSION").to_string()),
151                        resolved_model: request.requested_model.clone(),
152                        effective_tools: request.requested_tools.clone(),
153                        launch_variant: None,
154                        capability_snapshot_digest: None,
155                    },
156                },
157            };
158            outcomes.push(outcome);
159        }
160        Ok(outcomes)
161    }
162
163    /// Launch-scoped clone pinned to `session_id`, sharing the same live
164    /// session / role maps. An id that does not parse as a `SessionId`
165    /// cannot name a live session, so it yields `None` and the launch keeps
166    /// the unpinned provider — the pin's fail-loud line is the compiler's
167    /// pinned spawner lookup, which rejects that same id there.
168    fn pinned_to_session(&self, session_id: &str) -> Option<Arc<dyn AgentBindingProvider>> {
169        let sid = SessionId::parse(session_id.to_string()).ok()?;
170        Some(Arc::new(Self {
171            operator_sessions: self.operator_sessions.clone(),
172            roles_to_sid: self.roles_to_sid.clone(),
173            pinned_sid: Some(sid),
174        }))
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use mlua_swarm::{AgentProviderCapability, AgentProviderManifest, BindingDigest};
182
183    fn request() -> BindRequest {
184        BindRequest {
185            agent: "coder".to_string(),
186            request_digest: BindingDigest::sha256("request"),
187            backend: BindingBackend::WsOperator,
188            binding_target: Some("main-ai".to_string()),
189            requested_model: Some("sonnet".to_string()),
190            requested_tools: vec!["Read".to_string()],
191            launch_variant: Some("mse-coder".to_string()),
192        }
193    }
194
195    async fn provider(manifest: Option<AgentProviderManifest>) -> OperatorSessionBindingProvider {
196        let sid = SessionId::new();
197        let entry = Arc::new(OperatorSessionEntry {
198            sid: sid.clone(),
199            token: "token".to_string(),
200            roles: vec!["main-ai".to_string()],
201            capability_manifest: manifest,
202            joined_at_secs: 0,
203            ws_session: Mutex::new(None),
204        });
205        let sessions = Arc::new(Mutex::new(HashMap::from([(sid.clone(), entry)])));
206        let roles = Arc::new(Mutex::new(HashMap::from([("main-ai".to_string(), sid)])));
207        OperatorSessionBindingProvider::new(sessions, roles)
208    }
209
210    fn expect_bound(outcome: &BindOutcome) -> &mlua_swarm::BindReceipt {
211        match outcome {
212            BindOutcome::Bound { receipt } => receipt,
213            BindOutcome::Unbound { agent, reason } => {
214                panic!("expected Bound, got Unbound({agent}): {reason}")
215            }
216        }
217    }
218
219    #[tokio::test]
220    async fn operator_manifest_resolves_to_untrusted_receipt() {
221        let manifest = AgentProviderManifest {
222            provider_id: "main-ai-self-report".to_string(),
223            provider_revision: Some("1".to_string()),
224            capabilities: vec![AgentProviderCapability {
225                launch_variant: Some("mse-coder".to_string()),
226                resolved_model: Some("claude-sonnet-4".to_string()),
227                effective_tools: vec!["Read".to_string(), "Write".to_string()],
228                capability_snapshot_digest: Some(BindingDigest::sha256("manifest")),
229            }],
230        };
231        let outcomes = provider(Some(manifest))
232            .await
233            .bind(&[request()])
234            .await
235            .unwrap();
236        assert_eq!(outcomes.len(), 1);
237        let receipt = expect_bound(&outcomes[0]);
238        assert_eq!(receipt.provider_id, "main-ai-self-report");
239        assert_eq!(receipt.request_digest, request().request_digest);
240        assert_eq!(receipt.effective_tools, ["Read", "Write"]);
241    }
242
243    #[tokio::test]
244    async fn missing_manifest_reports_unbound() {
245        let outcomes = provider(None).await.bind(&[request()]).await.unwrap();
246        assert_eq!(outcomes.len(), 1);
247        match &outcomes[0] {
248            BindOutcome::Unbound { agent, reason } => {
249                assert_eq!(agent, "coder");
250                assert!(
251                    reason.contains("supplied no capability_manifest"),
252                    "reason: {reason}"
253                );
254            }
255            BindOutcome::Bound { .. } => panic!("expected Unbound when no manifest was submitted"),
256        }
257    }
258
259    #[tokio::test]
260    async fn missing_role_reports_unbound() {
261        // A provider whose role maps are empty: the requested binding target
262        // has not joined, so the agent is Unbound (not a hard error).
263        let sessions = Arc::new(Mutex::new(HashMap::new()));
264        let roles = Arc::new(Mutex::new(HashMap::new()));
265        let provider = OperatorSessionBindingProvider::new(sessions, roles);
266        let outcomes = provider.bind(&[request()]).await.unwrap();
267        match &outcomes[0] {
268            BindOutcome::Unbound { agent, reason } => {
269                assert_eq!(agent, "coder");
270                assert!(
271                    reason.contains("no Operator session owns"),
272                    "reason: {reason}"
273                );
274            }
275            BindOutcome::Bound { .. } => panic!("expected Unbound when the role has not joined"),
276        }
277    }
278
279    /// Two sessions, one role: the role is held by a session with no
280    /// manifest (another driver's, as far as this launch is concerned) while
281    /// the pinned session carries the manifest. The pinned provider must
282    /// attest through the pin — this is the strict_binding path staying
283    /// `Bound` under run-scoped pinning.
284    #[tokio::test]
285    async fn pinned_provider_attests_through_the_pin_not_the_role_holder() {
286        let manifest = AgentProviderManifest {
287            provider_id: "pinned-session".to_string(),
288            provider_revision: None,
289            capabilities: vec![AgentProviderCapability {
290                launch_variant: Some("mse-coder".to_string()),
291                resolved_model: Some("claude-sonnet-4".to_string()),
292                effective_tools: vec!["Read".to_string()],
293                capability_snapshot_digest: None,
294            }],
295        };
296        let role_holder_sid = SessionId::new();
297        let pinned_sid = SessionId::new();
298        let role_holder = Arc::new(OperatorSessionEntry {
299            sid: role_holder_sid.clone(),
300            token: "token".to_string(),
301            roles: vec!["main-ai".to_string()],
302            capability_manifest: None,
303            joined_at_secs: 0,
304            ws_session: Mutex::new(None),
305        });
306        let pinned = Arc::new(OperatorSessionEntry {
307            sid: pinned_sid.clone(),
308            token: "token".to_string(),
309            roles: Vec::new(),
310            capability_manifest: Some(manifest),
311            joined_at_secs: 0,
312            ws_session: Mutex::new(None),
313        });
314        let sessions = Arc::new(Mutex::new(HashMap::from([
315            (role_holder_sid.clone(), role_holder),
316            (pinned_sid.clone(), pinned),
317        ])));
318        let roles = Arc::new(Mutex::new(HashMap::from([(
319            "main-ai".to_string(),
320            role_holder_sid,
321        )])));
322        let provider = OperatorSessionBindingProvider::new(sessions, roles);
323
324        // Unpinned, the role's holder answers — and it has nothing to attest.
325        let outcomes = provider.bind(&[request()]).await.unwrap();
326        assert!(
327            matches!(&outcomes[0], BindOutcome::Unbound { .. }),
328            "the role's holder supplied no manifest, so the unpinned bind is Unbound"
329        );
330
331        // Pinned, the pinned session's manifest resolves the receipt. The
332        // agent keeps declaring the same logical role throughout.
333        let pinned_provider = provider
334            .pinned_to_session(pinned_sid.as_str())
335            .expect("a live sid must yield a pinned provider");
336        let outcomes = pinned_provider.bind(&[request()]).await.unwrap();
337        let receipt = expect_bound(&outcomes[0]);
338        assert_eq!(receipt.provider_id, "pinned-session");
339        assert_eq!(receipt.effective_tools, ["Read"]);
340    }
341
342    /// A pin naming no live session reports `Unbound` (the launch's loud
343    /// failure is the compiler's pinned spawner lookup), and the reason says
344    /// the pin — not the role — is what went missing.
345    #[tokio::test]
346    async fn pin_to_a_dead_session_reports_unbound_naming_the_pin() {
347        let provider = provider(None).await;
348        let gone = SessionId::new();
349        let pinned = provider
350            .pinned_to_session(gone.as_str())
351            .expect("pinned provider");
352        let outcomes = pinned.bind(&[request()]).await.unwrap();
353        match &outcomes[0] {
354            BindOutcome::Unbound { reason, .. } => {
355                assert!(
356                    reason.contains("run-scoped pin"),
357                    "reason must attribute the gap to the pin: {reason}"
358                );
359                assert!(
360                    reason.contains(gone.as_str()),
361                    "reason must name the pinned sid: {reason}"
362                );
363            }
364            BindOutcome::Bound { .. } => panic!("a pin to a dead session cannot be Bound"),
365        }
366    }
367
368    /// An id that is not a `SessionId` at all cannot name a live session:
369    /// no pinned provider is produced, so the launch keeps the unpinned one
370    /// and fails loudly at the compiler instead.
371    #[test]
372    fn unparseable_pin_yields_no_pinned_provider() {
373        let sessions = Arc::new(Mutex::new(HashMap::new()));
374        let roles = Arc::new(Mutex::new(HashMap::new()));
375        let provider = OperatorSessionBindingProvider::new(sessions, roles);
376        assert!(provider.pinned_to_session("not-a-session-id").is_none());
377    }
378
379    #[tokio::test]
380    async fn in_process_backend_is_attested_by_server_registry() {
381        let mut request = request();
382        request.backend = BindingBackend::AgentBlockInProcess;
383        request.binding_target = None;
384        request.launch_variant = None;
385        let outcomes = provider(None).await.bind(&[request.clone()]).await.unwrap();
386        let receipt = expect_bound(&outcomes[0]);
387        assert_eq!(receipt.provider_id, "mse-agent-block-in-process");
388        assert_eq!(receipt.effective_tools, request.requested_tools);
389    }
390}