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