1use super::*;
4use crate::access::{AccessController, AccessResource, AccessView, AgentResourceAttributes};
5use crate::rpc::MOBKIT_CONTRACT_VERSION;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ConsoleMember {
15 pub agent_identity: String,
16 pub role: String,
17 pub state: String,
18 #[serde(default)]
19 pub model_capabilities: ConsoleModelCapabilities,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub runtime_mode: Option<String>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub session_id: Option<String>,
24 pub wired_to: Vec<String>,
25 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
26 pub labels: std::collections::BTreeMap<String, String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub progress: Option<serde_json::Value>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct ConsoleModelCapabilities {
38 #[serde(default)]
39 pub image_input: bool,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ConsoleRestJsonRequest {
44 pub method: String,
45 pub path: String,
46 pub auth: Option<ConsoleAccessRequest>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ConsoleRestJsonResponse {
51 pub status: u16,
52 pub body: Value,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ConsoleAgentLiveSnapshot {
57 pub agent_id: String,
58 pub member_id: String,
59 pub label: String,
60 pub kind: String,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub identity: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub role: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub state: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub session_id: Option<String>,
69 #[serde(default)]
70 pub model_capabilities: ConsoleModelCapabilities,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub response_phase: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub watched: Option<bool>,
75 #[serde(
76 default,
77 skip_serializing_if = "Option::is_none",
78 rename = "alertLevel"
79 )]
80 pub alert_level: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub degraded: Option<bool>,
83 #[serde(
84 default,
85 skip_serializing_if = "Option::is_none",
86 rename = "degradedReason"
87 )]
88 pub degraded_reason: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ConsoleLiveSnapshot {
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub runtime_id: Option<String>,
95 pub running: bool,
96 pub loaded_modules: Vec<String>,
97 #[serde(default)]
98 pub agents: Vec<ConsoleAgentLiveSnapshot>,
99 pub members: Vec<ConsoleMember>,
100 pub has_mob_runtime: bool,
101}
102
103fn console_member_console_identity(member: &ConsoleMember) -> &str {
104 member
105 .labels
106 .get("agent_identity")
107 .filter(|value| !value.trim().is_empty())
108 .map_or(member.agent_identity.as_str(), String::as_str)
109}
110
111impl ConsoleLiveSnapshot {
112 pub fn new(
113 runtime_id: Option<String>,
114 running: bool,
115 loaded_modules: Vec<String>,
116 agents: Vec<ConsoleAgentLiveSnapshot>,
117 members: Vec<ConsoleMember>,
118 has_mob_runtime: bool,
119 ) -> Self {
120 let mut seen = BTreeSet::new();
121 let mut deduped_modules = Vec::new();
122 for module_id in loaded_modules {
123 if seen.insert(module_id.clone()) {
124 deduped_modules.push(module_id);
125 }
126 }
127 let mut seen_agents = BTreeSet::new();
128 let mut deduped_agents = Vec::new();
129 for agent in agents {
130 if seen_agents.insert(agent.agent_id.clone()) {
131 deduped_agents.push(agent);
132 }
133 }
134 Self {
135 runtime_id,
136 running,
137 loaded_modules: deduped_modules,
138 agents: deduped_agents,
139 members,
140 has_mob_runtime,
141 }
142 }
143}
144
145pub fn handle_console_rest_json_route(
146 decisions: &RuntimeDecisionState,
147 request: &ConsoleRestJsonRequest,
148) -> ConsoleRestJsonResponse {
149 handle_console_rest_json_route_with_snapshot(decisions, request, None)
150}
151
152pub fn handle_console_rest_json_route_with_snapshot(
153 decisions: &RuntimeDecisionState,
154 request: &ConsoleRestJsonRequest,
155 live_snapshot: Option<&ConsoleLiveSnapshot>,
156) -> ConsoleRestJsonResponse {
157 handle_console_rest_json_route_with_snapshot_and_access(decisions, request, live_snapshot, None)
158}
159
160pub fn handle_console_rest_json_route_with_snapshot_and_access(
168 decisions: &RuntimeDecisionState,
169 request: &ConsoleRestJsonRequest,
170 live_snapshot: Option<&ConsoleLiveSnapshot>,
171 access: Option<&AccessController>,
172) -> ConsoleRestJsonResponse {
173 handle_console_rest_json_route_with_snapshot_access_and_memory(
174 decisions,
175 request,
176 live_snapshot,
177 access,
178 false,
179 )
180}
181
182pub fn handle_console_rest_json_route_with_snapshot_access_and_memory(
186 decisions: &RuntimeDecisionState,
187 request: &ConsoleRestJsonRequest,
188 live_snapshot: Option<&ConsoleLiveSnapshot>,
189 access: Option<&AccessController>,
190 memory_panel_available: bool,
191) -> ConsoleRestJsonResponse {
192 handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph(
193 decisions,
194 request,
195 live_snapshot,
196 access,
197 memory_panel_available,
198 false,
199 )
200}
201
202pub fn handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph(
207 decisions: &RuntimeDecisionState,
208 request: &ConsoleRestJsonRequest,
209 live_snapshot: Option<&ConsoleLiveSnapshot>,
210 access: Option<&AccessController>,
211 memory_panel_available: bool,
212 workgraph_available: bool,
213) -> ConsoleRestJsonResponse {
214 let (base_path, query_params) = split_path_and_query(&request.path);
215 if request.method != "GET"
216 || (base_path != CONSOLE_MODULES_ROUTE && base_path != CONSOLE_EXPERIENCE_ROUTE)
217 {
218 return ConsoleRestJsonResponse {
219 status: 404,
220 body: serde_json::json!({"error":"not_found"}),
221 };
222 }
223
224 let resolved_auth = match resolve_console_auth(decisions, request.auth.as_ref(), &query_params)
225 {
226 Ok(auth) => auth,
227 Err(error) => {
228 return ConsoleRestJsonResponse {
229 status: 401,
230 body: serde_json::json!({
231 "error":"unauthorized",
232 "reason": console_auth_error_reason(&error),
233 }),
234 };
235 }
236 };
237
238 match &resolved_auth {
239 Some(auth) => {
240 if let Err(error) =
241 enforce_console_route_access(&decisions.auth, &decisions.console, auth)
242 {
243 return ConsoleRestJsonResponse {
244 status: 401,
245 body: serde_json::json!({
246 "error":"unauthorized",
247 "reason": auth_error_reason(&error),
248 }),
249 };
250 }
251 }
252 None if decisions.console.require_app_auth => {
253 return ConsoleRestJsonResponse {
254 status: 401,
255 body: serde_json::json!({
256 "error":"unauthorized",
257 "reason":"missing_credentials",
258 }),
259 };
260 }
261 None => {}
262 }
263
264 let mut access_subject = resolved_auth.as_ref().map(|auth| auth.email.clone());
269 if access_subject.is_none()
270 && access.is_some()
271 && !decisions.console.require_app_auth
272 && let Some(token) = query_params.get("auth_token")
273 {
274 access_subject =
275 resolve_authorized_console_auth_from_token(decisions, token).map(|auth| auth.email);
276 }
277 let access_view =
278 access.map(|controller| controller.view_for_subject(access_subject.as_deref()));
279
280 let modules: Vec<String> = decisions
281 .modules
282 .iter()
283 .map(|module| module.id.clone())
284 .collect();
285 let mut live_snapshot = live_snapshot
286 .cloned()
287 .unwrap_or_else(|| default_console_live_snapshot(decisions));
288 if let Some(controller) = access {
289 prime_access_attributes(controller, &live_snapshot);
290 }
291 if let Some(view) = &access_view {
292 filter_snapshot_for_access(&mut live_snapshot, view);
293 }
294 let mut body = if base_path == CONSOLE_EXPERIENCE_ROUTE {
295 build_console_experience_contract(&modules, &live_snapshot, &decisions.console)
296 } else {
297 serde_json::json!({
298 "contract_version": MOBKIT_CONTRACT_VERSION,
299 "modules": modules
300 })
301 };
302 if base_path == CONSOLE_EXPERIENCE_ROUTE
303 && let Some(view) = &access_view
304 {
305 apply_access_to_experience(&mut body, view);
306 }
307 if base_path == CONSOLE_EXPERIENCE_ROUTE {
308 apply_memory_to_experience(&mut body, access_view.as_ref(), memory_panel_available);
309 apply_workgraph_to_experience(&mut body, access_view.as_ref(), workgraph_available);
310 }
311 ConsoleRestJsonResponse { status: 200, body }
312}
313
314fn apply_workgraph_to_experience(
319 body: &mut Value,
320 view: Option<&AccessView>,
321 workgraph_available: bool,
322) {
323 let (can_view, can_manage) = match view.filter(|view| view.enforced()) {
324 Some(view) => (
325 workgraph_available && view.allows(crate::access::ACTION_WORKGRAPH_VIEW),
326 workgraph_available && view.allows(crate::access::ACTION_WORKGRAPH_MANAGE),
327 ),
328 None => (workgraph_available, workgraph_available),
329 };
330 if let Some(object) = body.as_object_mut() {
331 object.insert(
332 "workgraph".to_string(),
333 serde_json::json!({
334 "available": workgraph_available,
335 "can_view": can_view,
336 "can_manage": can_manage,
337 }),
338 );
339 }
340}
341
342fn apply_memory_to_experience(
347 body: &mut Value,
348 view: Option<&AccessView>,
349 memory_panel_available: bool,
350) {
351 let (can_read, can_review_quarantine) = match view.filter(|view| view.enforced()) {
352 Some(view) => (
353 memory_panel_available
354 && (view.may_perform_anywhere(crate::access::ACTION_AGENT_MEMORY_READ)
355 || view.may_perform_anywhere(crate::access::ACTION_MOB_MEMORY_READ)
356 || view.may_perform_anywhere(crate::access::ACTION_OPERATOR_MEMORY_READ)),
357 memory_panel_available && view.allows(crate::access::ACTION_MEMORY_QUARANTINE_REVIEW),
358 ),
359 None => (memory_panel_available, memory_panel_available),
360 };
361 if let Some(object) = body.as_object_mut() {
362 object.insert(
363 "memory".to_string(),
364 serde_json::json!({
365 "available": memory_panel_available,
366 "can_read": can_read,
367 "can_review_quarantine": can_review_quarantine,
368 }),
369 );
370 }
371}
372
373fn prime_access_attributes(controller: &AccessController, snapshot: &ConsoleLiveSnapshot) {
377 for agent in &snapshot.agents {
378 let identity = agent
379 .identity
380 .clone()
381 .unwrap_or_else(|| agent.agent_id.clone());
382 controller.record_agent_attributes(AgentResourceAttributes {
383 identity,
384 agent_id: Some(agent.agent_id.clone()),
385 role: agent.role.clone(),
386 labels: std::collections::BTreeMap::new(),
387 });
388 }
389 for member in &snapshot.members {
391 controller.record_agent_attributes(AgentResourceAttributes {
392 identity: console_member_console_identity(member).to_string(),
393 agent_id: Some(member.agent_identity.clone()),
394 role: Some(member.role.clone()),
395 labels: member.labels.clone(),
396 });
397 }
398}
399
400fn member_access_resource(member: &ConsoleMember) -> AccessResource<'_> {
401 AccessResource {
402 identity: Some(console_member_console_identity(member)),
403 agent_id: Some(member.agent_identity.as_str()),
404 role: Some(member.role.as_str()),
405 labels: Some(&member.labels),
406 }
407}
408
409fn filter_snapshot_for_access(snapshot: &mut ConsoleLiveSnapshot, view: &AccessView) {
410 if !view.enforced() {
411 return;
412 }
413 snapshot.members.retain(|member| {
414 view.decide(
415 crate::access::ACTION_AGENT_VIEW,
416 &member_access_resource(member),
417 )
418 .is_allow()
419 });
420 snapshot.agents.retain(|agent| {
421 let identity = agent.identity.as_deref().unwrap_or(agent.agent_id.as_str());
422 view.allows_agent(crate::access::ACTION_AGENT_VIEW, identity)
423 });
424 snapshot
428 .loaded_modules
429 .retain(|module_id| view.allows_agent(crate::access::ACTION_AGENT_VIEW, module_id));
430}
431
432fn apply_access_to_experience(body: &mut Value, view: &AccessView) {
435 if view.enforced() {
436 let mut any_can_send = false;
437 let mut any_can_retire = false;
438 if let Some(agents) = body
439 .pointer_mut("/agent_sidebar/live_snapshot/agents")
440 .and_then(Value::as_array_mut)
441 {
442 for agent in agents {
443 let identity = agent
444 .get("identity")
445 .or_else(|| agent.get("member_id"))
446 .or_else(|| agent.get("agent_id"))
447 .and_then(Value::as_str)
448 .unwrap_or_default()
449 .to_string();
450 let can_send =
451 view.allows_agent(crate::access::ACTION_AGENT_SEND, identity.as_str());
452 let can_retire =
453 view.allows_agent(crate::access::ACTION_AGENT_RETIRE, identity.as_str());
454 let can_respawn =
455 view.allows_agent(crate::access::ACTION_AGENT_RESPAWN, identity.as_str());
456 if let Some(affordances) =
457 agent.get_mut("affordances").and_then(Value::as_object_mut)
458 {
459 intersect_bool(affordances, "can_send_message", can_send);
460 intersect_bool(affordances, "can_retire", can_retire);
461 intersect_bool(affordances, "can_respawn", can_respawn);
462 any_can_send |= affordances
463 .get("can_send_message")
464 .and_then(Value::as_bool)
465 .unwrap_or(false);
466 any_can_retire |= affordances
467 .get("can_retire")
468 .and_then(Value::as_bool)
469 .unwrap_or(false);
470 } else {
471 any_can_send |= can_send;
472 any_can_retire |= can_retire;
473 }
474 }
475 }
476 if let Some(capabilities) = body
477 .get_mut("runtime_capabilities")
478 .and_then(Value::as_object_mut)
479 {
480 intersect_bool(capabilities, "can_send_messages", any_can_send);
481 intersect_bool(capabilities, "can_retire_members", any_can_retire);
482 intersect_bool(
483 capabilities,
484 "can_spawn_members",
485 view.allows(crate::access::ACTION_AGENT_SPAWN),
486 );
487 intersect_bool(
488 capabilities,
489 "can_wire_members",
490 view.allows(crate::access::ACTION_RUNTIME_ADMIN),
491 );
492 }
493 }
494 if let Some(object) = body.as_object_mut() {
495 object.insert(
496 "access".to_string(),
497 serde_json::json!({
498 "available": true,
499 "enabled": view.enforced(),
500 "subject": view.subject(),
501 "groups": view.groups().iter().collect::<Vec<_>>(),
502 "can_administer": view.can_administer(),
503 }),
504 );
505 }
506}
507
508fn intersect_bool(object: &mut serde_json::Map<String, Value>, key: &str, allowed: bool) {
509 let current = object.get(key).and_then(Value::as_bool).unwrap_or(false);
510 object.insert(key.to_string(), Value::Bool(current && allowed));
511}
512
513fn default_console_live_snapshot(decisions: &RuntimeDecisionState) -> ConsoleLiveSnapshot {
514 let loaded_modules = decisions
515 .modules
516 .iter()
517 .map(|module| module.id.clone())
518 .collect::<Vec<_>>();
519 let agents = loaded_modules
520 .iter()
521 .map(|module_id| ConsoleAgentLiveSnapshot {
522 agent_id: module_id.clone(),
523 member_id: module_id.clone(),
524 label: module_id.clone(),
525 kind: "module_agent".to_string(),
526 identity: None,
527 role: None,
528 state: Some("idle".to_string()),
529 session_id: None,
530 model_capabilities: ConsoleModelCapabilities::default(),
531 response_phase: None,
532 watched: None,
533 alert_level: None,
534 degraded: None,
535 degraded_reason: None,
536 })
537 .collect::<Vec<_>>();
538 ConsoleLiveSnapshot::new(
539 None,
540 !decisions.modules.is_empty(),
541 loaded_modules,
542 agents,
543 Vec::new(),
544 false,
545 )
546}
547
548fn build_console_experience_contract(
549 modules: &[String],
550 live_snapshot: &ConsoleLiveSnapshot,
551 console_policy: &ConsolePolicy,
552) -> Value {
553 let console_config = &console_policy.ui;
554 let is_read_only = console_policy.read_only;
555 let is_aggregate_console = live_snapshot.runtime_id.as_deref() == Some("console-aggregator");
556 fn has_extended_agent_contract(agent: &ConsoleAgentLiveSnapshot) -> bool {
557 agent.role.is_some()
558 || agent.session_id.is_some()
559 || agent.model_capabilities != ConsoleModelCapabilities::default()
560 || agent.response_phase.is_some()
561 || agent.watched.is_some()
562 || agent.alert_level.is_some()
563 || agent.degraded.is_some()
564 || agent.degraded_reason.is_some()
565 || agent.kind != "module_agent"
566 || agent.member_id != agent.agent_id
567 || agent.label != agent.agent_id
568 }
569
570 let module_panels = modules
571 .iter()
572 .map(|module_id| {
573 serde_json::json!({
574 "panel_id": format!("module.{module_id}"),
575 "module_id": module_id,
576 "title": format!("{module_id} module"),
577 "route": format!("/console/modules/{module_id}"),
578 "capabilities": {
579 "can_render": true,
580 "can_subscribe_activity": true,
581 }
582 })
583 })
584 .collect::<Vec<_>>();
585
586 let has_roster_members = live_snapshot.has_mob_runtime && !live_snapshot.members.is_empty();
591 let sidebar_agents: Vec<Value> = if has_roster_members {
592 let mut sorted_members: Vec<&ConsoleMember> = live_snapshot.members.iter().collect();
593 sorted_members.sort_by(|a, b| a.agent_identity.cmp(&b.agent_identity));
594 sorted_members
595 .iter()
596 .map(|member| {
597 let console_identity = console_member_console_identity(member);
598 let label = member
599 .labels
600 .get("display_name")
601 .cloned()
602 .unwrap_or_else(|| member.agent_identity.clone());
603 let is_active = member.state == "active";
604 let addressable = is_active
605 && member
606 .labels
607 .get("addressable")
608 .map(|v| v != "false")
609 .unwrap_or(true);
610 let can_send_message = addressable && !is_read_only;
611 let watched = member
612 .labels
613 .get("console_watched")
614 .map(|value| value == "true");
615 let alert_level = member
616 .labels
617 .get("console_alert_level")
618 .filter(|value| matches!(value.as_str(), "elevated" | "critical"))
619 .cloned();
620 let degraded = member
621 .labels
622 .get("console_degraded")
623 .map(|value| value == "true");
624 let degraded_reason = member.labels.get("console_degraded_reason").cloned();
625 let singleton = member
626 .labels
627 .get("singleton")
628 .map(|v| v == "true")
629 .unwrap_or(false);
630 let group = member
631 .labels
632 .get("group")
633 .cloned()
634 .unwrap_or_else(|| member.role.clone());
635 serde_json::json!({
636 "agent_id": member.agent_identity,
637 "member_id": member.agent_identity,
638 "identity": console_identity,
639 "label": label,
640 "kind": "mob_agent",
641 "role": member.role,
642 "state": member.state,
643 "progress": member.progress,
644 "model_capabilities": member.model_capabilities,
645 "session_id": member.session_id,
646 "wired_to": member.wired_to,
647 "labels": member.labels,
648 "group": group,
649 "addressable": addressable,
650 "watched": watched,
651 "alertLevel": alert_level,
652 "degraded": degraded,
653 "degradedReason": degraded_reason,
654 "affordances": {
655 "addressable": addressable,
656 "can_send_message": can_send_message,
657 "can_retire": is_active && !is_read_only && !is_aggregate_console && !singleton,
658 "can_respawn": !is_read_only && !is_aggregate_console,
659 "runtime_mode": if is_aggregate_console { "console_aggregator" } else { "mob_agent" },
660 },
661 })
662 })
663 .collect()
664 } else {
665 live_snapshot
666 .loaded_modules
667 .iter()
668 .map(|module_id| {
669 serde_json::json!({
670 "agent_id": module_id,
671 "member_id": module_id,
672 "label": module_id,
673 "kind": "module_agent",
674 })
675 })
676 .collect()
677 };
678
679 let sidebar_agents: Vec<Value> = if has_roster_members {
680 sidebar_agents
681 } else if live_snapshot.agents.iter().any(has_extended_agent_contract) {
682 live_snapshot
683 .agents
684 .iter()
685 .map(|agent| {
686 let mut record = serde_json::Map::new();
687 record.insert(
688 "agent_id".to_string(),
689 Value::String(agent.agent_id.clone()),
690 );
691 record.insert(
692 "member_id".to_string(),
693 Value::String(agent.member_id.clone()),
694 );
695 record.insert("label".to_string(), Value::String(agent.label.clone()));
696 record.insert("kind".to_string(), Value::String(agent.kind.clone()));
697 if let Some(role) = &agent.role {
698 record.insert("role".to_string(), Value::String(role.clone()));
699 }
700 if let Some(state) = &agent.state {
701 record.insert("state".to_string(), Value::String(state.clone()));
702 }
703 if let Some(identity) = &agent.identity {
704 record.insert("identity".to_string(), Value::String(identity.clone()));
705 }
706 if let Some(session_id) = &agent.session_id {
707 record.insert("session_id".to_string(), Value::String(session_id.clone()));
708 }
709 record.insert(
710 "model_capabilities".to_string(),
711 serde_json::to_value(&agent.model_capabilities).unwrap_or(Value::Null),
712 );
713 if let Some(response_phase) = &agent.response_phase {
714 record.insert(
715 "response_phase".to_string(),
716 Value::String(response_phase.clone()),
717 );
718 }
719 if is_aggregate_console {
720 record.insert("addressable".to_string(), Value::Bool(true));
721 record.insert(
722 "affordances".to_string(),
723 serde_json::json!({
724 "addressable": true,
725 "can_send_message": !is_read_only,
726 "can_retire": false,
727 "can_respawn": false,
728 "runtime_mode": "console_aggregator",
729 }),
730 );
731 }
732 if let Some(watched) = agent.watched {
733 record.insert("watched".to_string(), Value::Bool(watched));
734 }
735 if let Some(alert_level) = &agent.alert_level {
736 record.insert("alertLevel".to_string(), Value::String(alert_level.clone()));
737 }
738 if let Some(degraded) = agent.degraded {
739 record.insert("degraded".to_string(), Value::Bool(degraded));
740 }
741 if let Some(degraded_reason) = &agent.degraded_reason {
742 record.insert(
743 "degradedReason".to_string(),
744 Value::String(degraded_reason.clone()),
745 );
746 }
747 Value::Object(record)
748 })
749 .collect()
750 } else {
751 sidebar_agents
752 };
753
754 let has_mob = live_snapshot.has_mob_runtime && !is_aggregate_console;
755 let can_send_messages = !is_read_only && (has_mob || is_aggregate_console);
756 let can_spawn_members = !is_read_only && has_mob && !is_aggregate_console;
757 let can_wire_members = !is_read_only && has_mob && !is_aggregate_console;
758 let can_retire_members = !is_read_only && has_mob && !is_aggregate_console;
759 let identity_status_rows = build_identity_status_rows(&sidebar_agents);
760
761 let profile_capabilities: BTreeMap<String, Value> = {
763 let mut profiles: BTreeMap<String, (usize, bool, bool)> = BTreeMap::new();
764 for member in &live_snapshot.members {
765 let entry = profiles
766 .entry(member.role.clone())
767 .or_insert((0, true, false));
768 entry.0 += 1; let member_addressable = member
771 .labels
772 .get("addressable")
773 .map(|v| v != "false")
774 .unwrap_or(true);
775 entry.1 = entry.1 && member_addressable;
776 entry.2 = entry.2 || !member.wired_to.is_empty();
778 }
779 profiles
780 .into_iter()
781 .map(|(profile, (count, addressable, has_wiring))| {
782 (
783 profile,
784 serde_json::json!({
785 "instance_count": count,
786 "addressable": addressable,
787 "has_wiring": has_wiring,
788 }),
789 )
790 })
791 .collect()
792 };
793
794 let console_title = console_config.title.as_deref().unwrap_or("Mob Console");
795 let mut body = serde_json::json!({
796 "contract_version": MOBKIT_CONTRACT_VERSION,
797 "runtime_id": live_snapshot.runtime_id,
798 "console_config": console_config,
799 "runtime_capabilities": {
800 "can_spawn_members": can_spawn_members,
801 "can_send_messages": can_send_messages,
802 "can_wire_members": can_wire_members,
803 "can_retire_members": can_retire_members,
804 "available_spawn_modes": if can_spawn_members {
805 vec!["module", "role"]
806 } else {
807 vec!["module"]
808 },
809 "profile_capabilities": profile_capabilities,
810 },
811 "base_panel": {
812 "panel_id": "console.home",
813 "title": console_title,
814 "route": CONSOLE_EXPERIENCE_ROUTE,
815 "capabilities": {
816 "can_render": true,
817 "surface": "console",
818 }
819 },
820 "module_panels": module_panels,
821 "agent_sidebar": {
822 "panel_id": "console.agent_sidebar",
823 "title": "Agents",
824 "schema_version": "1",
825 "refresh": {
826 "mode": "poll",
827 "interval_ms": 5000,
828 },
829 "source_method": if is_aggregate_console { "mobkit/console/list_identities" } else if has_mob { "mobkit/list_members" } else { "mobkit/status" },
830 "refresh_policy": {
831 "mode": "pull",
832 "poll_interval_ms": 5000,
833 },
834 "selection_contract": {
835 "selected_agent_id_field": "agent_id",
836 "selected_member_id_field": "member_id",
837 "emits_scope": "agent",
838 "supported_scopes": ["mob", "agent"],
839 },
840 "list_item_contract": {
841 "fields": ["agent_id", "member_id", "identity", "label", "kind", "role", "state", "progress", "model_capabilities", "response_phase", "wired_to", "labels", "group", "addressable", "affordances", "watched", "alertLevel", "degraded", "degradedReason"],
842 "agent_id_field": "agent_id",
843 "member_id_field": "member_id",
844 "group_by_field": "group",
845 "well_known_labels": {
846 "display_name": "human-readable label; overrides member_id in sidebar",
847 "addressable": "set \"false\" to hide send-message actions for internal agents",
848 "singleton": "set \"true\" to prevent retire (e.g. review, summarizer agents)",
849 "group": "sidebar group name; overrides profile-based grouping",
850 },
851 "refresh_projection": "source_method returns ConsoleMember rows (agent_identity, role, state, model_capabilities, wired_to, labels). Clients must project: agent_id=agent_identity, member_id=agent_identity, identity=labels.agent_identity||agent_identity, label=labels.display_name||agent_identity, group=labels.group||role, addressable=labels.addressable!='false', model_capabilities.image_input default false, affordances derived from labels.singleton and addressable.",
852 },
853 "live_snapshot": {
854 "agents": sidebar_agents,
855 }
856 },
857 "identity_status": {
858 "panel_id": "console.identity_status",
859 "title": "Identity Status",
860 "schema_version": "1",
861 "refresh": {
862 "mode": "poll",
863 "interval_ms": 5000,
864 },
865 "source_method": if is_aggregate_console { "mobkit/console/list_identities" } else if has_mob { "mobkit/list_members" } else { "mobkit/status" },
866 "rows": identity_status_rows,
867 },
868 "activity_feed": {
869 "panel_id": "console.activity_feed",
870 "title": "Activity",
871 "schema_version": "1",
872 "refresh": {
873 "mode": "stream",
874 "topic": "all_events",
875 "update_semantics": "append",
876 },
877 "transport": "sse",
878 "source_route": "/console/timeline/stream",
879 "request_contract": {
880 "last_event_id_header": "optional Last-Event-ID checkpoint from prior event_id",
881 },
882 "event_contract": {
883 "envelope_fields": ["event_id", "interaction_id", "identity", "event_type", "timestamp_ms", "data"],
884 "event_type_path": "event_type",
885 "frame_format": "id: <event_id>\\nevent: <event_type>\\ndata: <event_json>\\n\\n",
886 },
887 "keep_alive": {
888 "interval_ms": SSE_KEEP_ALIVE_INTERVAL_MS,
889 "event": SSE_KEEP_ALIVE_EVENT_NAME,
890 "comment_frame": SSE_KEEP_ALIVE_COMMENT_FRAME,
891 },
892 "filter_presets": [
893 { "id": "all", "label": "All" },
894 { "id": "watched-only", "label": "Watched only", "watchedOnly": true },
895 { "id": "critical", "label": "Critical", "alertLevels": ["critical"] }
896 ],
897 "active_preset_id": "all"
898 },
899 "chat_inspector": {
900 "panel_id": "console.chat_inspector",
901 "title": "Chat Inspector",
902 "schema_version": "1",
903 "refresh": {
904 "mode": "stream",
905 "topic": "selected_identity",
906 "update_semantics": "append",
907 },
908 "send_method": "mobkit/console/send",
909 "observe_route": "/console/timeline/stream",
910 "transport": "rpc+sse",
911 "request_contract": {
912 "identity": "required target identity",
913 "content": "required user text to send",
914 "origin": "required caller identifier for audit and routing",
915 },
916 "response_contract": {
917 "interaction_id": "per-turn console correlation token",
918 "identity": "echoed target identity",
919 },
920 "event_contract": {
921 "envelope_fields": ["event_id", "interaction_id", "identity", "event_type", "timestamp_ms", "data"],
922 "event_type_path": "event_type",
923 "interaction_id_field": "interaction_id",
924 }
925 },
926 "topology": {
927 "panel_id": "console.topology",
928 "title": "Topology",
929 "schema_version": "1",
930 "refresh": {
931 "mode": "poll",
932 "interval_ms": 5000,
933 },
934 "source_method": if is_aggregate_console { "mobkit/console/list_identities" } else { "mobkit/status" },
935 "route_method": if is_aggregate_console { Value::Null } else { serde_json::json!("mobkit/routing/routes/list") },
936 "refresh_policy": {
937 "mode": "pull",
938 "poll_interval_ms": 5000,
939 },
940 "graph_contract": {
941 "node_id_field": "identity",
942 "edge_fields": ["wired_to"],
943 },
944 "live_snapshot": if live_snapshot.members.is_empty() {
945 serde_json::json!({
947 "nodes": &live_snapshot.loaded_modules,
948 "node_count": live_snapshot.loaded_modules.len(),
949 })
950 } else {
951 serde_json::json!({
953 "nodes": live_snapshot.members.iter().map(|member| {
954 let addressable = member.state == "active" && member
955 .labels
956 .get("addressable")
957 .map(|value| value != "false")
958 .unwrap_or(true);
959 serde_json::json!({
960 "identity": member.agent_identity,
961 "label": member.labels.get("display_name").cloned().unwrap_or_else(|| member.agent_identity.clone()),
962 "role": member.role,
963 "state": member.state,
964 "wired_to": member.wired_to,
965 "addressable": addressable,
966 })
967 }).collect::<Vec<_>>(),
968 "node_count": live_snapshot.members.len(),
969 })
970 }
971 },
972 "health_overview": {
973 "panel_id": "console.health_overview",
974 "title": "Health",
975 "schema_version": "1",
976 "refresh": {
977 "mode": "poll",
978 "interval_ms": 5000,
979 },
980 "source_method": if is_aggregate_console { "mobkit/console/list_identities" } else { "mobkit/status" },
981 "activity_source_method": if is_aggregate_console { Value::Null } else { serde_json::json!(EVENTS_SUBSCRIBE_METHOD) },
982 "activity_source_route": if is_aggregate_console { serde_json::json!("/console/timeline/stream") } else { Value::Null },
983 "refresh_policy": {
984 "mode": "pull_and_stream",
985 "poll_interval_ms": 5000,
986 },
987 "status_contract": {
988 "running_field": "running",
989 "loaded_modules_field": "loaded_modules",
990 },
991 "live_snapshot": {
992 "running": live_snapshot.running,
993 "loaded_modules": &live_snapshot.loaded_modules,
994 "loaded_module_count": &live_snapshot.loaded_modules.len(),
995 "identities": identity_status_rows,
996 }
997 },
998 "flows": if is_aggregate_console {
999 serde_json::json!({
1000 "panel_id": "console.flows",
1001 "title": "Flows",
1002 "schema_version": "1",
1003 "available": false,
1004 "reason": "flow scheduling is not exposed by the console aggregator surface",
1005 })
1006 } else {
1007 serde_json::json!({
1008 "panel_id": "console.flows",
1009 "title": "Flows",
1010 "schema_version": "1",
1011 "refresh": {
1012 "mode": "poll",
1013 "interval_ms": 10000,
1014 },
1015 "evaluate_method": "mobkit/scheduling/evaluate",
1016 "dispatch_method": "mobkit/scheduling/dispatch",
1017 "refresh_policy": {
1018 "mode": "pull",
1019 "poll_interval_ms": 10000,
1020 },
1021 "request_contract": {
1022 "schedules": "caller-supplied array of ScheduleDefinition (schedule_id, interval|cron, timezone, enabled)",
1023 "tick_ms": "evaluation timestamp in epoch milliseconds",
1024 },
1025 "evaluate_response_contract": {
1026 "tick_ms": "u64 - echoed evaluation tick",
1027 "due_triggers": "array of ScheduleTrigger {schedule_id, interval, timezone, due_tick_ms}",
1028 },
1029 "dispatch_response_contract": {
1030 "tick_ms": "u64 - echoed dispatch tick",
1031 "due_count": "usize - number of triggers that were due",
1032 "dispatched": "array of ScheduleDispatch {claim_key, schedule_id, interval, timezone, due_tick_ms, tick_ms, event_id, supervisor_signal?, runtime_injection?, runtime_injection_error?}",
1033 "skipped_claims": "array of schedule_id strings skipped due to idempotent claim",
1034 },
1035 "note": "Flows require caller-supplied schedule definitions; the runtime does not persist a flow registry. Clients must maintain their own schedule configs."
1036 })
1037 },
1038 "session_history": if has_mob {
1039 serde_json::json!({
1040 "panel_id": "console.session_history",
1041 "title": "Session History",
1042 "schema_version": "1",
1043 "refresh": {
1044 "mode": "poll",
1045 "interval_ms": 5000,
1046 },
1047 "source_method": "mobkit/console/query_timeline",
1048 "transport": "rpc",
1049 "available": true,
1050 "request_contract": {
1051 "session_id": "required current session id for the identity/member",
1052 "offset": "optional message offset from the start of the transcript",
1053 "limit": "optional max messages to return",
1054 },
1055 "response_contract": {
1056 "success": "result is SessionHistoryPage {session_id, message_count, offset, limit, has_more, messages}",
1057 }
1058 })
1059 } else {
1060 serde_json::json!({
1061 "panel_id": "console.session_history",
1062 "title": "Session History",
1063 "schema_version": "1",
1064 "refresh": {
1065 "mode": "poll",
1066 "interval_ms": 5000,
1067 },
1068 "available": false,
1069 "reason": "session history is projected through the console timeline",
1070 })
1071 }
1072 });
1073 if let Some(fetch_timeout_ms) = console_policy.fetch_timeout_ms {
1074 body["console_policy"] = serde_json::json!({
1075 "fetch_timeout_ms": fetch_timeout_ms,
1076 });
1077 }
1078 if is_read_only && let Some(object) = body.as_object_mut() {
1079 let policy = object
1080 .entry("console_policy")
1081 .or_insert_with(|| serde_json::json!({}));
1082 policy["read_only"] = Value::Bool(true);
1083 }
1084 body
1085}
1086
1087fn build_identity_status_rows(sidebar_agents: &[Value]) -> Vec<Value> {
1088 sidebar_agents
1089 .iter()
1090 .map(|agent| {
1091 let identity = agent
1092 .get("identity")
1093 .and_then(Value::as_str)
1094 .or_else(|| agent.get("member_id").and_then(Value::as_str))
1095 .unwrap_or_default();
1096 let display_name = agent
1097 .get("label")
1098 .and_then(Value::as_str)
1099 .filter(|label| *label != identity);
1100 let role = agent.get("role").and_then(Value::as_str);
1101 let state = agent
1102 .get("state")
1103 .and_then(Value::as_str)
1104 .unwrap_or("unknown");
1105 let labels = agent
1106 .get("labels")
1107 .cloned()
1108 .unwrap_or_else(|| serde_json::json!({}));
1109 let addressability = if agent
1110 .get("addressable")
1111 .and_then(Value::as_bool)
1112 .unwrap_or(true)
1113 {
1114 "addressable"
1115 } else {
1116 "internal_only"
1117 };
1118 let mut row = serde_json::json!({
1119 "identity": identity,
1120 "state": state,
1121 "addressability": addressability,
1122 "labels": labels,
1123 });
1124 if let Some(display_name) = display_name {
1125 row["display_name"] = Value::String(display_name.to_string());
1126 }
1127 if let Some(role) = role {
1128 row["role"] = Value::String(role.to_string());
1129 }
1130 if let Some(generation) = agent.get("generation").and_then(Value::as_u64) {
1131 row["generation"] = Value::from(generation);
1132 }
1133 if let Some(checkpoint_version) =
1134 agent.get("checkpoint_version").and_then(Value::as_u64)
1135 {
1136 row["checkpoint_version"] = Value::from(checkpoint_version);
1137 }
1138 if let Some(lease_healthy) = agent.get("lease_healthy").and_then(Value::as_bool) {
1139 row["lease_healthy"] = Value::from(lease_healthy);
1140 }
1141 if let Some(progress) = agent.get("progress").filter(|value| !value.is_null()) {
1143 row["progress"] = progress.clone();
1144 }
1145 if let Some(response_phase) = agent.get("response_phase").and_then(Value::as_str) {
1146 row["response_phase"] = Value::String(response_phase.to_string());
1147 }
1148 if let Some(model_capabilities) = agent.get("model_capabilities") {
1149 row["model_capabilities"] = model_capabilities.clone();
1150 }
1151 if let Some(session_id) = agent.get("session_id").and_then(Value::as_str) {
1152 row["session_id"] = Value::String(session_id.to_string());
1153 }
1154 row
1155 })
1156 .collect()
1157}
1158
1159fn split_path_and_query(path: &str) -> (&str, BTreeMap<String, String>) {
1160 let (base, query) = path.split_once('?').unwrap_or((path, ""));
1161 let mut params = BTreeMap::new();
1162 for part in query.split('&') {
1163 if part.is_empty() {
1164 continue;
1165 }
1166 let (k, v) = part.split_once('=').unwrap_or((part, ""));
1167 if !k.is_empty() {
1168 params.insert(k.to_string(), v.to_string());
1169 }
1170 }
1171 (base, params)
1172}
1173
1174fn resolve_console_auth(
1175 decisions: &RuntimeDecisionState,
1176 explicit_auth: Option<&ConsoleAccessRequest>,
1177 query_params: &BTreeMap<String, String>,
1178) -> Result<Option<ConsoleAccessRequest>, ConsoleAuthResolutionError> {
1179 if let Some(auth) = explicit_auth {
1180 return Ok(Some(auth.clone()));
1181 }
1182
1183 if !decisions.console.require_app_auth {
1184 return Ok(None);
1185 }
1186
1187 match query_params.get("auth_token") {
1190 Some(token) => resolve_console_auth_from_token(decisions, token).map(Some),
1191 None => Ok(None),
1192 }
1193}
1194
1195pub fn extract_bearer_token_from_header(header_value: &str) -> Option<&str> {
1197 let token = header_value.strip_prefix("Bearer ")?;
1198 if token.is_empty() { None } else { Some(token) }
1199}
1200
1201pub fn validate_console_token(decisions: &RuntimeDecisionState, token: &str) -> bool {
1205 resolve_authorized_console_auth_from_token(decisions, token).is_some()
1206}
1207
1208pub(crate) fn resolve_authorized_console_auth_from_token(
1209 decisions: &RuntimeDecisionState,
1210 token: &str,
1211) -> Option<ConsoleAccessRequest> {
1212 let auth = resolve_console_auth_from_token(decisions, token).ok()?;
1213 crate::decisions::enforce_console_route_access(&decisions.auth, &decisions.console, &auth)
1214 .ok()?;
1215 Some(auth)
1216}
1217
1218fn resolve_console_auth_from_token(
1219 decisions: &RuntimeDecisionState,
1220 token: &str,
1221) -> Result<ConsoleAccessRequest, ConsoleAuthResolutionError> {
1222 if decisions.trusted_oidc.audience.trim().is_empty() {
1223 return Err(ConsoleAuthResolutionError::InvalidTrustedOidcConfig);
1224 }
1225
1226 let discovery = parse_oidc_discovery_json(&decisions.trusted_oidc.discovery_json)
1227 .map_err(|_| ConsoleAuthResolutionError::InvalidTrustedOidcConfig)?;
1228 let jwks = parse_jwks_json(&decisions.trusted_oidc.jwks_json)
1229 .map_err(|_| ConsoleAuthResolutionError::InvalidTrustedOidcConfig)?;
1230 let header =
1231 inspect_jwt_header(token).map_err(|_| ConsoleAuthResolutionError::InvalidTokenHeader)?;
1232
1233 if header.alg == "HS256"
1234 && !hs256_allowed_for_development_issuer(&discovery.issuer, &discovery.jwks_uri)
1235 {
1236 return Err(ConsoleAuthResolutionError::Hs256NotAllowed);
1237 }
1238
1239 let key = select_jwk_for_token(&jwks, header.kid.as_deref(), &header.alg)
1240 .map_err(|_| ConsoleAuthResolutionError::JwksKeyNotFound)?;
1241 let verification_key = build_jwt_verification_key(key, &header.alg)
1242 .map_err(|_| ConsoleAuthResolutionError::InvalidJwksKeyMaterial)?;
1243
1244 let now_epoch_seconds = SystemTime::now()
1245 .duration_since(UNIX_EPOCH)
1246 .unwrap_or_default()
1247 .as_secs();
1248 let claims = validate_jwt_with_verification_key(
1249 token,
1250 &verification_key,
1251 &JwtClaimsValidationConfig {
1252 issuer: Some(discovery.issuer),
1253 audience: Some(decisions.trusted_oidc.audience.clone()),
1254 now_epoch_seconds,
1255 leeway_seconds: 30,
1256 },
1257 )
1258 .map_err(|_| ConsoleAuthResolutionError::InvalidToken)?;
1259
1260 let principal = claims
1261 .email
1262 .or(claims.subject)
1263 .ok_or(ConsoleAuthResolutionError::MissingTokenIdentity)?;
1264 let provider =
1265 if claims.actor_type.as_deref() == Some("service") || principal.starts_with("svc:") {
1266 AuthProvider::ServiceIdentity
1267 } else {
1268 match claims.provider.as_deref() {
1269 Some("google_oauth") => AuthProvider::GoogleOAuth,
1270 Some("github_oauth") => AuthProvider::GitHubOAuth,
1271 Some("generic_oidc") => AuthProvider::GenericOidc,
1272 _ => AuthProvider::GenericOidc,
1273 }
1274 };
1275
1276 Ok(ConsoleAccessRequest {
1277 provider,
1278 email: principal,
1279 })
1280}
1281
1282#[derive(Debug, Clone, PartialEq, Eq)]
1283enum ConsoleAuthResolutionError {
1284 InvalidTrustedOidcConfig,
1285 InvalidTokenHeader,
1286 JwksKeyNotFound,
1287 InvalidJwksKeyMaterial,
1288 InvalidToken,
1289 MissingTokenIdentity,
1290 Hs256NotAllowed,
1291}
1292
1293fn console_auth_error_reason(error: &ConsoleAuthResolutionError) -> &'static str {
1294 match error {
1295 ConsoleAuthResolutionError::InvalidTrustedOidcConfig => "invalid_trusted_oidc_config",
1296 ConsoleAuthResolutionError::InvalidTokenHeader => "invalid_token_header",
1297 ConsoleAuthResolutionError::JwksKeyNotFound => "jwks_key_not_found",
1298 ConsoleAuthResolutionError::InvalidJwksKeyMaterial => "invalid_jwks_key_material",
1299 ConsoleAuthResolutionError::InvalidToken => "invalid_token",
1300 ConsoleAuthResolutionError::MissingTokenIdentity => "missing_token_identity",
1301 ConsoleAuthResolutionError::Hs256NotAllowed => "hs256_not_allowed",
1302 }
1303}
1304
1305fn hs256_allowed_for_development_issuer(issuer: &str, jwks_uri: &str) -> bool {
1306 match (extract_uri_host(issuer), extract_uri_host(jwks_uri)) {
1307 (Some(issuer_host), Some(jwks_host)) => {
1308 is_development_host(issuer_host) && is_development_host(jwks_host)
1309 }
1310 _ => false,
1311 }
1312}
1313
1314fn extract_uri_host(uri: &str) -> Option<&str> {
1315 let after_scheme = uri.split_once("://").map_or(uri, |(_, rest)| rest);
1316 let authority_with_path = after_scheme.split('/').next()?;
1317 let authority = authority_with_path
1318 .rsplit('@')
1319 .next()
1320 .unwrap_or(authority_with_path);
1321 if authority.is_empty() {
1322 return None;
1323 }
1324
1325 if let Some(stripped) = authority.strip_prefix('[') {
1326 let (ipv6_host, _) = stripped.split_once(']')?;
1327 return if ipv6_host.is_empty() {
1328 None
1329 } else {
1330 Some(ipv6_host)
1331 };
1332 }
1333
1334 let host = authority
1335 .split_once(':')
1336 .map_or(authority, |(hostname, _)| hostname);
1337 if host.is_empty() { None } else { Some(host) }
1338}
1339
1340fn is_development_host(host: &str) -> bool {
1341 let lowercase = host.to_ascii_lowercase();
1342 lowercase == "localhost"
1343 || lowercase == "127.0.0.1"
1344 || lowercase == "::1"
1345 || lowercase.ends_with(".localhost")
1346}
1347
1348fn auth_error_reason(error: &DecisionPolicyError) -> &'static str {
1349 match error {
1350 DecisionPolicyError::AuthProviderMismatch => "provider_mismatch",
1351 DecisionPolicyError::AuthProviderNotSupported => "provider_not_supported",
1352 DecisionPolicyError::EmailNotAllowlisted => "email_not_allowlisted",
1353 DecisionPolicyError::InvalidServiceIdentity => "invalid_service_identity",
1354 DecisionPolicyError::ServiceIdentityNotAllowlisted => "service_identity_not_allowlisted",
1355 _ => "policy_denied",
1356 }
1357}