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    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    // Subject for ABAC. When app auth is not required the auth resolver
217    // returns None without looking at credentials; still identify callers
218    // that *did* present a valid token so per-user grants apply on
219    // auth-optional consoles. Invalid tokens fall back to anonymous.
220    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
262/// Feed the controller's attribute cache from a roster snapshot so
263/// label/role selectors resolve on surfaces that only carry an identity
264/// string (timeline frames, send requests, SSE streams).
265fn 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    // Members carry the richer attribute set; record them last so they win.
279    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    // Module IDs double as module-agent sidebar rows when no roster
314    // member survives filtering; gate them like any other agent so a
315    // fully-denied caller is not shown the module fallback.
316    snapshot
317        .loaded_modules
318        .retain(|module_id| view.allows_agent(crate::access::ACTION_AGENT_VIEW, module_id));
319}
320
321/// Intersect the projected experience with the caller's grants and append
322/// the `access` section the console UI consumes.
323fn 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    // P0 fix: Build sidebar from the full mob roster (members) when a mob
476    // runtime is present, so multi-instance profiles (e.g. 5 profiles → 15
477    // agents) enumerate every individual agent, not just profile-level IDs.
478    // Fall back to loaded_modules for module-only runtimes.
479    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    // P3: Build per-profile capability hints from roster data.
650    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; // instance_count
657            // addressable = all instances addressable
658            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            // has_wiring = any instance wired
665            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                // Module-only runtime: topology nodes are loaded module IDs.
834                serde_json::json!({
835                    "nodes": &live_snapshot.loaded_modules,
836                    "node_count": live_snapshot.loaded_modules.len(),
837                })
838            } else {
839                // Mob runtime: identity-native topology from members.
840                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    // Check query-param auth_token (also used as the bearer-header injection
1072    // point by the HTTP handler — see console_json_handler).
1073    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
1079/// Extract a bearer token from an `Authorization: Bearer <token>` header value.
1080pub 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
1085/// Validate a bearer token against the console's trusted OIDC config AND
1086/// the email allowlist / provider policy (via `enforce_console_route_access`).
1087/// Returns `true` only if the token is valid AND the caller is authorized.
1088pub 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}