diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 2a13db47bb..1c45d9966e 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -67,8 +67,8 @@ pub(super) async fn prepare_fresh_startup_config( } }; let defaults = crate::config_update::read_effective_config_with_layers( - app_server.request_handle(), - defaults_cwd, + &app_server.request_handle(), + defaults_cwd.display().to_string(), ) .await?; super::new_session::overlay_new_session_defaults( diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index 70a23df860..25e35f1425 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -439,15 +439,15 @@ pub(crate) struct EffectiveConfig { } pub(crate) async fn read_effective_config_with_layers( - request_handle: AppServerRequestHandle, - cwd: &Path, + request_handle: &AppServerRequestHandle, + cwd: String, ) -> Result { request_handle .request_typed(ClientRequest::ConfigRead { request_id: RequestId::String(format!("tui-config-read-{}", Uuid::new_v4())), params: ConfigReadParams { include_layers: true, - cwd: Some(cwd.display().to_string()), + cwd: Some(cwd), }, }) .await @@ -459,7 +459,7 @@ pub(crate) async fn read_effective_config_if_supported( request_handle: AppServerRequestHandle, cwd: &Path, ) -> Result> { - match read_effective_config_with_layers(request_handle, cwd).await { + match read_effective_config_with_layers(&request_handle, cwd.display().to_string()).await { Ok(response) => Ok(Some(response)), Err(err) if matches!( diff --git a/codex-rs/tui/src/statusline_provider.rs b/codex-rs/tui/src/statusline_provider.rs index 1af7b9634f..7ebd18293c 100644 --- a/codex-rs/tui/src/statusline_provider.rs +++ b/codex-rs/tui/src/statusline_provider.rs @@ -1,10 +1,28 @@ -//! MCP server identity and inventory for statusline providers. +//! Resolve globally advertised statusline tools and read them on the displayed thread. +mod descriptor; #[cfg_attr(not(test), expect(dead_code, reason = "staged provider discovery"))] mod inventory; #[cfg(test)] mod test_support; +use crate::config_update::read_effective_config_with_layers; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::McpResourceReadParams; +use codex_app_server_protocol::McpResourceReadResponse; +use codex_app_server_protocol::McpServerConnectionStatus; +use codex_app_server_protocol::RequestId; +use codex_protocol::ThreadId; +use codex_utils_path_uri::PathUri; +use color_eyre::Result; +use color_eyre::eyre::OptionExt as _; +use color_eyre::eyre::bail; +pub(crate) use descriptor::StatuslineProvider; +use serde_json::Value; +use std::collections::HashMap; +use uuid::Uuid; + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct McpServerName(String); @@ -19,3 +37,159 @@ impl From<&str> for McpServerName { Self(name.to_owned()) } } + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct McpToolName(String); + +impl From<&str> for McpToolName { + fn from(name: &str) -> Self { + Self(name.to_owned()) + } +} + +/// The saved identity is independent of the latest display title and resource URI. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ProviderId { + pub(crate) server: McpServerName, + pub(crate) tool: McpToolName, +} + +#[cfg_attr(not(test), expect(dead_code, reason = "staged provider choice"))] +pub(crate) enum ProviderSelection<'a> { + Saved(&'a ProviderId), + /// A provider advertised during the current picker refresh. + Discovered(&'a StatuslineProvider), +} + +pub(crate) struct ResolvedStatuslineProvider { + client: AppServerRequestHandle, + thread: ThreadId, + provider: StatuslineProvider, +} + +#[cfg_attr(not(test), expect(dead_code, reason = "staged resource reader"))] +impl ResolvedStatuslineProvider { + pub(crate) async fn lookup( + client: &AppServerRequestHandle, + thread: ThreadId, + cwd: &PathUri, + selection: ProviderSelection<'_>, + ) -> Result> { + let config = + read_effective_config_with_layers(client, cwd.inferred_native_path_string()).await?; + let layers = config + .layers + .ok_or_eyre("config/read omitted the requested layers")?; + let project_sets = |section: &str, key: &str| { + let definitions: Vec<_> = layers + .iter() + .filter(|layer| layer["disabledReason"].is_null()) + .filter_map(|layer| { + Some(( + layer["name"]["type"] == "project", + layer["config"][section].get(key)?, + )) + }) + .collect(); + has_project_field(&definitions) + }; + let (id, advertisement) = match selection { + ProviderSelection::Saved(id) => (id, None), + ProviderSelection::Discovered(provider) => (&provider.id, Some(provider)), + }; + if project_sets("mcp_servers", id.server.as_str()) { + return Ok(None); + } + let owner = if let Some(provider) = advertisement { + provider.plugin_id.clone() + } else { + let Some(global) = inventory::server(client, &id.server, /*thread*/ None).await? else { + return Ok(None); + }; + if global.provider(&id.tool)?.is_none() { + return Ok(None); + } + global.plugin_id + }; + if let Some(plugin) = &owner + && (project_sets("plugins", &plugin.as_key()) + || project_sets("marketplaces", &plugin.marketplace_name)) + { + return Ok(None); + } + let Some(live) = inventory::server(client, &id.server, Some(thread)).await? else { + return Ok(None); + }; + if live.plugin_id != owner { + return Ok(None); + } + let provider = live.provider(&id.tool)?; + if live.runtime_status != Some(McpServerConnectionStatus::Connected) { + bail!("MCP server {} is not connected", id.server.as_str()); + } + Ok(provider.map(|provider| Self { + client: client.clone(), + thread, + provider, + })) + } + + pub(crate) fn provider(&self) -> &StatuslineProvider { + &self.provider + } + + /// The host routes by name. A reload can replace that name between lookup and read. + /// A thread/start override can also replace a configured command before lookup. Both + /// registrations report the same name and no plugin ID. Same-owner plugin replacements + /// are indistinguishable for the same reason. + pub(crate) async fn read(&self, snapshot: Value) -> Result { + let provider = &self.provider; + Ok(self + .client + .request_typed(ClientRequest::McpResourceRead { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: McpResourceReadParams { + thread_id: Some(self.thread.to_string()), + server: provider.id.server.0.clone(), + uri: provider.uri.clone(), + connector_id: None, + origin_call_id: None, + target: None, + meta: Some(serde_json::Map::from_iter([( + "com.openai/codex-statusline-state".into(), + snapshot, + )])), + }, + }) + .await?) + } +} + +/// Config layers arrive highest first. A project field only matters if a higher layer +/// leaves it in the effective registration. Arrays and scalars are replaced as a whole. +fn has_project_field(definitions: &[(bool, &Value)]) -> bool { + let Some((&(project, first), rest)) = definitions.split_first() else { + return false; + }; + if !first.is_object() || rest.iter().all(|(origin, _)| *origin == project) { + return project; + } + let mut fields: HashMap<&str, Vec<_>> = HashMap::new(); + for &(project, value) in definitions { + let Some(table) = value.as_object() else { + break; + }; + for (key, value) in table { + fields.entry(key).or_default().push((project, value)); + } + } + if fields.is_empty() { + project + } else { + fields.values().any(|values| has_project_field(values)) + } +} + +#[cfg(test)] +#[path = "statusline_provider_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/statusline_provider/descriptor.rs b/codex-rs/tui/src/statusline_provider/descriptor.rs new file mode 100644 index 0000000000..211fa36e7e --- /dev/null +++ b/codex-rs/tui/src/statusline_provider/descriptor.rs @@ -0,0 +1,68 @@ +//! Interpret the statusline entrypoint advertised by one MCP tool. + +use super::McpServerName; +use super::McpToolName; +use super::ProviderId; +use codex_plugin::PluginId; +use codex_protocol::mcp::Tool; +use std::cell::Cell; +use url::SyntaxViolation; +use url::Url; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StatuslineProvider { + pub(crate) id: ProviderId, + pub(crate) title: String, + pub(crate) plugin_label: Option, + pub(crate) uri: String, + pub(super) plugin_id: Option, +} + +impl StatuslineProvider { + pub(super) fn from_tool( + server: &McpServerName, + plugin_id: Option<&PluginId>, + tool: &Tool, + ) -> Option { + let meta = tool.meta.as_ref()?; + let entries = meta.get("openai/ui")?.get("entrypoints")?.as_array()?; + let mut valid_uris = entries + .iter() + .filter(|entry| entry["type"] == "statusline") + .filter_map(|entry| entry["resourceUri"].as_str()) + .filter(|uri| { + // The resource read forwards the original spelling. Reject errors the URL parser recovers from. + let valid = Cell::new(true); + let violation = |issue| { + if issue != SyntaxViolation::EmbeddedCredentials { + valid.set(false); + } + }; + uri.starts_with("ui://") + && Url::options() + .syntax_violation_callback(Some(&violation)) + .parse(uri) + .is_ok() + && valid.get() + }); + let uri = valid_uris.next()?; + if valid_uris.next().is_some() { + tracing::warn!( + server = server.as_str(), + tool = tool.name.as_str(), + "ignoring MCP tool with multiple valid statusline entrypoints" + ); + return None; + } + Some(Self { + id: ProviderId { + server: server.clone(), + tool: McpToolName(tool.name.clone()), + }, + title: tool.title.clone().unwrap_or_else(|| tool.name.clone()), + plugin_label: plugin_id.map(PluginId::as_key), + uri: uri.to_owned(), + plugin_id: plugin_id.cloned(), + }) + } +} diff --git a/codex-rs/tui/src/statusline_provider/inventory.rs b/codex-rs/tui/src/statusline_provider/inventory.rs index 498ed9d251..6b0cf57436 100644 --- a/codex-rs/tui/src/statusline_provider/inventory.rs +++ b/codex-rs/tui/src/statusline_provider/inventory.rs @@ -1,6 +1,8 @@ //! Read MCP tool catalogs from the connected app server’s global inventory. use super::McpServerName; +use super::McpToolName; +use super::StatuslineProvider; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ListMcpServerStatusParams; @@ -10,9 +12,11 @@ use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::RequestId; use codex_plugin::PluginId; use codex_plugin::PluginIdError; +use codex_protocol::ThreadId; use codex_protocol::mcp::Tool; use color_eyre::Result; use color_eyre::eyre::bail; +use color_eyre::eyre::eyre; use futures::Stream; use futures::stream; use serde::Deserialize; @@ -49,6 +53,26 @@ impl TryFrom for StatuslineServer { } } +impl StatuslineServer { + fn catalog(&self) -> Result<&HashMap> { + self.tools.as_ref().map_err(|error| eyre!("{error}")) + } + pub(super) fn providers(&self) -> Result> { + Ok(self + .catalog()? + .values() + .filter_map(|tool| { + StatuslineProvider::from_tool(&self.name, self.plugin_id.as_ref(), tool) + }) + .collect()) + } + pub(super) fn provider(&self, name: &McpToolName) -> Result> { + Ok(self.catalog()?.get(&name.0).and_then(|tool| { + StatuslineProvider::from_tool(&self.name, self.plugin_id.as_ref(), tool) + })) + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct InventoryPage { @@ -119,11 +143,12 @@ fn local_server(value: Value) -> Result> { pub(super) async fn server( client: &AppServerRequestHandle, name: &McpServerName, + thread: Option, ) -> Result> { let mut page = read_page( client, ListMcpServerStatusParams { - thread_id: None, + thread_id: thread.map(|id| id.to_string()), server_name: Some(name.0.clone()), detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), cursor: None, diff --git a/codex-rs/tui/src/statusline_provider/inventory_tests.rs b/codex-rs/tui/src/statusline_provider/inventory_tests.rs index a94ecc5268..ec0419f7d8 100644 --- a/codex-rs/tui/src/statusline_provider/inventory_tests.rs +++ b/codex-rs/tui/src/statusline_provider/inventory_tests.rs @@ -173,7 +173,7 @@ async fn targeted_inventory_rejects_another_server() { let name = "calendar".into(); let other = row("other", vec![json!({"name":"footer","inputSchema":{}})]); let (result, ()) = tokio::join!( - server(&client, &name), + server(&client, &name, /*thread*/ None), reply( &mut commands, "mcpServerStatus/list", diff --git a/codex-rs/tui/src/statusline_provider_tests.rs b/codex-rs/tui/src/statusline_provider_tests.rs new file mode 100644 index 0000000000..4aad6fd529 --- /dev/null +++ b/codex-rs/tui/src/statusline_provider_tests.rs @@ -0,0 +1,266 @@ +#![cfg(test)] +//! Tool metadata identifies global picker choices. Selected resources use the displayed thread. + +use super::inventory::StatuslineServer; +use super::test_support::Requests; +use super::test_support::reply; +use super::test_support::row; +use super::test_support::test_client; +use super::*; +use codex_app_server_protocol::McpServerStatus; +use pretty_assertions::assert_eq; +use serde_json::json; + +fn server(raw: Value) -> StatuslineServer { + StatuslineServer::try_from(serde_json::from_value::(raw).unwrap()).unwrap() +} + +fn statusline(uri: &str) -> Value { + json!({"type":"statusline","resourceUri":uri}) +} + +fn tool(name: &str, entries: Value) -> Value { + json!({"name":name,"inputSchema":{"type":"object"},"_meta":{"openai/ui":{"entrypoints":entries}}}) +} + +#[test] +fn a_tool_contributes_exactly_one_valid_statusline_entry() { + let good = statusline("ui://demo/a%20b"); + let malformed = [ + Value::Null, + json!({"type":"statusline"}), + json!({"type":"statusline","resourceUri":12}), + statusline("https://example.test/footer"), + statusline("ui://["), + json!({"type":"desktop","resourceUri":"ui://demo/window"}), + statusline("ui://demo/ba\nd"), + statusline("ui://demo/%"), + ]; + for extra in &malformed { + let mut raw = row("demo", vec![tool("status", json!([extra, good, extra]))]); + raw["pluginId"] = json!("calendar@local"); + let row = server(raw); + let providers = row.providers().unwrap(); + let [provider] = providers.as_slice() else { + panic!("expected one statusline provider") + }; + assert_eq!( + (provider.uri.as_str(), provider.plugin_label.as_deref()), + ("ui://demo/a%20b", Some("calendar@local")) + ); + } + for entries in [ + json!(malformed), + json!([good, good]), + json!([good, statusline("ui://demo/other")]), + json!({}), + ] { + let row = server(row("demo", vec![tool("status", entries)])); + assert!(row.providers().unwrap().is_empty()); + } +} + +async fn scoped_config(commands: &mut Requests, cwd: &str, layers: &[Value]) { + reply( + commands, + "config/read", + json!({"includeLayers":true,"cwd":cwd}), + json!({"config":{},"layers":layers}), + ) + .await; +} + +async fn named(commands: &mut Requests, thread: Option, row: Value) { + reply( + commands, + "mcpServerStatus/list", + json!({"threadId":thread.map(|id| id.to_string()),"serverName":row["name"], + "detail":"toolsAndAuthOnly","cursor":null,"limit":null}), + json!({"data":[row],"nextCursor":null}), + ) + .await; +} + +#[tokio::test] +async fn picker_choice_reads_the_displayed_threads_uri_with_the_snapshot() -> Result<()> { + let thread = ThreadId::new(); + let cwd = PathUri::parse("file:///C:/workspace")?; + let listing = |uri: &str| row("calendar", vec![tool("next", json!([statusline(uri)]))]); + let known = server(listing("ui://calendar/global")) + .providers()? + .pop() + .unwrap(); + let snapshot = json!({"version":1,"thread":{"id":thread.to_string()}}); + let layers = [ + json!({"name":{"type":"sessionFlags"},"config":{"mcp_servers":{"calendar":{"command":"calendar"}}}}), + json!({"name":{"type":"project"},"config":{"mcp_servers":{"calendar":{"command":"project"}}}}), + json!({"name":{"type":"project"},"disabledReason":"untrusted", + "config":{"mcp_servers":{"calendar":{"args":["ignored"]}}}}), + ]; + let (client, mut requests) = test_client(); + let host = async { + scoped_config(&mut requests, r"C:\workspace", &layers).await; + let mut live = listing("ui://calendar/today"); + live["tools"]["next"]["title"] = json!("Next event"); + named(&mut requests, Some(thread), live).await; + reply( + &mut requests, + "mcpServer/resource/read", + json!({ + "threadId": thread.to_string(), + "server": "calendar", + "uri": "ui://calendar/today", + "connectorId": null, + "target": null, + "originCallId": null, + "_meta": {"com.openai/codex-statusline-state": snapshot}, + }), + json!({"contents": [], "originCallId": null}), + ) + .await; + }; + let read = async { + let provider = ResolvedStatuslineProvider::lookup( + &client, + thread, + &cwd, + ProviderSelection::Discovered(&known), + ) + .await? + .unwrap(); + assert_eq!(provider.provider().title, "Next event"); + provider.read(snapshot.clone()).await + }; + let (result, ()) = tokio::join!(read, host); + result?; + Ok(()) +} + +#[tokio::test] +async fn visible_project_registration_blocks_the_selected_provider() -> Result<()> { + let cwd = PathUri::parse("file:///workspace/b")?; + for (owner, session, project) in [ + ( + None, + json!({"mcp_servers":{"calendar":{"command":"calendar"}}}), + json!({"mcp_servers":{"calendar":{"command":"replacement","args":["from-project"]}}}), + ), + ( + Some("daily@local"), + json!({}), + json!({"plugins":{"daily@local":{"release_id":"replacement"}}}), + ), + ( + Some("daily@local"), + json!({}), + json!({"marketplaces":{"local":{"source_type":"local","source":"/replacement"}}}), + ), + ] { + let mut listing = row( + "calendar", + vec![tool("today", json!([statusline("ui://calendar/today")]))], + ); + listing["pluginId"] = json!(owner); + let known = server(listing).providers()?.pop().unwrap(); + let (client, mut requests) = test_client(); + let host = async move { + scoped_config( + &mut requests, + "/workspace/b", + &[ + json!({"name":{"type":"sessionFlags"},"config":session}), + json!({"name":{"type":"project"},"config":project}), + ], + ) + .await; + }; + let lookup = ResolvedStatuslineProvider::lookup( + &client, + ThreadId::new(), + &cwd, + ProviderSelection::Discovered(&known), + ); + let (provider, ()) = tokio::join!(lookup, host); + assert!(provider?.is_none()); + } + Ok(()) +} + +#[tokio::test] +async fn selecting_a_plugin_requires_the_globally_advertised_tool_and_the_connected_owner() +-> Result<()> { + let (client, mut commands) = test_client(); + let thread = ThreadId::new(); + let named_plugin = |name| { + let mut value = row( + "calendar", + vec![tool(name, json!([statusline("ui://calendar/status")]))], + ); + value["pluginId"] = json!("daily@local"); + value + }; + let global = named_plugin("status"); + let mut replacement = global.clone(); + replacement["pluginId"] = json!("project@local"); + replacement["runtimeStatus"] = json!("failed"); + let mut disconnected = global.clone(); + disconnected["runtimeStatus"] = json!("failed"); + let mut list_failure = disconnected.clone(); + list_failure["toolsError"] = json!("calendar catalog failed"); + let mut unverified = global.clone(); + unverified.as_object_mut().unwrap().remove("httpOrigin"); + let unavailable = [ + (named_plugin("other"), None), + (global.clone(), Some(replacement)), + (global.clone(), Some(named_plugin("renamed"))), + (global.clone(), Some(unverified)), + ]; + let rejected = unavailable.len(); + let mut live = global.clone(); + live["tools"]["status"]["_meta"]["openai/ui"]["entrypoints"] = + json!([statusline("ui://calendar/current")]); + let worker = tokio::spawn(async move { + for (global, live) in unavailable.into_iter().chain([ + (global.clone(), Some(disconnected)), + (global.clone(), Some(list_failure)), + (global, Some(live)), + ]) { + scoped_config(&mut commands, "/workspace/a", &[]).await; + named(&mut commands, /*thread*/ None, global).await; + if let Some(live) = live { + named(&mut commands, Some(thread), live).await; + } + } + reply( + &mut commands, + "mcpServer/resource/read", + json!({ + "threadId":thread.to_string(),"server":"calendar","uri":"ui://calendar/current", + "connectorId":null,"target":null,"originCallId":null, + "_meta":{"com.openai/codex-statusline-state":{"version":1}} + }), + json!({"contents":[],"originCallId":null}), + ) + .await; + }); + let cwd = PathUri::parse("file:///workspace/a")?; + let id = ProviderId { + server: "calendar".into(), + tool: "status".into(), + }; + let lookup = + || ResolvedStatuslineProvider::lookup(&client, thread, &cwd, ProviderSelection::Saved(&id)); + for _ in 0..rejected { + assert!(lookup().await?.is_none()); + } + for message in ["is not connected", "calendar catalog failed"] { + assert!( + lookup() + .await + .is_err_and(|e| e.to_string().contains(message)) + ); + } + lookup().await?.unwrap().read(json!({"version":1})).await?; + worker.await?; + Ok(()) +}