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