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