Skip to main content

meerkat_mobkit/runtime/
console_ingress.rs

1//! Console ingress types and JSON request/response structures.
2
3use super::*;
4use crate::access::{AccessController, AccessResource, AccessView, AgentResourceAttributes};
5use crate::rpc::MOBKIT_CONTRACT_VERSION;
6
7/// Console-facing view of a single mob member.
8///
9/// Narrow projection of meerkat's roster enriched with cached diagnostics
10/// such as the current bridge session id when the console read model has one.
11/// Wire field names match the console JSON — `agent_identity`, `role`,
12/// `state` — so the admin UI reads them unchanged from the live snapshot.
13#[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
153/// Access-aware variant of [`handle_console_rest_json_route_with_snapshot`].
154///
155/// When an [`AccessController`] is supplied and enabled, the experience
156/// projection is filtered per authenticated principal: agents the caller
157/// may not view disappear from every section, per-agent affordances are
158/// intersected with the caller's grants, and an `access` section describing
159/// the caller's standing is appended for the console UI.
160pub 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
175/// Memory-panel-aware variant: `memory_panel_available` reports whether the
176/// runtime wired a Memory-panel store, so the experience can project the
177/// `memory` affordance section (§9.3) the console nav gate consumes.
178pub 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    // Subject for ABAC. When app auth is not required the auth resolver
236    // returns None without looking at credentials; still identify callers
237    // that *did* present a valid token so per-user grants apply on
238    // auth-optional consoles. Invalid tokens fall back to anonymous.
239    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
284/// Append the `memory` affordance section (§9.3) the console nav gate
285/// consumes. Coarse by design — `can_read` means "some memory read could
286/// succeed for this caller", mirroring the capabilities intersection;
287/// per-scope enforcement still applies to every panel RPC.
288fn 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
315/// Feed the controller's attribute cache from a roster snapshot so
316/// label/role selectors resolve on surfaces that only carry an identity
317/// string (timeline frames, send requests, SSE streams).
318fn 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    // Members carry the richer attribute set; record them last so they win.
332    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    // Module IDs double as module-agent sidebar rows when no roster
367    // member survives filtering; gate them like any other agent so a
368    // fully-denied caller is not shown the module fallback.
369    snapshot
370        .loaded_modules
371        .retain(|module_id| view.allows_agent(crate::access::ACTION_AGENT_VIEW, module_id));
372}
373
374/// Intersect the projected experience with the caller's grants and append
375/// the `access` section the console UI consumes.
376fn 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    // P0 fix: Build sidebar from the full mob roster (members) when a mob
529    // runtime is present, so multi-instance profiles (e.g. 5 profiles → 15
530    // agents) enumerate every individual agent, not just profile-level IDs.
531    // Fall back to loaded_modules for module-only runtimes.
532    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    // P3: Build per-profile capability hints from roster data.
703    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; // instance_count
710            // addressable = all instances addressable
711            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            // has_wiring = any instance wired
718            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                // Module-only runtime: topology nodes are loaded module IDs.
887                serde_json::json!({
888                    "nodes": &live_snapshot.loaded_modules,
889                    "node_count": live_snapshot.loaded_modules.len(),
890                })
891            } else {
892                // Mob runtime: identity-native topology from members.
893                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    // Check query-param auth_token (also used as the bearer-header injection
1125    // point by the HTTP handler — see console_json_handler).
1126    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
1132/// Extract a bearer token from an `Authorization: Bearer <token>` header value.
1133pub 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
1138/// Validate a bearer token against the console's trusted OIDC config AND
1139/// the email allowlist / provider policy (via `enforce_console_route_access`).
1140/// Returns `true` only if the token is valid AND the caller is authorized.
1141pub 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}