diff --git a/crates/webcodex-core/src/authority.rs b/crates/webcodex-core/src/authority.rs index b753a1ac..2a724e86 100644 --- a/crates/webcodex-core/src/authority.rs +++ b/crates/webcodex-core/src/authority.rs @@ -159,6 +159,7 @@ pub enum OAuthBodyAwarePolicy { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolAuthorityPolicy { Require(&'static str), + RequireAny(&'static [&'static str]), RequireAll(&'static [&'static str]), Unknown, } diff --git a/crates/webcodex-core/src/plugin.rs b/crates/webcodex-core/src/plugin.rs index c2f2f997..190a6312 100644 --- a/crates/webcodex-core/src/plugin.rs +++ b/crates/webcodex-core/src/plugin.rs @@ -1,9 +1,9 @@ //! Bounded transport contracts for Runner-owned native Tool Plugins. //! //! Plugins speak the WebCodex Plugin Protocol over local stdio. This module is -//! deliberately independent from MCP: MCP is one adapter that may expose an -//! admitted startup catalog, while these types describe the native Runner -//! protocol and the closed Server <-> Runner gateway. +//! deliberately independent from MCP: concrete provider tools stay behind the +//! exact Runner gateway, while these types describe the native Runner protocol +//! and the closed Server <-> Runner gateway. use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -15,11 +15,9 @@ pub const PLUGIN_MAX_PROVIDERS: usize = 8; pub const PLUGIN_MAX_PROVIDER_ID_BYTES: usize = 64; pub const PLUGIN_MAX_PROVIDER_NAME_BYTES: usize = 128; pub const PLUGIN_MAX_TOOL_COUNT: usize = 128; -pub const PLUGIN_STARTUP_MAX_DIRECT_TOOLS: usize = 64; pub const PLUGIN_MAX_TOOL_NAME_BYTES: usize = 128; pub const PLUGIN_MAX_DESCRIPTION_BYTES: usize = 4 * 1024; pub const PLUGIN_MAX_SCHEMA_BYTES: usize = 64 * 1024; -pub const PLUGIN_STARTUP_MAX_SCHEMA_BYTES: usize = 32 * 1024; pub const PLUGIN_MAX_ARGUMENT_BYTES: usize = 64 * 1024; pub const PLUGIN_MAX_STRUCTURED_CONTENT_BYTES: usize = 128 * 1024; pub const PLUGIN_MAX_TEXT_CONTENT_BYTES: usize = 64 * 1024; @@ -29,20 +27,12 @@ pub const PLUGIN_MAX_MESSAGE_BYTES: usize = 1024 * 1024; pub const PLUGIN_MAX_JSON_DEPTH: usize = 16; pub const PLUGIN_MAX_JSON_NODES: usize = 4_096; pub const PLUGIN_MAX_JSON_STRING_BYTES: usize = 64 * 1024; -pub const PLUGIN_STARTUP_CATALOG_MAX_BYTES: usize = 256 * 1024; pub const PLUGIN_MAX_CHECK_DETAIL_BYTES: usize = 512; pub const PLUGIN_SCHEMA_MAX_PROPERTIES: usize = 128; pub const PLUGIN_SCHEMA_MAX_REQUIRED: usize = 128; pub const PLUGIN_SCHEMA_MAX_ENUM_VALUES: usize = 128; pub const PLUGIN_CATALOG_DIGEST_PREFIX: &str = "sha256:"; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PluginPlane { - Startup, - Effective, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] pub enum PluginGatewayRequest { @@ -52,12 +42,10 @@ pub enum PluginGatewayRequest { Reload, ProvidersList, ToolsList { - plane: PluginPlane, provider_id: String, provider_instance_id: String, }, ToolsCall { - plane: PluginPlane, provider_id: String, provider_instance_id: String, name: String, @@ -67,19 +55,17 @@ pub enum PluginGatewayRequest { } impl PluginGatewayRequest { - pub fn provider_binding(&self) -> Option<(&str, &str, PluginPlane)> { + pub fn provider_binding(&self) -> Option<(&str, &str)> { match self { Self::ToolsList { - plane, provider_id, provider_instance_id, } | Self::ToolsCall { - plane, provider_id, provider_instance_id, .. - } => Some((provider_id, provider_instance_id, *plane)), + } => Some((provider_id, provider_instance_id)), Self::Check { .. } | Self::Reload | Self::ProvidersList => None, } } @@ -188,39 +174,15 @@ pub struct PluginToolResult { pub is_error: bool, } -/// Frozen, sanitized startup registration entry. `catalog_*` describes the exact -/// immutable provider-instance catalog while `tools` contains only the bounded -/// direct-eligible subset. Execution configuration never crosses the Runner -/// boundary. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct StartupPluginProvider { - pub provider_id: String, - pub provider_instance_id: String, - pub name: String, - pub status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_code: Option, - #[serde(default)] - pub catalog_tool_count: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub catalog_digest: Option, - #[serde(default)] - pub tools: Vec, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginProviderView { pub provider_id: String, pub provider_instance_id: String, pub name: String, - pub plane: PluginPlane, pub status: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, - #[serde(default)] - pub startup_direct_tool_count: usize, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -262,18 +224,6 @@ pub struct PluginCheckDiagnostic { pub field: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PluginStartupToolShape { - pub eligible: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tool: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub field: Option, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginCheckReport { @@ -289,8 +239,6 @@ pub struct PluginCheckReport { pub tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub diagnostic: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub startup_tool_shape: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -301,12 +249,10 @@ pub enum PluginGatewayResponsePayload { }, Providers { providers: Vec, - first_class_restart_required: bool, }, Reloaded { providers: Vec, failures: Vec, - first_class_restart_required: bool, }, Tools { tools: Vec, @@ -1072,75 +1018,6 @@ pub fn validate_schema_observation(observation: &PluginSchemaObservation) -> Res Ok(()) } -pub fn validate_startup_tool(tool: &PluginTool) -> Result<(), String> { - validate_tools(std::slice::from_ref(tool))?; - validate_json_value( - &tool.input_schema, - PLUGIN_STARTUP_MAX_SCHEMA_BYTES, - "startup tool inputSchema", - )?; - if let Some(output) = tool.output_schema.as_ref() { - validate_json_value( - output, - PLUGIN_STARTUP_MAX_SCHEMA_BYTES, - "startup tool outputSchema", - )?; - } - if let Some(annotations) = tool.annotations.as_ref() { - validate_json_value( - annotations, - PLUGIN_STARTUP_MAX_SCHEMA_BYTES, - "startup tool annotations", - )?; - } - Ok(()) -} - -pub fn validate_startup_catalog(providers: &[StartupPluginProvider]) -> Result<(), String> { - if providers.len() > PLUGIN_MAX_PROVIDERS { - return Err("startup plugin provider count exceeds bound".to_string()); - } - let mut ids = HashSet::new(); - let mut instances = HashSet::new(); - let mut total_direct_tools = 0usize; - for provider in providers { - validate_provider_id(&provider.provider_id)?; - validate_provider_instance_id(&provider.provider_instance_id)?; - validate_provider_name(&provider.name)?; - if !ids.insert(provider.provider_id.as_str()) - || !instances.insert(provider.provider_instance_id.as_str()) - { - return Err("duplicate startup plugin provider identity".to_string()); - } - validate_status_atom(&provider.status, "startup plugin status")?; - if let Some(code) = provider.error_code.as_deref() { - validate_status_atom(code, "startup plugin error code")?; - } - if provider.catalog_tool_count > PLUGIN_MAX_TOOL_COUNT { - return Err("startup plugin provider catalog count exceeds bound".to_string()); - } - if let Some(digest) = provider.catalog_digest.as_deref() { - validate_plugin_catalog_digest(digest)?; - } - if provider.tools.len() > provider.catalog_tool_count { - return Err("startup direct subset exceeds provider catalog count".to_string()); - } - total_direct_tools = total_direct_tools.saturating_add(provider.tools.len()); - if total_direct_tools > PLUGIN_STARTUP_MAX_DIRECT_TOOLS { - return Err("startup direct tool count exceeds bound".to_string()); - } - for tool in &provider.tools { - validate_startup_tool(tool)?; - } - } - let encoded = serde_json::to_vec(providers) - .map_err(|_| "startup plugin catalog could not be serialized".to_string())?; - if encoded.len() > PLUGIN_STARTUP_CATALOG_MAX_BYTES { - return Err("startup plugin catalog exceeds aggregate byte bound".to_string()); - } - Ok(()) -} - pub fn validate_tool_result(result: &PluginToolResult) -> Result<(), String> { if result.content.len() > PLUGIN_MAX_CONTENT_ITEMS { return Err("tool result content item count exceeds bound".to_string()); @@ -1199,7 +1076,7 @@ pub fn validate_response(response: &PluginGatewayResponse) -> Result<(), String> if let Some(payload) = response.payload.as_ref() { match payload { PluginGatewayResponsePayload::Checked { report } => validate_check_report(report)?, - PluginGatewayResponsePayload::Providers { providers, .. } => { + PluginGatewayResponsePayload::Providers { providers } => { validate_provider_views(providers)? } PluginGatewayResponsePayload::Reloaded { @@ -1270,15 +1147,11 @@ pub fn validate_check_report(report: &PluginCheckReport) -> Result<(), String> { { return Err("ready Plugin check report has inconsistent status fields".to_string()); } - if report.startup_tool_shape.is_none() { - return Err("ready Plugin check report requires startup tool shape".to_string()); - } } else { if report.phase == PluginCheckPhase::Ready || report.code.is_none() { return Err("failed Plugin check report has inconsistent status fields".to_string()); } - if report.tool_count != 0 || !report.tools.is_empty() || report.startup_tool_shape.is_some() - { + if report.tool_count != 0 || !report.tools.is_empty() { return Err("failed Plugin check report must not retain tool inventory".to_string()); } if report.diagnostic.is_some() @@ -1315,35 +1188,9 @@ pub fn validate_check_report(report: &PluginCheckReport) -> Result<(), String> { if let Some(diagnostic) = report.diagnostic.as_ref() { validate_check_diagnostic(diagnostic)?; } - if let Some(shape) = report.startup_tool_shape.as_ref() { - validate_startup_tool_shape(shape)?; - } Ok(()) } -fn validate_startup_tool_shape(shape: &PluginStartupToolShape) -> Result<(), String> { - let tool = shape.tool.as_deref(); - let field = shape.field.as_deref(); - match (shape.eligible, shape.code.as_deref(), tool, field) { - (true, None, None, None) => Ok(()), - (false, Some("plugin_startup_tool_count_exceeded"), None, None) => Ok(()), - ( - false, - Some("plugin_startup_schema_too_large"), - Some(tool), - Some("inputSchema" | "outputSchema" | "annotations"), - ) => validate_tool_name(tool), - (false, Some("plugin_startup_tool_invalid"), Some(tool), field) => { - validate_tool_name(tool)?; - if let Some(field) = field { - validate_diagnostic_field(field)?; - } - Ok(()) - } - _ => Err("Plugin startup tool shape status is inconsistent".to_string()), - } -} - fn validate_check_diagnostic(diagnostic: &PluginCheckDiagnostic) -> Result<(), String> { let tool = diagnostic.tool.as_deref(); let field = diagnostic.field.as_deref(); @@ -1374,17 +1221,6 @@ fn validate_check_diagnostic(diagnostic: &PluginCheckDiagnostic) -> Result<(), S } } -fn validate_diagnostic_field(field: &str) -> Result<(), String> { - if matches!( - field, - "name" | "title" | "description" | "inputSchema" | "outputSchema" | "annotations" - ) { - Ok(()) - } else { - Err("Plugin diagnostic field is invalid".to_string()) - } -} - fn validate_provider_views(providers: &[PluginProviderView]) -> Result<(), String> { if providers.len() > PLUGIN_MAX_PROVIDERS { return Err("plugin provider count exceeds bound".to_string()); @@ -1398,9 +1234,6 @@ fn validate_provider_views(providers: &[PluginProviderView]) -> Result<(), Strin if let Some(code) = provider.error_code.as_deref() { validate_status_atom(code, "plugin provider error code")?; } - if provider.startup_direct_tool_count > PLUGIN_STARTUP_MAX_DIRECT_TOOLS { - return Err("startup direct tool count exceeds bound".to_string()); - } if !ids.insert(provider.provider_id.as_str()) { return Err("duplicate plugin provider id".to_string()); } @@ -1493,26 +1326,6 @@ mod tests { } } - fn startup_provider(instance_id: &str, tools: Vec) -> StartupPluginProvider { - let catalog = PluginCatalog::admit(tools.clone()).unwrap(); - StartupPluginProvider { - provider_id: "repo-tools".to_string(), - provider_instance_id: instance_id.to_string(), - name: "Repo Tools".to_string(), - status: "ready".to_string(), - error_code: None, - catalog_tool_count: catalog.tools().len(), - catalog_digest: Some(catalog.digest().to_string()), - tools, - } - } - - #[test] - fn startup_catalog_is_aggregate_bounded() { - let provider = startup_provider("instance_1", vec![tool()]); - validate_startup_catalog(&[provider]).unwrap(); - } - #[test] fn result_rejects_unsupported_content_at_deserialize_boundary() { let value = json!({"content":[{"type":"image","data":"x"}],"isError":false}); @@ -1522,7 +1335,6 @@ mod tests { #[test] fn request_requires_object_arguments_and_bounded_schema() { let request = PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Effective, provider_id: "repo-tools".to_string(), provider_instance_id: "instance_1".to_string(), name: "echo".to_string(), @@ -1551,12 +1363,6 @@ mod tests { title: Some("Echo".to_string()), }], diagnostic: None, - startup_tool_shape: Some(PluginStartupToolShape { - eligible: true, - code: None, - tool: None, - field: None, - }), }; validate_check_report(&report).unwrap(); validate_response(&PluginGatewayResponse::success( @@ -1586,7 +1392,6 @@ mod tests { &request, &PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { providers: vec![], - first_class_restart_required: false, }), ) .is_err()); @@ -1598,7 +1403,6 @@ mod tests { invalid.code = Some("plugin_tools_list_invalid".to_string()); invalid.tool_count = 0; invalid.tools.clear(); - invalid.startup_tool_shape = None; assert!(validate_check_report(&invalid).is_err()); } @@ -1729,25 +1533,16 @@ mod tests { } #[test] - fn full_startup_catalog_bound_is_separate_from_direct_subset_bound() { - let tools = (0..=PLUGIN_STARTUP_MAX_DIRECT_TOOLS) + fn provider_catalog_is_not_limited_by_removed_outer_mcp_direct_bound() { + let tools = (0..65) .map(|index| PluginTool { name: format!("tool_{index}"), ..tool() }) .collect::>(); let catalog = PluginCatalog::admit(tools).unwrap(); - let provider = StartupPluginProvider { - provider_id: "repo-tools".to_string(), - provider_instance_id: "instance_secondary".to_string(), - name: "Repo Tools".to_string(), - status: "ready_secondary".to_string(), - error_code: Some("first_class_catalog_too_large".to_string()), - catalog_tool_count: catalog.tools().len(), - catalog_digest: Some(catalog.digest().to_string()), - tools: Vec::new(), - }; - validate_startup_catalog(&[provider]).unwrap(); + assert_eq!(catalog.tools().len(), 65); + assert!(catalog.tools().iter().any(|tool| tool.name == "tool_64")); } #[test] @@ -1868,65 +1663,4 @@ mod tests { }) .is_ok()); } - - #[test] - fn startup_tool_shape_validation_rejects_unknown_or_inconsistent_diagnostics() { - for shape in [ - PluginStartupToolShape { - eligible: false, - code: Some("unknown_startup_reason".to_string()), - tool: None, - field: None, - }, - PluginStartupToolShape { - eligible: false, - code: Some("plugin_startup_tool_count_exceeded".to_string()), - tool: Some("echo".to_string()), - field: None, - }, - PluginStartupToolShape { - eligible: false, - code: Some("plugin_startup_schema_too_large".to_string()), - tool: Some("echo".to_string()), - field: Some("name".to_string()), - }, - ] { - assert!(validate_startup_tool_shape(&shape).is_err()); - } - - assert!(validate_startup_tool_shape(&PluginStartupToolShape { - eligible: false, - code: Some("plugin_startup_schema_too_large".to_string()), - tool: Some("echo".to_string()), - field: Some("inputSchema".to_string()), - }) - .is_ok()); - } - - #[test] - fn startup_catalog_rejects_total_tool_and_aggregate_byte_overflow() { - let too_many_tools = (0..=PLUGIN_STARTUP_MAX_DIRECT_TOOLS) - .map(|index| PluginTool { - name: format!("tool_{index}"), - ..tool() - }) - .collect::>(); - assert!( - validate_startup_catalog(&[startup_provider("instance_1", too_many_tools)]).is_err() - ); - - let aggregate_tools = (0..10) - .map(|index| PluginTool { - name: format!("large_{index}"), - input_schema: json!({ - "type": "object", - "description": "x".repeat(30 * 1024) - }), - ..tool() - }) - .collect::>(); - assert!( - validate_startup_catalog(&[startup_provider("instance_2", aggregate_tools)]).is_err() - ); - } } diff --git a/crates/webcodex-core/src/runner_protocol.rs b/crates/webcodex-core/src/runner_protocol.rs index 7553b834..9d5eba2a 100644 --- a/crates/webcodex-core/src/runner_protocol.rs +++ b/crates/webcodex-core/src/runner_protocol.rs @@ -358,7 +358,6 @@ pub const RUNNER_CONFIG_RESTART_REQUIRED_FIELDS: &[&str] = &[ "max_concurrent_jobs", "mcp_gateway", "owner", - "plugins", "poll_interval_ms", "project_registry_dir", "quic", @@ -705,8 +704,8 @@ pub struct RunnerCapabilities { #[serde(default, skip_serializing_if = "is_false")] pub coding_agent_runs: bool, /// Runner-owned native Tool Plugin lifecycle and typed Plugin gateway. - /// This remains useful even when the startup Plugin inventory is empty, - /// because `plugin_tool reload` can activate a dynamic provider later. + /// Registration carries capability only; provider/tool inventory remains + /// Runner-owned and is observed through the exact `plugin_tool` gateway. #[serde(default, skip_serializing_if = "is_false")] pub native_tool_plugins: bool, /// Runner-local managed SSH resource list/register/remove lifecycle. @@ -849,6 +848,8 @@ impl RunnerConfigOperationResponse { | "config_parse_failed" | "config_validation_failed" | "provider_config_invalid" + | "plugin_reload_failed" + | "plugin_reload_busy" | "config_generation_conflict" | "runner_unavailable" | "runner_replaced" @@ -1161,12 +1162,6 @@ pub struct RunnerPolicySummary { /// are never projected to the Server. #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp_gateway_providers: Option>, - /// Frozen, bounded, sanitized startup native Tool Plugin catalog. This is - /// registration-time first-class inventory only; executable paths, argv, - /// cwd, prepared environment, PIDs, stderr, and credentials never cross - /// the Runner boundary. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub plugin_providers: Option>, } impl Default for RunnerPolicySummary { @@ -1180,7 +1175,6 @@ impl Default for RunnerPolicySummary { shell_profiles: None, tool_providers: None, mcp_gateway_providers: None, - plugin_providers: None, } } } diff --git a/crates/webcodex-runner-registry/src/polling.rs b/crates/webcodex-runner-registry/src/polling.rs index 5ae18f99..3eb6fbee 100644 --- a/crates/webcodex-runner-registry/src/polling.rs +++ b/crates/webcodex-runner-registry/src/polling.rs @@ -14,7 +14,7 @@ use webcodex_core::mcp_gateway::{ }; use webcodex_core::plugin::{ validate_response_for_request as validate_plugin_gateway_response, PluginDispatchState, - PluginGatewayResponse, PluginPlane, + PluginGatewayResponse, }; use webcodex_core::runner_protocol::{ RunnerPersistentShellResultRequest, RunnerPollRequest, RunnerRequest, RunnerResultPayload, @@ -328,9 +328,10 @@ impl RunnerRegistry { )); }; let operation_binding = operation.provider_binding(); - let fence_binding = fence.provider.as_ref().map(|(provider, instance, plane)| { - (provider.as_str(), instance.as_str(), *plane) - }); + let fence_binding = fence + .provider + .as_ref() + .map(|(provider, instance)| (provider.as_str(), instance.as_str())); if operation_binding != fence_binding { return Some(( "stale_plugin_provider", @@ -358,26 +359,6 @@ impl RunnerRegistry { "native Plugin capability changed before dispatch".to_string(), )); } - if let Some((provider_id, provider_instance_id, PluginPlane::Startup)) = - operation_binding - { - let provider_is_current = runner - .policy - .as_ref() - .and_then(|policy| policy.plugin_providers.as_ref()) - .is_some_and(|providers| { - providers.iter().any(|provider| { - provider.provider_id == provider_id - && provider.provider_instance_id == provider_instance_id - }) - }); - if !provider_is_current { - return Some(( - "stale_plugin_provider", - "startup Plugin provider changed before dispatch".to_string(), - )); - } - } None }); if let Some((code, message)) = stale_plugin_error { diff --git a/crates/webcodex-runner-registry/src/requests.rs b/crates/webcodex-runner-registry/src/requests.rs index 2772d226..59d7a736 100644 --- a/crates/webcodex-runner-registry/src/requests.rs +++ b/crates/webcodex-runner-registry/src/requests.rs @@ -28,7 +28,7 @@ use webcodex_core::mcp_gateway::{ }; use webcodex_core::plugin::{ validate_request as validate_plugin_gateway_request, PluginDispatchState, PluginGatewayRequest, - PluginGatewayResponse, PluginPlane, + PluginGatewayResponse, }; use webcodex_core::runner_protocol::{ shell_computer_request_payload_max_bytes, PersistentShellRequest, PersistentShellResult, @@ -1630,8 +1630,8 @@ impl RunnerRegistry { } /// Enqueue one closed native Plugin operation for one exact live Runner. - /// Startup bindings are additionally fenced against immutable registration; - /// dynamic provider bindings remain exact Runner-owned identities. + /// Provider instance identity stays opaque Runner-owned state; the Runner + /// validates it against its current committed provider set before dispatch. pub async fn enqueue_plugin_gateway( &self, client_id: &str, @@ -1645,12 +1645,8 @@ impl RunnerRegistry { let provider_binding = operation .provider_binding() - .map(|(provider_id, provider_instance_id, plane)| { - ( - provider_id.to_string(), - provider_instance_id.to_string(), - plane, - ) + .map(|(provider_id, provider_instance_id)| { + (provider_id.to_string(), provider_instance_id.to_string()) }); let request_id = next_request_id(); let (tx, rx) = oneshot::channel(); @@ -1699,25 +1695,6 @@ impl RunnerRegistry { { return Err("exact Runner does not support native Tool Plugins".to_string()); } - if let Some((provider_id, provider_instance_id, PluginPlane::Startup)) = - provider_binding.as_ref() - { - let provider_is_current = runner - .policy - .as_ref() - .and_then(|policy| policy.plugin_providers.as_ref()) - .is_some_and(|providers| { - providers.iter().any(|provider| { - provider.provider_id == *provider_id - && provider.provider_instance_id == *provider_instance_id - }) - }); - if !provider_is_current { - return Err( - "stale startup Plugin provider identity; request was not started".to_string(), - ); - } - } if now_ts().saturating_sub(runner.last_seen) > super::RUNNER_ONLINE_WINDOW_SECS { return Err("exact Runner is offline; request was not started".to_string()); } diff --git a/crates/webcodex-runner-registry/src/runners.rs b/crates/webcodex-runner-registry/src/runners.rs index 6120dc7d..e955a711 100644 --- a/crates/webcodex-runner-registry/src/runners.rs +++ b/crates/webcodex-runner-registry/src/runners.rs @@ -29,7 +29,6 @@ use webcodex_core::coding_agent::{ CODING_AGENT_MAX_PROVIDER_NAME_BYTES, }; use webcodex_core::mcp_gateway::validate_providers; -use webcodex_core::plugin::validate_startup_catalog; use webcodex_core::runner_protocol::{ RunnerRegisterRequest, RunnerView, RUNNER_JOB_CONCURRENCY_MAX, RUNNER_JOB_CONCURRENCY_MIN, }; @@ -295,28 +294,6 @@ impl RunnerRegistry { validate_providers(providers) .map_err(|error| format!("invalid MCP gateway provider inventory: {error}"))?; } - let plugin_catalog = policy - .as_ref() - .and_then(|policy| policy.plugin_providers.as_ref()); - match ( - runner_features.supports(RunnerFeature::NativeToolPlugins), - plugin_catalog, - ) { - (true, Some(providers)) => validate_startup_catalog(providers) - .map_err(|error| format!("invalid native Plugin startup catalog: {error}"))?, - (true, None) => { - return Err( - "native_tool_plugins capability requires explicit startup Plugin catalog" - .to_string(), - ) - } - (false, Some(_)) => { - return Err( - "startup Plugin catalog requires native_tool_plugins capability".to_string(), - ) - } - (false, None) => {} - } let now = now_ts(); // Registration establishes liveness only. Project routing becomes authoritative // exclusively through the bounded paged inventory protocol. @@ -478,19 +455,6 @@ impl RunnerRegistry { "same runner instance cannot change MCP gateway provider inventory".to_string(), ); } - if inner.runners.get(&client_id).is_some_and(|existing| { - existing.runner_instance_id == runner_instance_id - && existing - .policy - .as_ref() - .and_then(|policy| policy.plugin_providers.as_ref()) - != record - .policy - .as_ref() - .and_then(|policy| policy.plugin_providers.as_ref()) - }) { - return Err("same runner instance cannot change startup Plugin catalog".to_string()); - } // A successful different-instance registration is an explicit lease // takeover. `last_seen` remains a passive liveness grace for temporary // network gaps; it must not turn a deliberate process restart into a diff --git a/crates/webcodex-runner-registry/src/state.rs b/crates/webcodex-runner-registry/src/state.rs index 8444c540..50b3d060 100644 --- a/crates/webcodex-runner-registry/src/state.rs +++ b/crates/webcodex-runner-registry/src/state.rs @@ -9,7 +9,7 @@ use webcodex_core::coding_agent::{ CodingAgentProvider, CodingAgentResponse, CodingAgentRunInventory, }; use webcodex_core::mcp_gateway::McpGatewayResponse; -use webcodex_core::plugin::{PluginGatewayResponse, PluginPlane}; +use webcodex_core::plugin::PluginGatewayResponse; use webcodex_core::runner_protocol::{ PersistentShellResult, RunnerBuildInfo, RunnerHostContext, RunnerPolicySummary, RunnerProjectSummary, RunnerRequest, RunnerView, ShellCommandExecutionState, ShellJobActivity, @@ -250,7 +250,7 @@ pub(super) struct CodingAgentDispatchFence { #[derive(Debug, Clone)] pub(super) struct PluginGatewayDispatchFence { pub(super) runner_instance_id: String, - pub(super) provider: Option<(String, String, PluginPlane)>, + pub(super) provider: Option<(String, String)>, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] diff --git a/crates/webcodex-runner-registry/src/tests/plugin_gateway.rs b/crates/webcodex-runner-registry/src/tests/plugin_gateway.rs index a22a903a..fd1981a6 100644 --- a/crates/webcodex-runner-registry/src/tests/plugin_gateway.rs +++ b/crates/webcodex-runner-registry/src/tests/plugin_gateway.rs @@ -1,13 +1,11 @@ use super::*; use crate::runner_protocol::{ - RunnerPolicySummary, RunnerPollRequest, RunnerRegisterRequest, RunnerResultPayload, - RunnerResultRequest, + RunnerPollRequest, RunnerRegisterRequest, RunnerResultPayload, RunnerResultRequest, }; use serde_json::json; use webcodex_core::plugin::{ PluginCheckPhase, PluginCheckReport, PluginDispatchState, PluginGatewayRequest, - PluginGatewayResponse, PluginGatewayResponsePayload, PluginPlane, PluginSchemaObservation, - PluginTool, StartupPluginProvider, + PluginGatewayResponse, PluginGatewayResponsePayload, PluginSchemaObservation, PluginTool, }; fn plugin_tool() -> PluginTool { @@ -24,24 +22,7 @@ fn plugin_tool() -> PluginTool { } } -fn startup_provider(instance_id: &str) -> StartupPluginProvider { - StartupPluginProvider { - provider_id: "repo-tools".to_string(), - provider_instance_id: instance_id.to_string(), - name: "Repo Tools".to_string(), - status: "ready".to_string(), - error_code: None, - catalog_tool_count: 1, - catalog_digest: None, - tools: vec![plugin_tool()], - } -} - -fn plugin_registration( - client_id: &str, - runner_instance_id: &str, - providers: Vec, -) -> RunnerRegisterRequest { +fn plugin_registration(client_id: &str, runner_instance_id: &str) -> RunnerRegisterRequest { let mut capabilities = RunnerCapabilities::default(); capabilities.native_tool_plugins = true; current_runner_registration(RunnerRegisterRequest { @@ -53,10 +34,7 @@ fn plugin_registration( hostname: None, capabilities, host_context: None, - policy: Some(RunnerPolicySummary { - plugin_providers: Some(providers), - ..Default::default() - }), + policy: Some(Default::default()), process_started_at: None, build: None, job_concurrency_limit: None, @@ -68,87 +46,42 @@ fn plugin_registration( async fn register_plugin_runner(registry: &RunnerRegistry) { registry - .register(plugin_registration( - "plugin-runner", - "runner-instance", - vec![startup_provider("startup-provider-instance")], - )) + .register(plugin_registration("plugin-runner", "runner-instance")) .await .unwrap(); } -fn startup_list(provider_instance_id: &str) -> PluginGatewayRequest { +fn provider_list(provider_instance_id: &str) -> PluginGatewayRequest { PluginGatewayRequest::ToolsList { - plane: PluginPlane::Startup, provider_id: "repo-tools".to_string(), provider_instance_id: provider_instance_id.to_string(), } } #[tokio::test] -async fn plugin_registration_catalog_is_exact_immutable_and_required_by_capability() { +async fn plugin_registration_needs_only_native_plugin_capability_not_provider_inventory() { let registry = RunnerRegistry::default(); registry - .register(plugin_registration( - "valid-plugin-runner", - "valid-instance", - vec![startup_provider("provider-instance")], - )) + .register(plugin_registration("valid-plugin-runner", "valid-instance")) .await .unwrap(); let view = registry .get_runner_view("valid-plugin-runner") .await .unwrap(); - assert_eq!( - view.policy - .as_ref() - .and_then(|policy| policy.plugin_providers.as_ref()) - .unwrap(), - &vec![startup_provider("provider-instance")] - ); - - let changed = registry - .register(plugin_registration( - "valid-plugin-runner", - "valid-instance", - vec![startup_provider("replacement-provider-instance")], - )) - .await - .unwrap_err(); - assert!( - changed.contains("cannot change startup Plugin catalog"), - "{changed}" - ); + assert!(view.capabilities.native_tool_plugins); - let mut missing_catalog = plugin_registration("missing", "missing-instance", vec![]); - missing_catalog.policy.as_mut().unwrap().plugin_providers = None; - assert!(registry - .register(missing_catalog) - .await - .unwrap_err() - .contains("requires explicit startup Plugin catalog")); - - let mut inventory_without_capability = plugin_registration("no-cap", "no-cap-instance", vec![]); - inventory_without_capability - .capabilities - .native_tool_plugins = false; - assert!(registry - .register(inventory_without_capability) + registry + .register(plugin_registration("valid-plugin-runner", "valid-instance")) .await - .unwrap_err() - .contains("requires native_tool_plugins capability")); + .unwrap(); } #[tokio::test] -async fn plugin_reload_can_target_exact_runner_with_zero_startup_plugins() { +async fn plugin_reload_can_target_exact_plugin_capable_runner_without_registration_catalog() { let registry = RunnerRegistry::default(); registry - .register(plugin_registration( - "empty-plugin-runner", - "empty-instance", - vec![], - )) + .register(plugin_registration("empty-plugin-runner", "empty-instance")) .await .unwrap(); let alice = auth_context(Some("alice"), false); @@ -192,7 +125,6 @@ async fn plugin_reload_can_target_exact_runner_with_zero_startup_plugins() { PluginGatewayResponsePayload::Reloaded { providers: vec![], failures: vec![], - first_class_restart_required: false, }, )), coding_agent: None, @@ -206,14 +138,10 @@ async fn plugin_reload_can_target_exact_runner_with_zero_startup_plugins() { } #[tokio::test] -async fn plugin_check_targets_exact_runner_without_requiring_startup_provider_identity() { +async fn plugin_check_targets_exact_runner_without_registration_provider_inventory() { let registry = RunnerRegistry::default(); registry - .register(plugin_registration( - "check-plugin-runner", - "check-instance", - vec![], - )) + .register(plugin_registration("check-plugin-runner", "check-instance")) .await .unwrap(); let alice = auth_context(Some("alice"), false); @@ -269,7 +197,6 @@ async fn plugin_check_targets_exact_runner_without_requiring_startup_provider_id tool_count: 0, tools: vec![], diagnostic: None, - startup_tool_shape: None, }, }, )), @@ -287,11 +214,7 @@ async fn plugin_check_targets_exact_runner_without_requiring_startup_provider_id async fn plugin_check_rejects_mismatched_provider_report_after_dispatch() { let registry = RunnerRegistry::default(); registry - .register(plugin_registration( - "check-plugin-runner", - "check-instance", - vec![], - )) + .register(plugin_registration("check-plugin-runner", "check-instance")) .await .unwrap(); let alice = auth_context(Some("alice"), false); @@ -343,7 +266,6 @@ async fn plugin_check_rejects_mismatched_provider_report_after_dispatch() { tool_count: 0, tools: vec![], diagnostic: None, - startup_tool_shape: None, }, }, )), @@ -361,7 +283,7 @@ async fn plugin_check_rejects_mismatched_provider_report_after_dispatch() { } #[tokio::test] -async fn plugin_enqueue_rechecks_owner_runner_and_startup_provider_identity() { +async fn plugin_enqueue_rechecks_owner_and_exact_runner_but_not_provider_inventory() { let registry = RunnerRegistry::default(); register_plugin_runner(®istry).await; let bob = auth_context(Some("bob"), false); @@ -369,7 +291,7 @@ async fn plugin_enqueue_rechecks_owner_runner_and_startup_provider_identity() { .enqueue_plugin_gateway( "plugin-runner", "runner-instance", - startup_list("startup-provider-instance"), + provider_list("runner-owned-provider-instance"), Some(&bob), "bob".to_string(), ) @@ -382,71 +304,79 @@ async fn plugin_enqueue_rechecks_owner_runner_and_startup_provider_identity() { .enqueue_plugin_gateway( "plugin-runner", "stale-runner-instance", - startup_list("startup-provider-instance"), + provider_list("runner-owned-provider-instance"), Some(&alice), "alice".to_string(), ) .await .unwrap_err() .contains("stale Runner")); - assert!(registry - .enqueue_plugin_gateway( - "plugin-runner", - "runner-instance", - startup_list("stale-provider-instance"), - Some(&alice), - "alice".to_string(), - ) - .await - .unwrap_err() - .contains("stale startup Plugin provider")); - let inner = registry.inner.lock().await; - assert!(inner.pending_by_id.is_empty()); - assert!(inner.plugin_gateway_waiters.is_empty()); -} - -#[tokio::test] -async fn plugin_dequeue_rechecks_exact_runner_and_startup_provider_before_dispatch() { - let registry = RunnerRegistry::default(); - register_plugin_runner(®istry).await; - let alice = auth_context(Some("alice"), false); let (_request_id, receiver) = registry .enqueue_plugin_gateway( "plugin-runner", "runner-instance", - startup_list("startup-provider-instance"), + provider_list("runner-owned-provider-instance"), Some(&alice), - "test".to_string(), + "alice".to_string(), ) .await .unwrap(); - { - let mut inner = registry.inner.lock().await; - inner - .runners - .get_mut("plugin-runner") - .unwrap() - .runner_instance_id = "replacement-runner-instance".to_string(); - } - assert!(registry + let request = registry .poll(RunnerPollRequest { client_id: "plugin-runner".to_string(), - runner_instance_id: "replacement-runner-instance".to_string(), + runner_instance_id: "runner-instance".to_string(), }) .await .unwrap() - .is_none()); + .unwrap(); + assert!(matches!( + request.plugin_gateway, + Some(PluginGatewayRequest::ToolsList { + ref provider_instance_id, + .. + }) if provider_instance_id == "runner-owned-provider-instance" + )); + registry + .complete(RunnerResultPayload { + result: RunnerResultRequest { + client_id: "plugin-runner".to_string(), + runner_instance_id: "runner-instance".to_string(), + request_id: request.request_id, + exit_code: None, + stdout: None, + stderr: None, + duration_ms: None, + error: None, + }, + command_execution_state: None, + mcp_gateway: None, + plugin_gateway: Some(PluginGatewayResponse::error( + PluginDispatchState::NotStarted, + "stale_plugin_provider", + "provider instance is not current", + )), + coding_agent: None, + }) + .await + .unwrap(); let response = receiver.await.unwrap(); assert_eq!(response.dispatch_state, PluginDispatchState::NotStarted); - assert_eq!(response.error.as_ref().unwrap().code, "stale_runner"); + assert_eq!( + response.error.as_ref().unwrap().code, + "stale_plugin_provider" + ); +} +#[tokio::test] +async fn plugin_dequeue_rechecks_exact_runner_before_dispatch() { let registry = RunnerRegistry::default(); register_plugin_runner(®istry).await; + let alice = auth_context(Some("alice"), false); let (_request_id, receiver) = registry .enqueue_plugin_gateway( "plugin-runner", "runner-instance", - startup_list("startup-provider-instance"), + provider_list("provider-instance"), Some(&alice), "test".to_string(), ) @@ -458,29 +388,23 @@ async fn plugin_dequeue_rechecks_exact_runner_and_startup_provider_before_dispat .runners .get_mut("plugin-runner") .unwrap() - .policy - .as_mut() - .unwrap() - .plugin_providers = Some(vec![startup_provider("replacement-provider-instance")]); + .runner_instance_id = "replacement-runner-instance".to_string(); } assert!(registry .poll(RunnerPollRequest { client_id: "plugin-runner".to_string(), - runner_instance_id: "runner-instance".to_string(), + runner_instance_id: "replacement-runner-instance".to_string(), }) .await .unwrap() .is_none()); let response = receiver.await.unwrap(); assert_eq!(response.dispatch_state, PluginDispatchState::NotStarted); - assert_eq!( - response.error.as_ref().unwrap().code, - "stale_plugin_provider" - ); + assert_eq!(response.error.as_ref().unwrap().code, "stale_runner"); } #[tokio::test] -async fn dynamic_effective_provider_is_runner_owned_but_exact_and_never_falls_back_to_startup() { +async fn provider_identity_is_runner_owned_and_forwarded_exactly() { let registry = RunnerRegistry::default(); register_plugin_runner(®istry).await; let alice = auth_context(Some("alice"), false); @@ -494,7 +418,6 @@ async fn dynamic_effective_provider_is_runner_owned_but_exact_and_never_falls_ba "plugin-runner", "runner-instance", PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Effective, provider_id: "repo-tools".to_string(), provider_instance_id: "dynamic-provider-instance".to_string(), name: "echo".to_string(), @@ -517,7 +440,6 @@ async fn dynamic_effective_provider_is_runner_owned_but_exact_and_never_falls_ba assert!(matches!( request.plugin_gateway, Some(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Effective, ref provider_instance_id, .. }) if provider_instance_id == "dynamic-provider-instance" diff --git a/crates/webcodex-runner-registry/src/tests/protocol.rs b/crates/webcodex-runner-registry/src/tests/protocol.rs index 56f0c037..08e2b61e 100644 --- a/crates/webcodex-runner-registry/src/tests/protocol.rs +++ b/crates/webcodex-runner-registry/src/tests/protocol.rs @@ -502,7 +502,6 @@ async fn runner_supports_recognizes_all_protocol_capability_names() { runner_config_control: true, }, policy: Some(crate::runner_protocol::RunnerPolicySummary { - plugin_providers: Some(Vec::new()), ..Default::default() }), }) diff --git a/crates/webcodex-runner-registry/src/validation.rs b/crates/webcodex-runner-registry/src/validation.rs index e5081a1f..33d421d8 100644 --- a/crates/webcodex-runner-registry/src/validation.rs +++ b/crates/webcodex-runner-registry/src/validation.rs @@ -31,7 +31,7 @@ pub(super) fn normalize_config_reload( ) -> Option { let mut status = status?; const RESULTS: &str = "not_attempted success partial failure unsupported"; - const ERRORS: &str = "config_read_failed config_parse_failed config_validation_failed provider_config_invalid reload_unsupported"; + const ERRORS: &str = "config_read_failed config_parse_failed config_validation_failed provider_config_invalid plugin_reload_failed reload_unsupported"; const ERROR_FIELDS: &str = "max_concurrent_jobs shell.max_persistent_shells shell.persistent_shell_idle_timeout_secs acp.max_concurrent_runs acp.permission_timeout_secs mcp.request_timeout_secs"; const ERROR_REASONS: &str = "out_of_range"; if status.generation == 0 @@ -841,5 +841,17 @@ mod provider_status_tests { ..RunnerConfigReloadStatus::default() })) .is_none()); + + let plugin_failure = normalize_config_reload(Some(RunnerConfigReloadStatus { + generation: 5, + last_reload_result: "failure".to_string(), + last_reload_error_code: Some("plugin_reload_failed".to_string()), + ..RunnerConfigReloadStatus::default() + })) + .expect("sanitized Plugin reload failure must remain observable"); + assert_eq!( + plugin_failure.last_reload_error_code.as_deref(), + Some("plugin_reload_failed") + ); } } diff --git a/crates/webcodex-runner/src/main.rs b/crates/webcodex-runner/src/main.rs index 5913ab8b..a6f8ed8c 100644 --- a/crates/webcodex-runner/src/main.rs +++ b/crates/webcodex-runner/src/main.rs @@ -2085,7 +2085,6 @@ fn build_register_request_with_provider_status( prepared_cache_count, tool_providers, runtime.mcp_gateway().provider_inventory(), - runtime.plugins().startup_catalog(), )), process_started_at: Some(process_started_at()), build: Some(runner_build_info()), @@ -2214,7 +2213,6 @@ fn register_policy_summary( prepared_cache_count: usize, tool_providers: runner_protocol::ToolProvidersStatus, mcp_gateway_providers: Vec, - plugin_providers: Vec, ) -> RunnerPolicySummary { RunnerPolicySummary { allow_raw_shell: cfg.policy.allow_raw_shell, @@ -2228,7 +2226,6 @@ fn register_policy_summary( )), tool_providers: Some(tool_providers), mcp_gateway_providers: Some(mcp_gateway_providers), - plugin_providers: Some(plugin_providers), } } diff --git a/crates/webcodex-runner/src/main_tests/config_reload.rs b/crates/webcodex-runner/src/main_tests/config_reload.rs index c3116490..44a503df 100644 --- a/crates/webcodex-runner/src/main_tests/config_reload.rs +++ b/crates/webcodex-runner/src/main_tests/config_reload.rs @@ -20,6 +20,7 @@ token = "test-token" client_id = "{client_id}" owner = "alice" poll_interval_ms = 1000 +plugins.request_timeout_secs = 30 {max_jobs} # Explicit project_registry_dir: load_config materializes the default from the # per-user config base, which depends on ambient HOME/USERPROFILE that other @@ -71,6 +72,7 @@ fn reload_field_classification_is_exhaustive_and_allowlisted() { let mut hot_only = startup.clone(); hot_only.policy.max_timeout_secs += 1; hot_only.shell.program = "bash".to_string(); + hot_only.plugins.request_timeout_secs += 1; hot_only.tool_providers.strategy = webcodex_runner::config::ToolProviderStrategy::ClaudeCodeThenNative; assert!(webcodex_runner::config::restart_required_fields(&startup, &hot_only).is_empty()); @@ -102,6 +104,40 @@ fn reload_field_classification_is_exhaustive_and_allowlisted() { ); } +#[test] +fn plugin_only_config_change_is_live_and_never_requires_runner_restart() { + let (_tmp, path, runtime) = reload_fixture(); + let candidate = reload_toml( + "oe", + None, + 60, + 1024, + "sh", + "native", + false, + "claude", + "project_search_generation_1", + ) + .replace( + "plugins.request_timeout_secs = 30", + "plugins.request_timeout_secs = 31", + ); + std::fs::write(&path, candidate).unwrap(); + + let checked = runtime.check_config(); + assert_eq!(checked.valid, Some(true)); + assert!(!checked.restart_required); + assert!(checked.restart_required_fields.is_empty()); + assert_eq!(checked.current_generation, Some(1)); + + let reloaded = runtime.reload_config(1); + assert_eq!(reloaded.valid, Some(true)); + assert!(!reloaded.restart_required); + assert!(reloaded.restart_required_fields.is_empty()); + assert_eq!(reloaded.current_generation, Some(2)); + assert_eq!(runtime.snapshot().generation, 2); +} + #[test] fn valid_reload_switches_one_complete_generation_and_preserves_old_snapshot() { let (_tmp, path, runtime) = reload_fixture(); diff --git a/crates/webcodex-runner/src/webcodex_runner/config.rs b/crates/webcodex-runner/src/webcodex_runner/config.rs index ae483c26..f4a3f0fb 100644 --- a/crates/webcodex-runner/src/webcodex_runner/config.rs +++ b/crates/webcodex-runner/src/webcodex_runner/config.rs @@ -101,9 +101,9 @@ pub(crate) struct RunnerConfig { /// built-in MCP gateway. The public config section is `[mcp]`. #[serde(default, rename = "mcp")] pub(crate) mcp_gateway: McpGatewayConfig, - /// Runner-local native stdio Tool Plugins. Startup admission is frozen for - /// this Runner process; explicit plugin reloads use the same section only - /// for the dynamic overlay. + /// Runner-local native stdio Tool Plugins. Startup initializes the first + /// committed provider set; specialized and generic reloads atomically replace + /// that committed state through the shared Plugin candidate gate. #[serde(default)] pub(crate) plugins: PluginConfig, /// Startup/restart-owned ACP coding-agent providers. This is independent @@ -794,24 +794,80 @@ impl ReloadableRunnerConfig { ); } }; - { - let mut routers = lock_unpoison(&self.external_routers); - routers.retain(|router| router.strong_count() > 0); - routers.push(Arc::downgrade(&next.external_tools)); - } - let mut current = self.current.write().unwrap(); - if self.is_stopping() { - let status = current.reload_status(); - return ( - status, - config_not_started( - RunnerConfigAction::Reload, - Some(current.generation), - "runner_unavailable", - ), - ); + let next_for_commit = Arc::clone(&next); + match self + .plugins + .apply_config_candidate_and_then(&candidate, || { + { + let mut routers = lock_unpoison(&self.external_routers); + routers.retain(|router| router.strong_count() > 0); + routers.push(Arc::downgrade(&next_for_commit.external_tools)); + } + // Plugin admission is the first externally meaningful commit + // of this candidate. The Plugin candidate gate remains held + // through this Hot config swap, so a specialized Plugin reload + // cannot interleave and create contradictory active truths. + let mut current = self.current.write().unwrap(); + *current = next_for_commit; + }) { + Ok(()) => {} + Err("plugin_reload_busy") => { + return ( + active.reload_status(), + config_not_started( + RunnerConfigAction::Reload, + Some(active.generation), + "plugin_reload_busy", + ), + ); + } + Err("plugin_manager_stopping") => { + return ( + active.reload_status(), + config_not_started( + RunnerConfigAction::Reload, + Some(active.generation), + "runner_unavailable", + ), + ); + } + Err("plugin_reload_state_failed") => { + return ( + active.reload_status(), + config_not_started( + RunnerConfigAction::Reload, + Some(active.generation), + "plugin_reload_failed", + ), + ); + } + Err(_) => { + let status = { + let mut status = active.reload_status.lock().unwrap(); + status.last_reload_result = "failure".to_string(); + status.last_reload_error_code = Some("plugin_reload_failed".to_string()); + status.last_reload_error_field = None; + status.last_reload_error_reason = None; + status.clone() + }; + active.external_tools.configuration_status_changed(); + eprintln!("webcodex-runner config reload failed: plugin_reload_failed"); + return ( + status, + RunnerConfigOperationResponse { + action: RunnerConfigAction::Reload, + execution_state: RunnerConfigExecutionState::Completed, + valid: Some(false), + current_generation: Some(active.generation), + error_code: Some("plugin_reload_failed".to_string()), + error_field: None, + error_reason: None, + restart_required: false, + restart_required_fields: Vec::new(), + }, + ); + } } - *current = next; eprintln!( "webcodex-runner config reload {}", status.last_reload_result @@ -926,7 +982,7 @@ pub(crate) fn restart_required_fields( macro_rules! classify { ($($field:ident),+ $(,)?) => {{ let RunnerConfig { - policy: _, shell: _, ssh: _, tool_providers: _, legacy_projects_dir: _, + policy: _, shell: _, ssh: _, plugins: _, tool_providers: _, legacy_projects_dir: _, deprecated_temporary_projects_root: _, $($field: _),+ } = candidate; [$((stringify!($field), startup.$field != candidate.$field)),+] @@ -944,7 +1000,6 @@ pub(crate) fn restart_required_fields( max_concurrent_jobs, acp, mcp_gateway, - plugins, owner, poll_interval_ms, project_registry_dir, diff --git a/crates/webcodex-runner/src/webcodex_runner/plugin.rs b/crates/webcodex-runner/src/webcodex_runner/plugin.rs index 641b04e8..acd2120c 100644 --- a/crates/webcodex-runner/src/webcodex_runner/plugin.rs +++ b/crates/webcodex-runner/src/webcodex_runner/plugin.rs @@ -1,11 +1,13 @@ //! Runner-owned native stdio Tool Plugin runtime. //! -//! Startup providers are eagerly initialized exactly once and their admitted -//! catalog is frozen for the Runner process lifetime. Explicit reloads build a -//! separate dynamic overlay; they never replace startup instances or direct -//! first-class bindings. +//! Configured providers are eagerly initialized into one committed state. Each +//! admitted provider instance has one frozen catalog. Explicit reloads prepare +//! a complete candidate set and atomically replace the committed state only +//! after every configured provider is admitted. -use super::config::{load_config, PluginConfig, PluginProviderConfig, RunnerConfig, ShellConfig}; +use super::config::{ + load_config, PluginConfig, PluginProviderConfig, RunnerConfig, ShellConfig, ShellDialect, +}; use super::shell::{PreparedExecutionEnvironment, PreparedShellProfileCache}; use serde::Deserialize; use serde_json::{json, Value}; @@ -18,13 +20,11 @@ use std::sync::{mpsc, Arc, Mutex, OnceLock, TryLockError}; use std::time::{Duration, Instant}; use webcodex_core::plugin::{ diagnose_invalid_tools, validate_plugin_input_arguments, validate_plugin_structured_output, - validate_request, validate_startup_catalog, validate_startup_tool, validate_tool_result, - validate_tools, PluginCatalog, PluginCheckDiagnostic, PluginCheckPhase, PluginCheckReport, - PluginCheckToolSummary, PluginDispatchState, PluginGatewayRequest, PluginGatewayResponse, - PluginGatewayResponsePayload, PluginPlane, PluginProviderView, PluginReloadFailure, - PluginSchemaObservation, PluginStartupToolShape, PluginTool, PluginToolResult, - StartupPluginProvider, PLUGIN_MAX_MESSAGE_BYTES, PLUGIN_PROTOCOL_VERSION, - PLUGIN_STARTUP_CATALOG_MAX_BYTES, PLUGIN_STARTUP_MAX_DIRECT_TOOLS, + validate_request, validate_tool_result, validate_tools, PluginCatalog, PluginCheckDiagnostic, + PluginCheckPhase, PluginCheckReport, PluginCheckToolSummary, PluginDispatchState, + PluginGatewayRequest, PluginGatewayResponse, PluginGatewayResponsePayload, PluginProviderView, + PluginReloadFailure, PluginSchemaObservation, PluginTool, PluginToolResult, + PLUGIN_MAX_MESSAGE_BYTES, PLUGIN_PROTOCOL_VERSION, }; use webcodex_process::ManagedChild; @@ -38,11 +38,7 @@ const PLUGIN_STDERR_MAX_LINE_BYTES: usize = 1024; const PLUGIN_STDERR_MAX_BYTES: usize = 32 * 1024; pub(crate) struct PluginManager { - startup: BTreeMap>, - startup_catalog: Vec, - startup_config: PluginConfig, - startup_shell: ShellConfig, - dynamic: Mutex, + committed: Mutex, candidate_gate: Mutex<()>, last_check_stderr: Mutex>, config_path: PathBuf, @@ -51,14 +47,89 @@ pub(crate) struct PluginManager { stopping: Arc, } -struct DynamicState { - overlay: BTreeMap, - first_class_restart_required: bool, +struct CommittedState { + providers: BTreeMap>, + config: PluginConfig, + environment: PluginEnvironmentSnapshot, } -enum DynamicEntry { - Provider(Arc), - Removed, +#[derive(Debug, Clone, PartialEq, Eq)] +struct PluginEnvironmentSnapshot { + default_profile: Option, + profiles: BTreeMap, + program: String, + args: Vec, + dialect: Option, + path_prepend: Vec, + env: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PluginProfileEnvironment { + program: Option, + args: Option>, + dialect: Option, + env: BTreeMap, + init_script: Option, +} + +impl PluginEnvironmentSnapshot { + fn from_config(shell: &ShellConfig, plugins: &PluginConfig) -> Self { + let mut profile_names = BTreeSet::new(); + let uses_default_profile = plugins + .providers + .iter() + .any(|provider| provider.profile.is_none()); + for provider in &plugins.providers { + if let Some(profile) = provider.profile.as_ref() { + profile_names.insert(profile.clone()); + } else if let Some(profile) = shell.default_profile.as_ref() { + profile_names.insert(profile.clone()); + } + } + let has_providers = !plugins.providers.is_empty(); + Self { + default_profile: uses_default_profile + .then(|| shell.default_profile.clone()) + .flatten(), + profiles: profile_names + .into_iter() + .filter_map(|name| { + shell.profiles.get(&name).map(|profile| { + ( + name, + PluginProfileEnvironment { + program: profile.program.clone(), + args: profile.args.clone(), + dialect: profile.dialect, + env: profile.env.clone(), + init_script: profile.init_script.clone(), + }, + ) + }) + }) + .collect(), + program: has_providers + .then(|| shell.program.clone()) + .unwrap_or_default(), + args: has_providers + .then(|| shell.args.clone()) + .unwrap_or_default(), + dialect: has_providers.then_some(shell.dialect).flatten(), + path_prepend: has_providers + .then(|| shell.path_prepend.clone()) + .unwrap_or_default(), + env: if has_providers { + shell + .env + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + } else { + BTreeMap::new() + }, + } + } } struct ProviderEntry { @@ -144,6 +215,20 @@ struct ProviderPreparationFailure { diagnostic: Option, } +enum PluginReloadAttempt { + Committed { + providers: Vec, + }, + Rejected { + providers: Vec, + failures: Vec, + }, + NotStarted { + code: &'static str, + message: &'static str, + }, +} + fn initialize_failure_detail(code: &str) -> &'static str { match code { "plugin_protocol_version_mismatch" => { @@ -190,51 +275,6 @@ fn failed_check_report( tool_count: 0, tools: Vec::new(), diagnostic, - startup_tool_shape: None, - } -} - -fn startup_tool_shape(tools: &[PluginTool]) -> PluginStartupToolShape { - if tools.len() > PLUGIN_STARTUP_MAX_DIRECT_TOOLS { - return PluginStartupToolShape { - eligible: false, - code: Some("plugin_startup_tool_count_exceeded".to_string()), - tool: None, - field: None, - }; - } - if let Some((tool, error)) = tools - .iter() - .find_map(|tool| validate_startup_tool(tool).err().map(|error| (tool, error))) - { - let field = if error.contains("inputSchema") { - Some("inputSchema".to_string()) - } else if error.contains("outputSchema") { - Some("outputSchema".to_string()) - } else if error.contains("annotations") { - Some("annotations".to_string()) - } else { - None - }; - return PluginStartupToolShape { - eligible: false, - code: Some( - if error.contains("startup tool") && error.contains("exceeds maximum") { - "plugin_startup_schema_too_large" - } else { - "plugin_startup_tool_invalid" - } - .to_string(), - ), - tool: Some(tool.name.clone()), - field, - }; - } - PluginStartupToolShape { - eligible: true, - code: None, - tool: None, - field: None, } } @@ -281,12 +321,10 @@ impl PluginManager { let prepared_profiles = PreparedShellProfileCache::default(); let stopping = Arc::new(AtomicBool::new(false)); let request_timeout = Duration::from_secs(startup.plugins.request_timeout_secs); - let mut startup_entries = BTreeMap::new(); - let mut startup_catalog = Vec::with_capacity(startup.plugins.providers.len()); - let mut direct_tool_count = 0usize; + let mut providers = BTreeMap::new(); for provider in &startup.plugins.providers { - let (entry, listed_tools, failure) = prepare_provider( + let (entry, _listed_tools, _failure) = prepare_provider( provider, &startup.shell, 1, @@ -294,72 +332,17 @@ impl PluginManager { &prepared_profiles, &stopping, ); - let instance_id = entry.instance_id.clone(); - let failure_code = failure.as_ref().map(|failure| failure.code.to_string()); - let catalog_tool_count = entry - .catalog - .get() - .map_or(0, |catalog| catalog.tools().len()); - let catalog_digest = entry - .catalog - .get() - .map(|catalog| catalog.digest().to_string()); - let mut advertised = StartupPluginProvider { - provider_id: provider.id.clone(), - provider_instance_id: instance_id, - name: provider.name.clone(), - status: if failure_code.is_some() { - "failed".to_string() - } else { - "ready".to_string() - }, - error_code: failure_code, - catalog_tool_count, - catalog_digest, - tools: Vec::new(), - }; - - if let Some(tools) = listed_tools { - let provider_shape = startup_tool_shape(&tools); - let provider_direct_admissible = provider_shape.eligible - && direct_tool_count.saturating_add(tools.len()) - <= PLUGIN_STARTUP_MAX_DIRECT_TOOLS; - if provider_direct_admissible { - advertised.tools = tools; - let mut tentative = startup_catalog.clone(); - tentative.push(advertised.clone()); - let within_aggregate = serde_json::to_vec(&tentative) - .is_ok_and(|encoded| encoded.len() <= PLUGIN_STARTUP_CATALOG_MAX_BYTES) - && validate_startup_catalog(&tentative).is_ok(); - if within_aggregate { - direct_tool_count += advertised.tools.len(); - } else { - advertised.tools.clear(); - advertised.status = "ready_secondary".to_string(); - advertised.error_code = Some("first_class_catalog_too_large".to_string()); - } - } else { - advertised.status = "ready_secondary".to_string(); - advertised.error_code = if provider_shape.eligible { - Some("first_class_catalog_too_large".to_string()) - } else { - provider_shape.code - }; - } - } - startup_entries.insert(provider.id.clone(), entry); - startup_catalog.push(advertised); + providers.insert(provider.id.clone(), entry); } - debug_assert!(validate_startup_catalog(&startup_catalog).is_ok()); Self { - startup: startup_entries, - startup_catalog, - startup_config: startup.plugins.clone(), - startup_shell: startup.shell.clone(), - dynamic: Mutex::new(DynamicState { - overlay: BTreeMap::new(), - first_class_restart_required: false, + committed: Mutex::new(CommittedState { + providers, + config: startup.plugins.clone(), + environment: PluginEnvironmentSnapshot::from_config( + &startup.shell, + &startup.plugins, + ), }), candidate_gate: Mutex::new(()), last_check_stderr: Mutex::new(BTreeMap::new()), @@ -370,20 +353,15 @@ impl PluginManager { } } - pub(crate) fn startup_catalog(&self) -> Vec { - self.startup_catalog.clone() - } - /// Runner-local diagnostic projection only. This is intentionally not part /// of Plugin gateway responses or any Server-facing protocol contract. #[allow(dead_code)] pub(crate) fn local_stderr_diagnostics( &self, - plane: PluginPlane, provider_id: &str, provider_instance_id: &str, ) -> Option { - self.resolve_provider(plane, provider_id, provider_instance_id) + self.resolve_provider(provider_id, provider_instance_id) .map(|provider| provider.process.stderr_snapshot()) } @@ -420,24 +398,17 @@ impl PluginManager { } match request { PluginGatewayRequest::Check { provider_id } => self.check_candidate(&provider_id), - PluginGatewayRequest::Reload => self.reload_dynamic(), + PluginGatewayRequest::Reload => self.reload_from_path(), PluginGatewayRequest::ProvidersList => { PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { providers: self.provider_views(), - first_class_restart_required: self - .dynamic - .lock() - .unwrap() - .first_class_restart_required, }) } PluginGatewayRequest::ToolsList { - plane, provider_id, provider_instance_id, } => { - let Some(provider) = - self.resolve_provider(plane, &provider_id, &provider_instance_id) + let Some(provider) = self.resolve_provider(&provider_id, &provider_instance_id) else { return stale_provider(); }; @@ -451,15 +422,13 @@ impl PluginManager { } } PluginGatewayRequest::ToolsCall { - plane, provider_id, provider_instance_id, name, arguments, expected_schema, } => { - let Some(provider) = - self.resolve_provider(plane, &provider_id, &provider_instance_id) + let Some(provider) = self.resolve_provider(&provider_id, &provider_instance_id) else { return stale_provider(); }; @@ -477,47 +446,26 @@ impl PluginManager { fn resolve_provider( &self, - plane: PluginPlane, provider_id: &str, provider_instance_id: &str, ) -> Option> { - let provider = match plane { - PluginPlane::Startup => self.startup.get(provider_id).cloned(), - PluginPlane::Effective => { - let dynamic = self.dynamic.lock().unwrap(); - match dynamic.overlay.get(provider_id) { - Some(DynamicEntry::Provider(provider)) => Some(Arc::clone(provider)), - Some(DynamicEntry::Removed) => None, - None => self.startup.get(provider_id).cloned(), - } - } - }?; + let provider = self + .committed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .providers + .get(provider_id) + .cloned()?; (provider.instance_id == provider_instance_id).then_some(provider) } fn provider_views(&self) -> Vec { - let dynamic = self.dynamic.lock().unwrap(); - let mut ids: BTreeSet = self.startup.keys().cloned().collect(); - ids.extend(dynamic.overlay.keys().cloned()); - ids.into_iter() - .filter_map(|provider_id| { - let (provider, plane) = match dynamic.overlay.get(&provider_id) { - Some(DynamicEntry::Provider(provider)) => { - (Arc::clone(provider), PluginPlane::Effective) - } - Some(DynamicEntry::Removed) => return None, - None => ( - Arc::clone(self.startup.get(&provider_id)?), - PluginPlane::Startup, - ), - }; - let direct_count = self - .startup_catalog - .iter() - .find(|entry| entry.provider_id == provider_id) - .map_or(0, |entry| entry.tools.len()); - Some(provider.view(plane, direct_count)) - }) + self.committed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .providers + .values() + .map(|provider| provider.view()) .collect() } @@ -622,7 +570,6 @@ impl PluginManager { }) .collect(), diagnostic: None, - startup_tool_shape: Some(startup_tool_shape(&tools)), } }; entry.shutdown(); @@ -633,7 +580,7 @@ impl PluginManager { PluginGatewayResponse::success(PluginGatewayResponsePayload::Checked { report }) } - fn reload_dynamic(&self) -> PluginGatewayResponse { + fn reload_from_path(&self) -> PluginGatewayResponse { let _candidate_guard = match self.candidate_gate.try_lock() { Ok(guard) => guard, Err(TryLockError::WouldBlock) => { @@ -647,7 +594,7 @@ impl PluginManager { return gateway_error( PluginDispatchState::NotStarted, "plugin_reload_state_failed", - "Plugin reload state is unavailable; dynamic state was unchanged", + "Plugin reload state is unavailable; committed state was unchanged", ) } }; @@ -658,10 +605,69 @@ impl PluginManager { return gateway_error( PluginDispatchState::NotStarted, code, - "Runner-owned Plugin configuration could not be loaded; dynamic state was unchanged", + "Runner-owned Plugin configuration could not be loaded; committed state was unchanged", ); } }; + // An explicit Plugin reload is also the code-reload primitive: the + // executable or script may have changed without changing runner.toml. + // Always prepare a fresh provider set for this path. + self.reload_attempt_response(self.reload_candidate_locked(&candidate, true)) + } + + /// Apply an already parsed/validated runner.toml candidate through the same + /// authoritative Plugin admission/commit primitive used by `plugin_tool` + /// reload. Callers retain their own runner:manage or plugin:manage boundary. + pub(crate) fn apply_config_candidate_and_then( + &self, + candidate: &RunnerConfig, + after_plugin_commit: impl FnOnce(), + ) -> Result<(), &'static str> { + let _candidate_guard = match self.candidate_gate.try_lock() { + Ok(guard) => guard, + Err(TryLockError::WouldBlock) => return Err("plugin_reload_busy"), + Err(TryLockError::Poisoned(_)) => return Err("plugin_reload_state_failed"), + }; + match self.reload_candidate_locked(candidate, false) { + PluginReloadAttempt::Committed { .. } => { + // Keep the Plugin candidate gate held until the caller commits + // the rest of the same Runner-config activation. Otherwise a + // specialized Plugin reload could interleave after Plugin + // commit but before the generic Hot config snapshot advances. + after_plugin_commit(); + Ok(()) + } + PluginReloadAttempt::Rejected { .. } => Err("plugin_reload_failed"), + PluginReloadAttempt::NotStarted { code, .. } => Err(code), + } + } + + fn reload_candidate_locked( + &self, + candidate: &RunnerConfig, + force_provider_restart: bool, + ) -> PluginReloadAttempt { + let candidate_environment = + PluginEnvironmentSnapshot::from_config(&candidate.shell, &candidate.plugins); + { + let committed = self + .committed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !force_provider_restart + && committed.config == candidate.plugins + && committed.environment == candidate_environment + { + return PluginReloadAttempt::Committed { + providers: committed + .providers + .values() + .map(|provider| provider.view()) + .collect(), + }; + } + } + let generation = self.next_generation.fetch_add(1, Ordering::SeqCst); let mut prepared = BTreeMap::new(); let mut failures = Vec::new(); @@ -679,89 +685,91 @@ impl PluginManager { provider_id: provider.id.clone(), code: failure.code.to_string(), }); - } else { - prepared.insert(provider.id.clone(), entry); } + prepared.insert(provider.id.clone(), entry); } - let configured: BTreeSet<_> = candidate - .plugins - .providers - .iter() - .map(|provider| provider.id.clone()) - .collect(); - let mut dynamic = self.dynamic.lock().unwrap(); if self.stopping.load(Ordering::SeqCst) { - drop(dynamic); - return gateway_error( - PluginDispatchState::NotStarted, - "plugin_manager_stopping", - "Plugin manager began stopping before reload commit; dynamic state was unchanged", - ); - } - // Replacing an entry may drop the last ProviderEntry Arc, whose Drop - // performs bounded process-tree termination. Keep that cleanup outside - // the dynamic-state mutex so unrelated list/describe/call operations do - // not wait on old-provider process teardown during an otherwise atomic - // reload commit. - let mut retired = Vec::new(); - let previous_ids: BTreeSet<_> = self - .startup - .keys() - .chain(dynamic.overlay.keys()) - .cloned() - .collect(); - for provider_id in previous_ids { - if !configured.contains(&provider_id) { - if let Some(previous) = dynamic.overlay.insert(provider_id, DynamicEntry::Removed) { - retired.push(previous); - } + for provider in prepared.values() { + provider.shutdown(); } + return PluginReloadAttempt::NotStarted { + code: "plugin_manager_stopping", + message: "Plugin manager began stopping before reload commit; committed state was unchanged", + }; } - for (provider_id, provider) in prepared { - if let Some(previous) = dynamic - .overlay - .insert(provider_id, DynamicEntry::Provider(provider)) - { - retired.push(previous); + + if !failures.is_empty() { + for provider in prepared.values() { + provider.shutdown(); } + return PluginReloadAttempt::Rejected { + providers: self.provider_views(), + failures, + }; } - dynamic.first_class_restart_required = candidate.plugins != self.startup_config - || candidate.shell != self.startup_shell - || dynamic - .overlay - .values() - .any(|entry| matches!(entry, DynamicEntry::Provider(_) | DynamicEntry::Removed)); - let restart_required = dynamic.first_class_restart_required; - drop(dynamic); + + // Config/environment identity and provider instances commit together. + // Retired provider teardown happens after the lock is released. + let retired = { + let mut committed = self + .committed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if self.stopping.load(Ordering::SeqCst) { + drop(committed); + for provider in prepared.values() { + provider.shutdown(); + } + return PluginReloadAttempt::NotStarted { + code: "plugin_manager_stopping", + message: "Plugin manager began stopping before reload commit; committed state was unchanged", + }; + } + committed.config = candidate.plugins.clone(); + committed.environment = candidate_environment; + std::mem::replace(&mut committed.providers, prepared) + }; drop(retired); - PluginGatewayResponse::success(PluginGatewayResponsePayload::Reloaded { + PluginReloadAttempt::Committed { providers: self.provider_views(), - failures, - first_class_restart_required: restart_required, - }) + } + } + + fn reload_attempt_response(&self, attempt: PluginReloadAttempt) -> PluginGatewayResponse { + match attempt { + PluginReloadAttempt::Committed { providers } => { + PluginGatewayResponse::success(PluginGatewayResponsePayload::Reloaded { + providers, + failures: Vec::new(), + }) + } + PluginReloadAttempt::Rejected { + providers, + failures, + } => PluginGatewayResponse::success(PluginGatewayResponsePayload::Reloaded { + providers, + failures, + }), + PluginReloadAttempt::NotStarted { code, message } => { + gateway_error(PluginDispatchState::NotStarted, code, message) + } + } } pub(crate) fn shutdown(&self) { if self.stopping.swap(true, Ordering::SeqCst) { return; } - for provider in self.startup.values() { - provider.shutdown(); - } - let dynamic_providers = { - let dynamic = self.dynamic.lock().unwrap(); - dynamic - .overlay - .values() - .filter_map(|entry| match entry { - DynamicEntry::Provider(provider) => Some(Arc::clone(provider)), - DynamicEntry::Removed => None, - }) - .collect::>() + let providers = { + let committed = self + .committed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + committed.providers.values().cloned().collect::>() }; - for provider in dynamic_providers { + for provider in providers { provider.shutdown(); } } @@ -787,16 +795,14 @@ impl ProviderEntry { Ok(self.frozen_catalog()?.tools().to_vec()) } - fn view(&self, plane: PluginPlane, startup_direct_tool_count: usize) -> PluginProviderView { + fn view(&self) -> PluginProviderView { let failed = self.failed.load(Ordering::SeqCst); PluginProviderView { provider_id: self.config.id.clone(), provider_instance_id: self.instance_id.clone(), name: self.config.name.clone(), - plane, status: if failed { "failed" } else { "ready" }.to_string(), error_code: self.error_code.lock().unwrap().clone(), - startup_direct_tool_count, } } diff --git a/crates/webcodex-runner/src/webcodex_runner/plugin_check_tests.rs b/crates/webcodex-runner/src/webcodex_runner/plugin_check_tests.rs index 2f955748..dd22904a 100644 --- a/crates/webcodex-runner/src/webcodex_runner/plugin_check_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/plugin_check_tests.rs @@ -139,16 +139,23 @@ fn checked_report(response: PluginGatewayResponse) -> PluginCheckReport { report } -fn provider_state(manager: &PluginManager) -> (Vec, bool) { +fn provider_state(manager: &PluginManager) -> Vec { let response = manager.handle(PluginGatewayRequest::ProvidersList); - let Some(PluginGatewayResponsePayload::Providers { - providers, - first_class_restart_required, - }) = response.payload - else { + let Some(PluginGatewayResponsePayload::Providers { providers }) = response.payload else { panic!("missing provider state: {:?}", response.error); }; - (providers, first_class_restart_required) + providers +} + +fn provider_tools(manager: &PluginManager, provider: &PluginProviderView) -> Vec { + let response = manager.handle(PluginGatewayRequest::ToolsList { + provider_id: provider.provider_id.clone(), + provider_instance_id: provider.provider_instance_id.clone(), + }); + let Some(PluginGatewayResponsePayload::Tools { tools }) = response.payload else { + panic!("missing provider tools: {:?}", response.error); + }; + tools } fn wait_until(timeout: Duration, condition: impl Fn() -> bool) -> bool { @@ -165,7 +172,6 @@ fn wait_until(timeout: Duration, condition: impl Fn() -> bool) -> bool { #[test] fn check_success_is_disposable_and_preserves_committed_plugin_state() { let fixture = CheckFixture::new("check_success_tree", 2); - let startup_before = fixture.manager.startup_catalog(); let state_before = provider_state(&fixture.manager); let report = checked_report(fixture.check()); @@ -173,13 +179,11 @@ fn check_success_is_disposable_and_preserves_committed_plugin_state() { assert_eq!(report.phase, PluginCheckPhase::Ready); assert_eq!(report.tool_count, 1); assert_eq!(report.tools[0].name, "echo"); - assert!(report.startup_tool_shape.as_ref().unwrap().eligible); assert_eq!( fixture.marker_count("call"), 0, "check must never call tools/call" ); - assert_eq!(fixture.manager.startup_catalog(), startup_before); assert_eq!(provider_state(&fixture.manager), state_before); let candidate_pid = fixture @@ -199,16 +203,11 @@ fn check_success_is_disposable_and_preserves_committed_plugin_state() { } #[test] -fn check_observes_edited_v2_without_replacing_current_dynamic_v1() { +fn check_observes_edited_v2_without_replacing_current_v1() { let fixture = CheckFixture::new("normal", 2); - let first_reload = fixture.manager.handle(PluginGatewayRequest::Reload); - let Some(PluginGatewayResponsePayload::Reloaded { providers, .. }) = first_reload.payload - else { - panic!("initial dynamic reload failed: {:?}", first_reload.error); - }; - let dynamic_v1 = providers[0].provider_instance_id.clone(); - let startup = fixture.manager.startup_catalog(); - let v1_schema = startup[0].tools[0].schema_observation(); + let current_v1 = provider_state(&fixture.manager).remove(0); + let dynamic_v1 = current_v1.provider_instance_id.clone(); + let v1_schema = provider_tools(&fixture.manager, ¤t_v1)[0].schema_observation(); let state_before_check = provider_state(&fixture.manager); fixture.rewrite_candidate("check_v2", 2); @@ -218,7 +217,6 @@ fn check_observes_edited_v2_without_replacing_current_dynamic_v1() { assert_eq!(provider_state(&fixture.manager), state_before_check); let v1_call = fixture.manager.handle(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Effective, provider_id: "fake".to_string(), provider_instance_id: dynamic_v1.clone(), name: "echo".to_string(), @@ -227,7 +225,7 @@ fn check_observes_edited_v2_without_replacing_current_dynamic_v1() { }); assert!( v1_call.error.is_none(), - "check must not disturb current dynamic v1" + "check must not disturb current committed v1" ); let reload_v2 = fixture.manager.handle(PluginGatewayRequest::Reload); @@ -237,7 +235,6 @@ fn check_observes_edited_v2_without_replacing_current_dynamic_v1() { let dynamic_v2 = providers[0].provider_instance_id.clone(); assert_ne!(dynamic_v2, dynamic_v1); let tools = fixture.manager.handle(PluginGatewayRequest::ToolsList { - plane: PluginPlane::Effective, provider_id: "fake".to_string(), provider_instance_id: dynamic_v2.clone(), }); @@ -248,7 +245,6 @@ fn check_observes_edited_v2_without_replacing_current_dynamic_v1() { let calls_before_stale = fixture.marker_count("call"); let stale_v1 = fixture.manager.handle(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Effective, provider_id: "fake".to_string(), provider_instance_id: dynamic_v1, name: "echo".to_string(), @@ -267,7 +263,6 @@ fn check_observes_edited_v2_without_replacing_current_dynamic_v1() { ); let v2_list_again = fixture.manager.handle(PluginGatewayRequest::ToolsList { - plane: PluginPlane::Effective, provider_id: "fake".to_string(), provider_instance_id: dynamic_v2, }); @@ -419,7 +414,7 @@ fn check_tool_validation_failures_have_safe_actionable_diagnostics() { } #[test] -fn check_reports_executable_config_and_startup_shape_without_sensitive_details() { +fn check_reports_executable_config_and_large_catalog_without_sensitive_details() { let fixture = CheckFixture::new("normal", 2); write_runner_toml( &fixture.config_path, @@ -445,14 +440,8 @@ fn check_reports_executable_config_and_startup_shape_without_sensitive_details() fixture.rewrite_candidate("check_startup_large_schema", 2); let shape = checked_report(fixture.check()); assert!(shape.ready); - let startup_shape = shape.startup_tool_shape.unwrap(); - assert!(!startup_shape.eligible); - assert_eq!( - startup_shape.code.as_deref(), - Some("plugin_startup_schema_too_large") - ); - assert_eq!(startup_shape.tool.as_deref(), Some("echo")); - assert_eq!(startup_shape.field.as_deref(), Some("inputSchema")); + assert_eq!(shape.tool_count, 1); + assert_eq!(shape.tools[0].name, "echo"); fixture.rewrite_candidate("stderr", 2); let response = fixture.check(); @@ -489,14 +478,9 @@ fn check_reports_executable_config_and_startup_shape_without_sensitive_details() #[test] fn candidate_gate_serializes_check_and_reload_without_blocking_current_providers() { let fixture = CheckFixture::new("normal", 2); - let first_reload = fixture.manager.handle(PluginGatewayRequest::Reload); - let Some(PluginGatewayResponsePayload::Reloaded { providers, .. }) = first_reload.payload - else { - panic!("initial reload failed: {:?}", first_reload.error); - }; - let dynamic_v1 = providers[0].provider_instance_id.clone(); - let startup = fixture.manager.startup_catalog(); - let schema = startup[0].tools[0].schema_observation(); + let current_v1 = provider_state(&fixture.manager).remove(0); + let dynamic_v1 = current_v1.provider_instance_id.clone(); + let schema = provider_tools(&fixture.manager, ¤t_v1)[0].schema_observation(); fixture.rewrite_candidate("candidate_block_list_tree", 10); let _ = fs::remove_file(fixture.marker.with_extension("release")); @@ -518,7 +502,6 @@ fn candidate_gate_serializes_check_and_reload_without_blocking_current_providers assert_eq!(reload.error.as_ref().unwrap().code, "plugin_reload_busy"); let current_call = fixture.manager.handle(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Effective, provider_id: "fake".to_string(), provider_instance_id: dynamic_v1.clone(), name: "echo".to_string(), @@ -526,15 +509,6 @@ fn candidate_gate_serializes_check_and_reload_without_blocking_current_providers expected_schema: schema.clone(), }); assert!(current_call.error.is_none()); - let direct_startup = fixture.manager.handle(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Startup, - provider_id: "fake".to_string(), - provider_instance_id: startup[0].provider_instance_id.clone(), - name: "echo".to_string(), - arguments: json!({"value":"startup-during-check"}), - expected_schema: schema, - }); - assert!(direct_startup.error.is_none()); assert!(fixture .manager .handle(PluginGatewayRequest::ProvidersList) @@ -544,7 +518,7 @@ fn candidate_gate_serializes_check_and_reload_without_blocking_current_providers fs::write(fixture.marker.with_extension("release"), b"release").unwrap(); let report = checked_report(check_a.join().unwrap()); assert!(report.ready); - let (providers, _) = provider_state(&fixture.manager); + let providers = provider_state(&fixture.manager); assert_eq!(providers[0].provider_instance_id, dynamic_v1); for pid in fixture.marker_pids("descendant-pid:") { assert!(wait_until(Duration::from_secs(2), || { diff --git a/crates/webcodex-runner/src/webcodex_runner/plugin_tests.rs b/crates/webcodex-runner/src/webcodex_runner/plugin_tests.rs index 29dcd43b..3ffecc2b 100644 --- a/crates/webcodex-runner/src/webcodex_runner/plugin_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/plugin_tests.rs @@ -6,7 +6,8 @@ use std::process::Command; use std::sync::{mpsc, Arc, Mutex, OnceLock, Weak}; use tempfile::TempDir; use webcodex_core::plugin::{ - PluginContent, PluginGatewayResponsePayload, PLUGIN_MAX_ARGUMENT_BYTES, + PluginContent, PluginGatewayResponsePayload, PluginProviderView, PluginSchemaObservation, + PLUGIN_MAX_ARGUMENT_BYTES, }; static FAKE_PLUGIN: OnceLock>> = OnceLock::new(); @@ -55,7 +56,8 @@ fn fake_binary() -> Arc { struct Fixture { manager: Arc, marker: PathBuf, - provider: StartupPluginProvider, + provider: PluginProviderView, + schema: Option, _fake: Arc, _temp: TempDir, } @@ -83,11 +85,14 @@ impl Fixture { temp.path(), ); let manager = Arc::new(PluginManager::new(&config, temp.path().join("runner.toml"))); - let provider = manager.startup_catalog().into_iter().next().unwrap(); + let provider = current_providers(&manager).into_iter().next().unwrap(); + let schema = (provider.status == "ready") + .then(|| current_tools(&manager, &provider)[0].schema_observation()); Self { manager, marker, provider, + schema, _fake: fake, _temp: temp, } @@ -95,7 +100,6 @@ impl Fixture { fn list(&self) -> PluginGatewayResponse { self.manager.handle(PluginGatewayRequest::ToolsList { - plane: PluginPlane::Startup, provider_id: self.provider.provider_id.clone(), provider_instance_id: self.provider.provider_instance_id.clone(), }) @@ -106,14 +110,12 @@ impl Fixture { } fn call_with_arguments(&self, arguments: Value) -> PluginGatewayResponse { - let schema = self.provider.tools[0].schema_observation(); self.manager.handle(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Startup, provider_id: self.provider.provider_id.clone(), provider_instance_id: self.provider.provider_instance_id.clone(), name: "echo".to_string(), arguments, - expected_schema: schema, + expected_schema: self.schema.clone().expect("ready fixture schema"), }) } @@ -133,6 +135,25 @@ impl Fixture { } } +fn current_providers(manager: &PluginManager) -> Vec { + let response = manager.handle(PluginGatewayRequest::ProvidersList); + let Some(PluginGatewayResponsePayload::Providers { providers }) = response.payload else { + panic!("missing providers: {:?}", response.error); + }; + providers +} + +fn current_tools(manager: &PluginManager, provider: &PluginProviderView) -> Vec { + let response = manager.handle(PluginGatewayRequest::ToolsList { + provider_id: provider.provider_id.clone(), + provider_instance_id: provider.provider_instance_id.clone(), + }); + let Some(PluginGatewayResponsePayload::Tools { tools }) = response.payload else { + panic!("missing tools: {:?}", response.error); + }; + tools +} + fn maximum_bounded_arguments() -> Value { let empty = json!({"value":""}); let overhead = serde_json::to_vec(&empty).unwrap().len(); @@ -192,19 +213,18 @@ fn runner_config( } #[test] -fn startup_is_eager_persistent_and_reused() { +fn initial_committed_provider_is_eager_persistent_and_reused() { let fixture = Fixture::new("normal", 2); assert_eq!(fixture.marker_count("start"), 1); assert_eq!(fixture.marker_count("initialize"), 1); assert_eq!( fixture.marker_count("list"), 1, - "startup admission must list eagerly" + "initial provider admission must list eagerly" ); assert_eq!(fixture.provider.status, "ready"); - assert_eq!(fixture.provider.tools.len(), 1); - assert_eq!(fixture.provider.catalog_tool_count, 1); - assert!(fixture.provider.catalog_digest.is_some()); + let tools = current_tools(&fixture.manager, &fixture.provider); + assert_eq!(tools.len(), 1); assert!(fixture.list().error.is_none()); for expected in ["call-1", "call-2"] { @@ -233,7 +253,7 @@ fn startup_is_eager_persistent_and_reused() { } #[test] -fn bad_version_and_invalid_startup_provider_do_not_block_manager() { +fn bad_version_and_invalid_initial_provider_do_not_block_manager() { for (scenario, code) in [ ("bad_version", "plugin_protocol_version_mismatch"), ("invalid_tools", "plugin_tools_list_invalid"), @@ -247,31 +267,15 @@ fn bad_version_and_invalid_startup_provider_do_not_block_manager() { Some(code), "{scenario}" ); - assert!(fixture.provider.tools.is_empty()); } } #[test] -fn startup_secondary_admission_stays_separate_from_runtime_provider_health() { +fn provider_catalog_is_not_limited_by_removed_first_class_schema_bound() { let fixture = Fixture::new("check_startup_large_schema", 2); - assert_eq!(fixture.provider.status, "ready_secondary"); - assert_eq!( - fixture.provider.error_code.as_deref(), - Some("plugin_startup_schema_too_large") - ); - assert!(fixture.provider.tools.is_empty()); - assert_eq!(fixture.provider.catalog_tool_count, 1); - assert!(fixture.provider.catalog_digest.is_some()); - - let response = fixture.manager.handle(PluginGatewayRequest::ProvidersList); - let Some(PluginGatewayResponsePayload::Providers { providers, .. }) = response.payload else { - panic!("missing provider view: {:?}", response.error); - }; - assert_eq!(providers.len(), 1); - assert_eq!(providers[0].status, "ready"); - assert_eq!(providers[0].plane, PluginPlane::Startup); - assert_eq!(providers[0].startup_direct_tool_count, 0); - assert_eq!(providers[0].error_code, None); + assert_eq!(fixture.provider.status, "ready"); + assert_eq!(fixture.provider.error_code, None); + assert_eq!(current_tools(&fixture.manager, &fixture.provider).len(), 1); } #[test] @@ -379,16 +383,16 @@ fn shutdown_terminates_process_tree_while_effectful_stdin_write_is_blocked() { let fixture = Fixture::new("block_after_preflight_tree", 10); let manager = Arc::clone(&fixture.manager); let provider = fixture.provider.clone(); + let schema = fixture.schema.clone().unwrap(); let arguments = maximum_bounded_arguments(); let (sender, receiver) = mpsc::channel(); let request = std::thread::spawn(move || { let response = manager.handle(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Startup, provider_id: provider.provider_id.clone(), provider_instance_id: provider.provider_instance_id.clone(), name: "echo".to_string(), arguments, - expected_schema: provider.tools[0].schema_observation(), + expected_schema: schema, }); let _ = sender.send(response); }); @@ -464,7 +468,6 @@ fn stderr_is_diagnostic_only_and_never_enters_catalog_or_result() { fixture .manager .local_stderr_diagnostics( - PluginPlane::Startup, &fixture.provider.provider_id, &fixture.provider.provider_instance_id, ) @@ -475,7 +478,7 @@ fn stderr_is_diagnostic_only_and_never_enters_catalog_or_result() { .any(|line| line.text == "diagnostic-only-secret-looking-stderr") }) })); - let catalog = serde_json::to_string(&fixture.manager.startup_catalog()).unwrap(); + let catalog = serde_json::to_string(&fixture.list()).unwrap(); assert!(!catalog.contains("diagnostic-only-secret-looking-stderr")); let result = serde_json::to_string(&fixture.call()).unwrap(); assert!(!result.contains("diagnostic-only-secret-looking-stderr")); @@ -492,7 +495,6 @@ fn stderr_flood_is_bounded_and_does_not_block_stdout_protocol() { fixture .manager .local_stderr_diagnostics( - PluginPlane::Startup, &fixture.provider.provider_id, &fixture.provider.provider_instance_id, ) @@ -501,7 +503,6 @@ fn stderr_flood_is_bounded_and_does_not_block_stdout_protocol() { let snapshot = fixture .manager .local_stderr_diagnostics( - PluginPlane::Startup, &fixture.provider.provider_id, &fixture.provider.provider_instance_id, ) @@ -512,7 +513,7 @@ fn stderr_flood_is_bounded_and_does_not_block_stdout_protocol() { .lines .iter() .all(|line| line.text.len() <= PLUGIN_STDERR_MAX_LINE_BYTES && line.truncated)); - let catalog = serde_json::to_string(&fixture.manager.startup_catalog()).unwrap(); + let catalog = serde_json::to_string(&fixture.list()).unwrap(); assert!(!catalog.contains("stderr-flood")); } @@ -521,14 +522,14 @@ fn provider_busy_is_not_started() { let fixture = Fixture::new("slow", 2); let manager = Arc::clone(&fixture.manager); let provider = fixture.provider.clone(); + let schema = fixture.schema.clone().unwrap(); let first = std::thread::spawn(move || { manager.handle(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Startup, provider_id: provider.provider_id.clone(), provider_instance_id: provider.provider_instance_id.clone(), name: "echo".to_string(), arguments: json!({"value":"first"}), - expected_schema: provider.tools[0].schema_observation(), + expected_schema: schema, }) }); for _ in 0..100 { @@ -593,7 +594,7 @@ fn prepared_environment_reuses_shell_env_default_profile_and_clears_sensitive_va }; let config = runner_config(plugins, shell, temp.path()); let manager = PluginManager::new(&config, temp.path().join("runner.toml")); - assert_eq!(manager.startup_catalog()[0].status, "ready"); + assert_eq!(current_providers(&manager)[0].status, "ready"); let markers = fs::read_to_string(marker).unwrap(); assert!(markers.contains("profile-env-ok")); assert!(markers.contains("sensitive-env-cleared")); @@ -644,7 +645,7 @@ fn bare_plugin_command_resolves_from_prepared_path_with_explicit_profile() { }; let config = runner_config(plugins, shell, temp.path()); let manager = PluginManager::new(&config, temp.path().join("runner.toml")); - assert_eq!(manager.startup_catalog()[0].status, "ready"); + assert_eq!(current_providers(&manager)[0].status, "ready"); assert_eq!( fs::read_to_string(marker) .unwrap_or_default() @@ -688,23 +689,26 @@ fn plugin_profile_init_script_is_captured_into_native_child_environment() { }; let config = runner_config(plugins, shell, temp.path()); let manager = PluginManager::new(&config, temp.path().join("runner.toml")); - assert_eq!(manager.startup_catalog()[0].status, "ready"); + assert_eq!(current_providers(&manager)[0].status, "ready"); let markers = fs::read_to_string(marker).unwrap(); assert!(markers.contains("profile-env-ok")); } #[test] -fn direct_startup_catalog_remains_frozen_across_dynamic_reload() { +fn reload_replaces_committed_provider_and_invalidates_old_instance() { let temp = tempfile::tempdir().unwrap(); let marker = temp.path().join("marker.log"); let fake = fake_binary(); - let startup_plugins = PluginConfig { + let plugins = PluginConfig { request_timeout_secs: 2, providers: vec![PluginProviderConfig { id: "fake".to_string(), name: "Fake Plugin".to_string(), command: fake.path.to_string_lossy().into_owned(), - args: vec!["normal".to_string(), marker.to_string_lossy().into_owned()], + args: vec![ + "schema_change".to_string(), + marker.to_string_lossy().into_owned(), + ], cwd: Some(temp.path().to_string_lossy().into_owned()), profile: None, timeout_secs: None, @@ -712,42 +716,66 @@ fn direct_startup_catalog_remains_frozen_across_dynamic_reload() { }; let config_path = temp.path().join("runner.toml"); write_runner_toml(&config_path, temp.path(), &fake.path, &marker, "normal"); - let config = runner_config(startup_plugins, ShellConfig::default(), temp.path()); + let config = runner_config(plugins, ShellConfig::default(), temp.path()); let manager = PluginManager::new(&config, config_path); - let startup = manager.startup_catalog(); - let startup_instance = startup[0].provider_instance_id.clone(); + let before = current_providers(&manager).remove(0); + let old_instance = before.provider_instance_id.clone(); let reloaded = manager.handle(PluginGatewayRequest::Reload); let Some(PluginGatewayResponsePayload::Reloaded { providers, - first_class_restart_required, - .. + failures, }) = reloaded.payload else { panic!("reload failed: {:?}", reloaded.error); }; - assert!(first_class_restart_required); - assert_ne!(providers[0].provider_instance_id, startup_instance); - assert_eq!( - manager.startup_catalog(), - startup, - "startup catalog must be immutable" - ); - - let direct = manager.handle(PluginGatewayRequest::ToolsList { - plane: PluginPlane::Startup, + assert!(failures.is_empty()); + let new_instance = providers[0].provider_instance_id.clone(); + assert_ne!(new_instance, old_instance); + let stale = manager.handle(PluginGatewayRequest::ToolsList { provider_id: "fake".to_string(), - provider_instance_id: startup_instance, + provider_instance_id: old_instance, }); - assert!(direct.error.is_none()); + assert_eq!(stale.dispatch_state, PluginDispatchState::NotStarted); + assert_eq!(stale.error.as_ref().unwrap().code, "stale_plugin_provider"); + let current = manager.handle(PluginGatewayRequest::ToolsList { + provider_id: "fake".to_string(), + provider_instance_id: new_instance, + }); + assert!(current.error.is_none()); } #[test] -fn concurrent_reload_is_busy_while_existing_dynamic_calls_continue_and_later_reload_wins() { +fn explicit_reload_restarts_provider_when_config_is_unchanged() { + let temp = tempfile::tempdir().unwrap(); + let marker = temp.path().join("marker.log"); + let fake = fake_binary(); + let config_path = temp.path().join("runner.toml"); + write_runner_toml(&config_path, temp.path(), &fake.path, &marker, "normal"); + let config = super::super::config::load_config(&config_path).unwrap(); + let manager = PluginManager::new(&config, config_path); + let old_instance = current_providers(&manager)[0].provider_instance_id.clone(); + + // The executable/script may have changed on disk even though runner.toml + // did not. An explicit plugin_tool reload must therefore re-instantiate it. + let reloaded = manager.handle(PluginGatewayRequest::Reload); + let Some(PluginGatewayResponsePayload::Reloaded { + providers, + failures, + }) = reloaded.payload + else { + panic!("reload failed: {:?}", reloaded.error); + }; + assert!(failures.is_empty()); + assert_ne!(providers[0].provider_instance_id, old_instance); +} + +#[test] +fn concurrent_reload_is_busy_while_existing_calls_continue_and_later_reload_wins() { let temp = tempfile::tempdir().unwrap(); let marker = temp.path().join("marker.log"); let release = marker.with_extension("release"); let fake = fake_binary(); - let startup_plugins = PluginConfig { + let plugins = PluginConfig { request_timeout_secs: 2, providers: vec![PluginProviderConfig { id: "fake".to_string(), @@ -761,17 +789,11 @@ fn concurrent_reload_is_busy_while_existing_dynamic_calls_continue_and_later_rel }; let config_path = temp.path().join("runner.toml"); write_runner_toml(&config_path, temp.path(), &fake.path, &marker, "normal"); - let config = runner_config(startup_plugins, ShellConfig::default(), temp.path()); + let config = runner_config(plugins, ShellConfig::default(), temp.path()); let manager = Arc::new(PluginManager::new(&config, config_path.clone())); - let startup = manager.startup_catalog(); - let expected_schema = startup[0].tools[0].schema_observation(); - - let first_reload = manager.handle(PluginGatewayRequest::Reload); - let Some(PluginGatewayResponsePayload::Reloaded { providers, .. }) = first_reload.payload - else { - panic!("initial dynamic reload failed: {:?}", first_reload.error); - }; - let dynamic_v1 = providers[0].provider_instance_id.clone(); + let current_v1 = current_providers(&manager).remove(0); + let dynamic_v1 = current_v1.provider_instance_id.clone(); + let expected_schema = current_tools(&manager, ¤t_v1)[0].schema_observation(); let _ = fs::remove_file(&release); write_runner_toml_with_timeout( @@ -811,12 +833,11 @@ fn concurrent_reload_is_busy_while_existing_dynamic_calls_continue_and_later_rel ); let current = manager.handle(PluginGatewayRequest::ProvidersList); - let Some(PluginGatewayResponsePayload::Providers { providers, .. }) = current.payload else { + let Some(PluginGatewayResponsePayload::Providers { providers }) = current.payload else { panic!("current dynamic provider list failed: {:?}", current.error); }; assert_eq!(providers[0].provider_instance_id, dynamic_v1); let call_while_reloading = manager.handle(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Effective, provider_id: "fake".to_string(), provider_instance_id: dynamic_v1.clone(), name: "echo".to_string(), @@ -825,9 +846,8 @@ fn concurrent_reload_is_busy_while_existing_dynamic_calls_continue_and_later_rel }); assert!( call_while_reloading.error.is_none(), - "candidate preparation must not hold the dynamic-state lock or block the current provider" + "candidate preparation must not hold the committed-state lock or block the current provider" ); - assert_eq!(manager.startup_catalog(), startup); fs::write(&release, b"release").unwrap(); let completed_a = reload_a.join().unwrap(); @@ -861,16 +881,126 @@ fn concurrent_reload_is_busy_while_existing_dynamic_calls_continue_and_later_rel 1, "reload C must read and prepare the new config after A releases the gate" ); - assert_eq!(manager.startup_catalog(), startup); - let final_view = manager.handle(PluginGatewayRequest::ProvidersList); - let Some(PluginGatewayResponsePayload::Providers { providers, .. }) = final_view.payload else { + let Some(PluginGatewayResponsePayload::Providers { providers }) = final_view.payload else { panic!("final provider list failed: {:?}", final_view.error); }; assert_eq!(providers[0].provider_instance_id, dynamic_c); manager.shutdown(); } +#[test] +fn generic_config_activation_holds_plugin_gate_through_followup_commit() { + let fixture = Fixture::new("normal", 2); + let plugins = { + let committed = fixture + .manager + .committed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + committed.config.clone() + }; + let candidate = runner_config(plugins, ShellConfig::default(), fixture._temp.path()); + let manager = Arc::clone(&fixture.manager); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let activation = std::thread::spawn(move || { + manager.apply_config_candidate_and_then(&candidate, || { + entered_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + }) + }); + + entered_rx + .recv_timeout(Duration::from_secs(2)) + .expect("generic activation follow-up commit must start"); + let concurrent = fixture.manager.handle(PluginGatewayRequest::Reload); + assert_eq!(concurrent.dispatch_state, PluginDispatchState::NotStarted); + assert_eq!( + concurrent.error.as_ref().map(|error| error.code.as_str()), + Some("plugin_reload_busy") + ); + release_tx.send(()).unwrap(); + assert!(activation.join().unwrap().is_ok()); +} + +#[test] +fn plugin_committed_environment_ignores_unrelated_shell_runtime_controls() { + let fixture = Fixture::new("normal", 2); + let plugins = fixture + .manager + .committed + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .config + .clone(); + let initial = current_providers(&fixture.manager)[0] + .provider_instance_id + .clone(); + + let mut unrelated_shell = ShellConfig::default(); + unrelated_shell.max_persistent_shells += 1; + unrelated_shell.persistent_shell_idle_timeout_secs += 1; + let mut unused_profile = super::super::config::ShellProfileConfig::default(); + unused_profile.env.insert( + "WEBCODEX_UNUSED_PLUGIN_PROFILE".to_string(), + "v1".to_string(), + ); + unrelated_shell + .profiles + .insert("unused".to_string(), unused_profile); + let unrelated = runner_config(plugins.clone(), unrelated_shell, fixture._temp.path()); + fixture + .manager + .apply_config_candidate_and_then(&unrelated, || {}) + .unwrap(); + assert_eq!( + current_providers(&fixture.manager)[0].provider_instance_id, + initial, + "generic persistent-shell controls and unreferenced profiles must not replace the Plugin provider" + ); + + let mut relevant_shell = ShellConfig::default(); + relevant_shell.default_profile = Some("plugin".to_string()); + relevant_shell.profiles.insert( + "plugin".to_string(), + super::super::config::ShellProfileConfig::default(), + ); + let relevant = runner_config( + plugins.clone(), + relevant_shell.clone(), + fixture._temp.path(), + ); + fixture + .manager + .apply_config_candidate_and_then(&relevant, || {}) + .unwrap(); + let profiled = current_providers(&fixture.manager)[0] + .provider_instance_id + .clone(); + assert_ne!( + profiled, initial, + "selecting the default Plugin profile must replace the provider" + ); + + relevant_shell + .profiles + .get_mut("plugin") + .unwrap() + .env + .insert("WEBCODEX_PLUGIN_ENV_TEST".to_string(), "v2".to_string()); + let changed_profile = runner_config(plugins, relevant_shell, fixture._temp.path()); + fixture + .manager + .apply_config_candidate_and_then(&changed_profile, || {}) + .unwrap(); + assert_ne!( + current_providers(&fixture.manager)[0].provider_instance_id, + profiled, + "referenced Plugin profile changes must create a new provider instance" + ); +} + #[test] fn shutdown_during_blocked_reload_does_not_wait_for_gate_or_allow_late_commit() { let temp = tempfile::tempdir().unwrap(); @@ -935,11 +1065,11 @@ fn shutdown_during_blocked_reload_does_not_wait_for_gate_or_allow_late_commit() } #[test] -fn failed_dynamic_candidate_keeps_previous_instance_and_removal_is_tombstoned() { +fn failed_candidate_keeps_previous_instance_and_removal_has_no_fallback() { let temp = tempfile::tempdir().unwrap(); let marker = temp.path().join("marker.log"); let fake = fake_binary(); - let startup_plugins = PluginConfig { + let plugins = PluginConfig { request_timeout_secs: 2, providers: vec![PluginProviderConfig { id: "fake".to_string(), @@ -953,17 +1083,9 @@ fn failed_dynamic_candidate_keeps_previous_instance_and_removal_is_tombstoned() }; let config_path = temp.path().join("runner.toml"); write_runner_toml(&config_path, temp.path(), &fake.path, &marker, "normal"); - let config = runner_config(startup_plugins, ShellConfig::default(), temp.path()); + let config = runner_config(plugins, ShellConfig::default(), temp.path()); let manager = PluginManager::new(&config, config_path.clone()); - let startup_instance = manager.startup_catalog()[0].provider_instance_id.clone(); - - let first_reload = manager.handle(PluginGatewayRequest::Reload); - let Some(PluginGatewayResponsePayload::Reloaded { providers, .. }) = first_reload.payload - else { - panic!("first dynamic reload failed: {:?}", first_reload.error); - }; - let dynamic_instance = providers[0].provider_instance_id.clone(); - assert_ne!(dynamic_instance, startup_instance); + let committed_instance = current_providers(&manager)[0].provider_instance_id.clone(); write_runner_toml( &config_path, @@ -987,8 +1109,8 @@ fn failed_dynamic_candidate_keeps_previous_instance_and_removal_is_tombstoned() assert_eq!(failures.len(), 1); assert_eq!(failures[0].provider_id, "fake"); assert_eq!( - providers[0].provider_instance_id, dynamic_instance, - "a failed candidate must not destroy the previous working dynamic instance" + providers[0].provider_instance_id, committed_instance, + "a failed candidate must not destroy the previous committed instance" ); write_runner_toml_without_plugins(&config_path, temp.path()); @@ -998,30 +1120,60 @@ fn failed_dynamic_candidate_keeps_previous_instance_and_removal_is_tombstoned() }; assert!( providers.is_empty(), - "removed provider must disappear from effective dynamic view" + "removed provider must disappear from committed view" ); - let effective_fallback = manager.handle(PluginGatewayRequest::ToolsList { - plane: PluginPlane::Effective, + let stale = manager.handle(PluginGatewayRequest::ToolsList { provider_id: "fake".to_string(), - provider_instance_id: startup_instance.clone(), + provider_instance_id: committed_instance, }); assert_eq!( - effective_fallback - .error - .as_ref() - .map(|error| error.code.as_str()), + stale.error.as_ref().map(|error| error.code.as_str()), Some("stale_plugin_provider"), - "dynamic removal tombstone must prevent fallback to startup for plugin_tool" + "provider removal must never fall back to an earlier provider instance" ); +} - let direct_startup = manager.handle(PluginGatewayRequest::ToolsList { - plane: PluginPlane::Startup, - provider_id: "fake".to_string(), - provider_instance_id: startup_instance, - }); - assert!( - direct_startup.error.is_none(), - "dynamic removal must not mutate the frozen direct startup plane" +#[test] +fn runner_config_reload_and_plugin_state_commit_as_one_active_generation() { + let temp = tempfile::tempdir().unwrap(); + let marker = temp.path().join("marker.log"); + let fake = fake_binary(); + let config_path = temp.path().join("runner.toml"); + write_runner_toml(&config_path, temp.path(), &fake.path, &marker, "normal"); + let config = super::super::config::load_config(&config_path).unwrap(); + let runtime = super::super::config::ReloadableRunnerConfig::new(config, config_path.clone()); + + let v1 = current_providers(runtime.plugins())[0].clone(); + assert_eq!(runtime.snapshot().generation, 1); + + write_runner_toml(&config_path, temp.path(), &fake.path, &marker, "check_v2"); + let applied = runtime.reload_config(1); + assert_eq!(applied.valid, Some(true)); + assert_eq!(applied.current_generation, Some(2)); + assert!(!applied.restart_required); + assert!(applied.restart_required_fields.is_empty()); + assert_eq!(runtime.snapshot().generation, 2); + let v2 = current_providers(runtime.plugins())[0].clone(); + assert_ne!(v2.provider_instance_id, v1.provider_instance_id); + assert_eq!(current_tools(runtime.plugins(), &v2)[0].name, "echo_v2"); + + write_runner_toml( + &config_path, + temp.path(), + &fake.path, + &marker, + "bad_version", + ); + let rejected = runtime.reload_config(2); + assert_eq!(rejected.valid, Some(false)); + assert_eq!(rejected.current_generation, Some(2)); + assert_eq!(rejected.error_code.as_deref(), Some("plugin_reload_failed")); + assert_eq!(runtime.snapshot().generation, 2); + let still_v2 = current_providers(runtime.plugins())[0].clone(); + assert_eq!(still_v2.provider_instance_id, v2.provider_instance_id); + assert_eq!( + current_tools(runtime.plugins(), &still_v2)[0].name, + "echo_v2" ); } diff --git a/crates/webcodex-tool-contracts/src/metadata.rs b/crates/webcodex-tool-contracts/src/metadata.rs index 7439c20b..95b1d761 100644 --- a/crates/webcodex-tool-contracts/src/metadata.rs +++ b/crates/webcodex-tool-contracts/src/metadata.rs @@ -178,6 +178,9 @@ pub const COMPUTER_DISPLAY_READ: &str = webcodex_core::authority::SCOPE_COMPUTER pub const COMPUTER_POINTER_CONTROL: &str = webcodex_core::authority::SCOPE_COMPUTER_POINTER_CONTROL; pub const COMPUTER_CLIPBOARD_READ: &str = webcodex_core::authority::SCOPE_COMPUTER_CLIPBOARD_READ; pub const COMPUTER_CLIPBOARD_WRITE: &str = webcodex_core::authority::SCOPE_COMPUTER_CLIPBOARD_WRITE; +pub const PLUGIN_INSPECT: &str = webcodex_core::authority::SCOPE_PLUGIN_INSPECT; +pub const PLUGIN_INVOKE: &str = webcodex_core::authority::SCOPE_PLUGIN_INVOKE; +pub const PLUGIN_MANAGE: &str = webcodex_core::authority::SCOPE_PLUGIN_MANAGE; /// Canonical Rust name for tools executed by a Runner. The serialized /// provider_id remains the historical `"agent"` compatibility value. diff --git a/crates/webcodex-tool-contracts/src/registry/input_schemas.rs b/crates/webcodex-tool-contracts/src/registry/input_schemas.rs index 2b548596..07fd002b 100644 --- a/crates/webcodex-tool-contracts/src/registry/input_schemas.rs +++ b/crates/webcodex-tool-contracts/src/registry/input_schemas.rs @@ -16,6 +16,7 @@ mod line_edits; mod lsp; mod memory; mod patches; +mod plugins; mod projects; mod runner_config; mod sessions; @@ -109,6 +110,7 @@ pub(super) use memory::{ memory_scope_purge_input_schema, memory_search_input_schema, memory_set_input_schema, }; pub use patches::{apply_patch_input_schema, apply_unified_diff_input_schema}; +pub use plugins::plugin_tool_input_schema; pub use projects::{ create_project_input_schema, register_project_input_schema, unregister_project_input_schema, }; diff --git a/crates/webcodex-tool-contracts/src/registry/input_schemas/plugins.rs b/crates/webcodex-tool-contracts/src/registry/input_schemas/plugins.rs new file mode 100644 index 00000000..25fc7536 --- /dev/null +++ b/crates/webcodex-tool-contracts/src/registry/input_schemas/plugins.rs @@ -0,0 +1,91 @@ +use serde_json::{json, Value}; +use webcodex_core::plugin::{ + PLUGIN_MAX_ARGUMENT_BYTES, PLUGIN_MAX_PROVIDER_ID_BYTES, PLUGIN_MAX_TOOL_NAME_BYTES, +}; + +pub fn plugin_tool_input_schema() -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "check", "reload", "describe", "call"], + "description": "Gateway operation. Discovery starts from an exact caller-visible Runner; call uses only an opaque binding from describe." + }, + "runner": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Exact caller-visible Runner client_id. Required for check/reload/describe and for Runner-scoped list operations." + }, + "plugin": { + "type": "string", + "minLength": 1, + "maxLength": PLUGIN_MAX_PROVIDER_ID_BYTES, + "description": "Logical Plugin provider id on the selected exact Runner." + }, + "tool": { + "type": "string", + "minLength": 1, + "maxLength": PLUGIN_MAX_TOOL_NAME_BYTES, + "description": "Logical provider-local Plugin tool name. Provider tools never become outer WebCodex MCP tool names." + }, + "binding": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^wc_pbind_[0-9a-f]{32}$", + "description": "Opaque exact Runner/provider/tool/schema binding returned by describe. It is observation identity, not authority." + }, + "arguments": { + "type": "object", + "description": format!("Plugin tool arguments matching the schema observed by describe; encoded payload is bounded to {PLUGIN_MAX_ARGUMENT_BYTES} bytes.") + } + }, + "required": ["action"], + "additionalProperties": false, + "allOf": [ + { + "if": {"properties": {"action": {"const": "check"}}, "required": ["action"]}, + "then": {"required": ["runner", "plugin"]} + }, + { + "if": {"properties": {"action": {"const": "reload"}}, "required": ["action"]}, + "then": { + "required": ["runner"], + "not": {"required": ["plugin"]} + } + }, + { + "if": {"properties": {"action": {"const": "describe"}}, "required": ["action"]}, + "then": {"required": ["runner", "plugin", "tool"]} + }, + { + "if": {"properties": {"action": {"const": "call"}}, "required": ["action"]}, + "then": { + "required": ["binding", "arguments"], + "not": {"anyOf": [ + {"required": ["runner"]}, + {"required": ["plugin"]}, + {"required": ["tool"]} + ]} + } + }, + { + "if": {"required": ["plugin"]}, + "then": {"required": ["runner"]} + }, + { + "if": {"required": ["tool"]}, + "then": {"properties": {"action": {"const": "describe"}}} + }, + { + "if": {"not": {"properties": {"action": {"const": "call"}}, "required": ["action"]}}, + "then": {"not": {"anyOf": [ + {"required": ["binding"]}, + {"required": ["arguments"]} + ]}} + } + ] + }) +} diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/runner_config.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/runner_config.rs index 5e77e819..8fc75567 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/runner_config.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/runner_config.rs @@ -9,6 +9,8 @@ const ERROR_CODES: &[&str] = &[ "config_parse_failed", "config_validation_failed", "provider_config_invalid", + "plugin_reload_failed", + "plugin_reload_busy", "config_generation_conflict", "runner_unavailable", "runner_replaced", diff --git a/crates/webcodex-tool-contracts/src/tool_catalog.rs b/crates/webcodex-tool-contracts/src/tool_catalog.rs index 01675f2e..15f2de48 100644 --- a/crates/webcodex-tool-contracts/src/tool_catalog.rs +++ b/crates/webcodex-tool-contracts/src/tool_catalog.rs @@ -242,6 +242,7 @@ pub const TOOL_DISCOVERY_GROUPS: &[ToolDiscoveryGroup] = &[ "list_runners", "runtime_status", "tool_manifest", + "plugin_tool", ], }, ToolDiscoveryGroup { @@ -425,6 +426,7 @@ pub const LOCAL_CODING_TOOL_NAMES: &[&str] = &[ // entry "work_on_project", "list_projects", + "plugin_tool", // exact coordinator assignment read + atomic completion "get_session_assignment", "complete_session_message", diff --git a/crates/webcodex-tool-contracts/src/tool_definition.rs b/crates/webcodex-tool-contracts/src/tool_definition.rs index 2bae7353..1becc4b3 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition.rs @@ -21,6 +21,7 @@ mod jobs; mod lsp; mod memory; mod patches; +mod plugins; mod runner_config; mod sessions; mod skills; @@ -486,6 +487,19 @@ const fn require_all_scopes( } } +const fn require_any_scopes( + definition: ToolDefinition, + scopes: &'static [&'static str], +) -> ToolDefinition { + ToolDefinition { + metadata: ToolMetadata { + authority: ToolAuthorityPolicy::RequireAny(scopes), + ..definition.metadata + }, + ..definition + } +} + macro_rules! bool_policy_modifier { ($function:ident, $field:ident) => { const fn $function(definition: ToolDefinition) -> ToolDefinition { @@ -572,6 +586,7 @@ const TOOL_DEFINITION_GROUPS: &[&[ToolDefinition]] = &[ diagnostics::DEFINITIONS, discovery::DEFINITIONS, runner_config::DEFINITIONS, + plugins::DEFINITIONS, jobs::EXECUTION_DEFINITIONS, files::SEARCH_DEFINITIONS, git::SUMMARY_DEFINITIONS, diff --git a/crates/webcodex-tool-contracts/src/tool_definition/plugins.rs b/crates/webcodex-tool-contracts/src/tool_definition/plugins.rs new file mode 100644 index 00000000..0e6c25a0 --- /dev/null +++ b/crates/webcodex-tool-contracts/src/tool_definition/plugins.rs @@ -0,0 +1,41 @@ +use super::ToolVisibility::ModelVisible; +use super::{ + adaptive_runtime_direct, def, model_spec, require_any_scopes, ToolDefinition, + TOOL_CATEGORY_RUNTIME, +}; +use crate::metadata::{ + ToolPathHint::None as NoPath, ToolRisk::RunControl, PLUGIN_INSPECT, PLUGIN_INVOKE, + PLUGIN_MANAGE, TOOL_PROVIDER_CONTROL, +}; +use crate::registry::input_schemas::plugin_tool_input_schema; + +const PLUGIN_GATEWAY_SCOPES: &[&str] = &[PLUGIN_INSPECT, PLUGIN_INVOKE, PLUGIN_MANAGE]; + +pub(super) const DEFINITIONS: &[ToolDefinition] = &[adaptive_runtime_direct( + require_any_scopes( + model_spec( + def( + "plugin_tool", + ModelVisible, + TOOL_CATEGORY_RUNTIME, + None, + TOOL_PROVIDER_CONTROL, + super::ToolSemanticContract { + effect: super::ToolEffect::Execute, + risk: RunControl, + approval: super::ToolApprovalPolicy::Standard, + idempotency: super::ToolIdempotency::NonIdempotent, + }, + None, + false, + NoPath, + false, + false, + ), + "Stable gateway for Runner-owned native Tool Plugins. Provider tools are never outer WebCodex MCP tools. Discovery begins at an exact caller-visible Runner; describe observes one exact Runner/provider/tool schema and returns an opaque binding; call accepts only binding + arguments, never retargets, relists, reloads, or blindly retries. Gateway visibility requires any Plugin scope, while each action separately enforces plugin:inspect, plugin:invoke, or plugin:manage before provider dispatch.", + plugin_tool_input_schema, + ), + PLUGIN_GATEWAY_SCOPES, + ), + 26, +)]; diff --git a/crates/webcodex-tool-runtime-contracts/src/tool_call.rs b/crates/webcodex-tool-runtime-contracts/src/tool_call.rs index ae993aaf..567490a9 100644 --- a/crates/webcodex-tool-runtime-contracts/src/tool_call.rs +++ b/crates/webcodex-tool-runtime-contracts/src/tool_call.rs @@ -16,6 +16,11 @@ use webcodex_core::job_observation::MAX_JOB_OBSERVATION_TOKEN_LEN; use webcodex_core::lsp_bridge::{ CallHierarchyDirection, DEFAULT_CALL_HIERARCHY_DEPTH, DEFAULT_CALL_HIERARCHY_LIMIT, }; +use webcodex_core::plugin::{ + validate_json_value as validate_plugin_json_value, + validate_provider_id as validate_plugin_provider_id, + validate_tool_name as validate_plugin_tool_name, PLUGIN_MAX_ARGUMENT_BYTES, +}; use webcodex_core::runner_protocol::ShellScriptLanguage; use webcodex_core::runtime_contract::{ validate_project_op_path, DEFAULT_OBSERVE_JOBS_TAIL_LINES, @@ -32,6 +37,125 @@ pub const TOOL_CALL_TOOL_FIELD: &str = "tool"; pub const TOOL_CALL_PARAMS_FIELD: &str = "params"; pub const TOOL_CALL_WRAPPER_FIELDS: &[&str] = &[TOOL_CALL_TOOL_FIELD, TOOL_CALL_PARAMS_FIELD]; +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PluginToolCall { + pub action: String, + #[serde(default)] + pub runner: Option, + #[serde(default)] + pub plugin: Option, + #[serde(default)] + pub tool: Option, + #[serde(default)] + pub binding: Option, + #[serde(default)] + pub arguments: Option, +} + +impl PluginToolCall { + fn validate(&self) -> Result<(), String> { + let valid_runner = |runner: &str| { + !runner.trim().is_empty() + && runner.len() <= 128 + && !runner.chars().any(char::is_control) + }; + if self + .runner + .as_deref() + .is_some_and(|runner| !valid_runner(runner)) + { + return Err("runner must be a bounded non-empty exact Runner client id".to_string()); + } + if let Some(plugin) = self.plugin.as_deref() { + validate_plugin_provider_id(plugin) + .map_err(|_| "plugin must be a valid bounded provider id".to_string())?; + } + if let Some(tool) = self.tool.as_deref() { + validate_plugin_tool_name(tool) + .map_err(|_| "tool must be a valid bounded provider-local tool name".to_string())?; + } + if let Some(binding) = self.binding.as_deref() { + let Some(random) = binding.strip_prefix("wc_pbind_") else { + return Err("binding must be a valid opaque Plugin binding".to_string()); + }; + if random.len() != 32 + || !random + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("binding must be a valid opaque Plugin binding".to_string()); + } + } + if let Some(arguments) = self.arguments.as_ref() { + if !arguments.is_object() { + return Err("arguments must be a JSON object".to_string()); + } + validate_plugin_json_value(arguments, PLUGIN_MAX_ARGUMENT_BYTES, "Plugin arguments") + .map_err(|_| "arguments exceed Plugin bounds".to_string())?; + } + + match self.action.as_str() { + "list" => { + if self.tool.is_some() || self.binding.is_some() || self.arguments.is_some() { + return Err("action=list accepts only optional runner and plugin".to_string()); + } + if self.plugin.is_some() && self.runner.is_none() { + return Err("action=list requires runner when plugin is provided".to_string()); + } + } + "check" => { + if self.runner.is_none() + || self.plugin.is_none() + || self.tool.is_some() + || self.binding.is_some() + || self.arguments.is_some() + { + return Err("action=check requires only runner and plugin".to_string()); + } + } + "reload" => { + if self.runner.is_none() + || self.plugin.is_some() + || self.tool.is_some() + || self.binding.is_some() + || self.arguments.is_some() + { + return Err("action=reload requires only runner".to_string()); + } + } + "describe" => { + if self.runner.is_none() + || self.plugin.is_none() + || self.tool.is_none() + || self.binding.is_some() + || self.arguments.is_some() + { + return Err( + "action=describe requires only runner, plugin, and tool".to_string() + ); + } + } + "call" => { + if self.binding.is_none() + || self.arguments.is_none() + || self.runner.is_some() + || self.plugin.is_some() + || self.tool.is_some() + { + return Err("action=call requires only binding and arguments".to_string()); + } + } + _ => { + return Err( + "action must be one of list, check, reload, describe, or call".to_string(), + ) + } + } + Ok(()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum SearchResultMode { @@ -2051,6 +2175,11 @@ pub enum ToolCall { expected_generation: u64, }, + /// Stable gateway for Runner-owned native Tool Plugins. The static + /// ToolDefinition is a worst-case discovery contract; execution policy is + /// classified from `action` before scope/session/permission governance. + PluginTool(PluginToolCall), + /// Return a structured runtime health/observability summary. /// /// This is a read-only observability tool: it never exposes tokens, @@ -2506,8 +2635,13 @@ impl ToolCall { }; wrapped.insert(TOOL_CALL_PARAMS_FIELD.to_string(), params); } - let call = serde_json::from_value(Value::Object(wrapped)) + let call: Self = serde_json::from_value(Value::Object(wrapped)) .map_err(|e| format!("invalid arguments for tool '{}': {}", name, e))?; + if let Self::PluginTool(plugin) = &call { + plugin + .validate() + .map_err(|error| format!("invalid arguments for tool '{}': {}", name, error))?; + } Ok((call, recorder_metadata)) } @@ -2670,6 +2804,7 @@ impl ToolCall { Self::ListRunners { .. } => "list_runners", Self::RunnerConfigCheck { .. } => "runner_config_check", Self::RunnerConfigReload { .. } => "runner_config_reload", + Self::PluginTool(_) => "plugin_tool", Self::RuntimeStatus { .. } => "runtime_status", Self::ReadToolTrace { .. } => "read_tool_trace", Self::ToolManifest { .. } => "tool_manifest", diff --git a/crates/webcodex-tool-runtime-contracts/src/tool_call_test_support.rs b/crates/webcodex-tool-runtime-contracts/src/tool_call_test_support.rs index 500eed03..6c203906 100644 --- a/crates/webcodex-tool-runtime-contracts/src/tool_call_test_support.rs +++ b/crates/webcodex-tool-runtime-contracts/src/tool_call_test_support.rs @@ -41,6 +41,9 @@ fn sample_tool_args_for_spec(spec: &ToolSpec) -> Value { "observe_jobs" => { args.insert("items".to_string(), json!([{"job_id": "job_123"}])); } + "plugin_tool" => { + args.insert("action".to_string(), json!("list")); + } _ => {} } Value::Object(args) @@ -124,6 +127,7 @@ fn sample_field_value(field: &str) -> Value { "head_commit" => json!("b".repeat(40)), "expected_head" => json!("a".repeat(40)), "expected_revision" => json!(format!("sha256:{}", "a".repeat(64))), + "expected_generation" => json!(1), "name" => json!("Private Drop"), "kind" => json!("note"), "message" => json!("hello"), diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index c635b4b3..f8489e45 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -381,6 +381,11 @@ same reload primitive, but is not required for first-class config control. Ident server/auth, project source, concurrency, capabilities, and transport changes remain restart-only where reported. +`[plugins]` is live-reloadable: generic Runner config reload and `plugin_tool reload` +share the same Plugin candidate admission/atomic-commit primitive. Plugin provider +tools remain Runner-local capabilities behind `plugin_tool`; they are never promoted +into outer MCP `tools/list` and do not require a Runner restart for discovery. + For a foreground test, run `webcodex-runner --profile workstation`. Advanced manual config generation uses `webcodex runner init`. diff --git a/docs/DEPLOYMENT.zh-CN.md b/docs/DEPLOYMENT.zh-CN.md index c07b42c8..c130a947 100644 --- a/docs/DEPLOYMENT.zh-CN.md +++ b/docs/DEPLOYMENT.zh-CN.md @@ -338,6 +338,11 @@ max_output_bytes = 262144 调用同一 reload primitive 的兼容 trigger,但 first-class config control 不依赖它。身份、 server/auth、项目来源、并发、能力与传输等字段在被报告为 restart-only 时仍需要重启。 +`[plugins]` 支持 live reload:generic Runner config reload 与 `plugin_tool reload` 共用同一个 +Plugin candidate admission/atomic-commit primitive。Plugin provider Tool 始终是 Runner-local +capability,只通过 `plugin_tool` 暴露,不会 promotion 到外层 MCP `tools/list`,也不需要为了 +Plugin discovery 重启 Runner。 + 前台测试可运行 `webcodex-runner --profile workstation`。高级手动生成配置用 `webcodex runner init`。 diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index a037f64e..1bc7c328 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -61,92 +61,97 @@ resolved from the prepared snapshot's `PATH`, not only from the Runner parent process PATH. Sensitive WebCodex process credentials are filtered from that environment. +Plugin candidate preparation may read the complete startup-bound `runner.toml` +because a provider can reference shell/profile inputs. The committed Plugin +state does not retain a second generic `ShellConfig` truth: it stores Plugin +provider config plus a derived Plugin-environment snapshot containing only the +base program/argv/dialect/PATH/env and referenced/default profile runtime, +environment, and init-script inputs. Unrelated persistent-shell controls do not +replace Plugin providers. Conversely, Plugin-relevant profile/environment +changes create a new provider instance on Plugin reload. `plugin:manage` still +cannot activate generic Runner shell configuration. + On Windows, native executables follow the Runner's normal `PATH`/`PATHEXT` rules. `.cmd` and `.bat` commands are rejected for this ABI because they require shell semantics; configure the native runtime executable instead. -## Startup and Dynamic planes - -There are exactly two Plugin planes. - -### Startup +## Runner-owned gateway model -When the Runner process starts, it reads `[plugins]`, prepares each provider, -starts it, performs `initialize`, calls `tools/list` **once**, validates the -bounded Tool schemas, canonicalizes the result, and freezes that catalog against -the exact `provider_instance_id`. The admitted provider process remains -persistent for calls, but ordinary discovery/call paths never ask that same -instance to list again. +Native Plugins are Runner-owned capabilities. Provider tools never join the +Server-global WebCodex tool namespace and are never appended to outer MCP +`tools/list`. A Plugin may define `safe_delete`, `runtime_status`, or any other +valid provider-local name without colliding with WebCodex built-ins or Plugin +tools on another Runner/provider. -The frozen provider catalog is immutable for that provider-instance lifetime. -Its canonical validated form has a stable SHA-256 catalog digest and exact Tool -count for stale detection/audit. A catalog can change only through a new -provider instance created by reload/restart. The startup plane itself remains -immutable for the lifetime of the Runner process. -Editing Plugin source or `runner.toml`, or using `plugin_tool reload`, does not -change first-class tools. A Runner restart creates a new Runner/provider -instance and performs startup admission again. +The stable model-facing entry is the first-class WebCodex tool `plugin_tool`. +Its ToolSpec is static and registered in the same canonical tool metadata path +as other WebCodex tools; its schema does not depend on Runner availability or +Plugin inventory. `tool_manifest(tool_name="plugin_tool")` therefore describes +the exact gateway contract even when no Plugin-capable Runner is online. -The complete provider catalog and the startup direct-eligible subset are -separate contracts. `PLUGIN_STARTUP_MAX_DIRECT_TOOLS` (currently 64) is a Runner -safety cap for the direct subset, not a claim that a provider can expose only 64 -Tools and not a per-model/per-surface routing policy. A bounded provider catalog -can therefore remain usable through `plugin_tool` even when no direct subset is -admitted. Server-side surface budgets remain a separate governance concern. +The same canonical `plugin_tool` request parser and action-aware gateway executor +serve MCP and the generic Tool Runtime used by OpenAPI/GPT Actions. A surface +that advertises `plugin_tool` can therefore call it; MCP does not have a separate +Plugin implementation. For generic `callRuntimeTool`, use the canonical nested +`params` envelope for the complete Plugin contract because the outer `tool` +field already selects `plugin_tool` and the provider-local `tool` name belongs +inside Plugin arguments: -If a startup tool name is valid, bounded, unique across the caller-visible -startup inventory, and does not conflict with a WebCodex-reserved tool name, it -appears directly in MCP `tools/list`. The model can call it normally, for -example: - -```text -search_symbol({"query":"RunnerRegistry"}) +```json +{"tool":"plugin_tool","params":{"action":"describe","runner":"my-runner","plugin":"repo-tools","tool":"safe_delete"}} +{"tool":"plugin_tool","params":{"action":"call","binding":"wc_pbind_...","arguments":{"path":"build/old.bin"}}} ``` -Duplicate or reserved names are not exposed directly. They remain reachable -through the dynamic `plugin_tool` describe/binding flow with an explicit Runner -and Plugin provider at describe time. - -A provider process failure does not prevent the Runner itself from registering. -If an admitted startup provider later fails, WebCodex retires that exact -provider instance and fails direct calls closed. It does not silently restart -the provider under the same identity. +The static ToolDefinition is intentionally a worst-case discovery contract. +Execution policy is classified from the validated action before Session or +permission governance: list/describe require `plugin:inspect` and are read-only; +call requires `plugin:invoke` and uses local-execution governance; check/reload +require `plugin:manage` and use management governance. One shared specialized +executor owns the authoritative Workflow Session lifecycle for both MCP and API +transports, so one Plugin invocation records one lifecycle. A +`recording_session_id` is always explicit provenance and is never inferred from +transport, window, credential, Runner, or a previous call. -### Dynamic - -Committed Plugin discovery uses one three-level ladder: +Routing always starts from the exact caller-visible Runner: ```text plugin_tool(action="list") -> caller-visible Plugin-capable Runners plugin_tool(action="list", runner="my-runner") - -> current effective providers on that exact Runner + -> current committed providers on that exact Runner plugin_tool(action="list", runner="my-runner", plugin="repo-tools") -> bounded current tool names/titles for that exact provider +plugin_tool(action="check", runner="my-runner", plugin="repo-tools") + -> disposable initialize + tools/list validation; no commit and no tools/call +plugin_tool(action="reload", runner="my-runner") + -> reread runner.toml, admit candidates, atomically replace committed provider set plugin_tool(action="describe", runner="my-runner", plugin="repo-tools", tool="search_symbol") -> { ..., "binding": "wc_pbind_..." } plugin_tool(action="call", binding="wc_pbind_...", arguments={"query":"foo"}) ``` -`list(runner, plugin)` is a runtime observation of the already committed/effective -provider. It resolves the exact caller-visible Runner and provider instance, then -reads that instance's frozen catalog locally. It does **not** issue another -provider `tools/list` round trip, reread `runner.toml`, start a disposable -candidate, run `check`, reload a provider, mutate the dynamic overlay, create a -binding, or alter `firstClassRestartRequired`. If the exact Runner/provider was -replaced, discovery fails closed and tells the caller to re-list; WebCodex never -silently resolves the same logical name to the replacement or replays the -operation. Full schemas remain exclusive to `describe`; list returns only -bounded discovery metadata such as tool names and optional titles. - -Provider-list `status` describes current effective runtime health. When the -logical provider also existed in the frozen startup catalog, `startupAdmission` -describes that separate frozen admission (`direct`, `secondary`, or `failed`), -with `startupAdmissionCode` when applicable. Reloading a dynamic provider does -not rewrite startup admission. Even `startupAdmission=direct` does **not** -guarantee that every caller sees a first-class MCP tool: Server-side reserved -names and caller-visible duplicate Plugin tool names can still suppress direct -exposure. +The identity hierarchy is always: + +```text +exact Runner instance + -> exact provider instance + -> provider-local tool + frozen schema observation +``` + +Same provider ids on different Runners, same tool names across Runners, and same +tool names in different providers on one Runner are normal. Only tool names +inside one provider catalog must be unique. + +At Runner startup, configured providers are eagerly prepared, initialized, and +listed once to form the first committed provider set. Every successful provider +instance owns a frozen validated catalog for its lifetime. Ordinary +list/describe/call reads that frozen catalog and never asks the same instance to +re-list. Catalog/schema changes require a new provider instance through reload. + +`list(runner, plugin)` observes only the currently committed provider instance. +It does not reread `runner.toml`, start a candidate, run check/reload, create a +binding, or call the Plugin tool. Full schemas remain exclusive to `describe`; +list returns bounded names/titles and safe health metadata only. `check` is the recommended preflight before `reload`. The Runner rereads its current `runner.toml`, locates only the requested provider, prepares the same @@ -154,7 +159,7 @@ shell/profile environment used by the normal Plugin runtime, resolves and **really starts** the configured executable, performs `initialize` and `tools/list`, validates the normal Plugin protocol/bounds, then terminates that candidate process tree. It never calls provider `tools/call` and never commits -the candidate into the dynamic overlay. Because a Native Plugin is an arbitrary +the candidate into the current provider set. Because a Native Plugin is an arbitrary local executable, its own startup/initialize/list behavior can still have external side effects; `check` is not a purely static config linter. @@ -172,12 +177,6 @@ control-sanitized local stderr ring for live providers and the most recent disposable `check` candidate; this local projection is not part of the Plugin gateway response. -`startupToolShape.eligible=true` means only that this checked provider's own Tool -definitions satisfy the stricter per-provider startup Tool bounds. It is **not** -a guarantee of final first-class MCP exposure: actual startup admission also -depends on whole-catalog bounds, and Server exposure still depends on reserved -names and caller-visible name uniqueness. - `call` has one dispatch identity: the opaque `binding` returned by that exact `describe`. It does not accept `runner`, `plugin`, or `tool` as call-time routing fields. Each describe creates an independent binding for the exact @@ -190,13 +189,14 @@ executable, environment, or raw Plugin config. Candidate management is serialized across both `check` and `reload`: a second check returns `plugin_check_busy`, while reload keeps the existing `plugin_reload_busy` result. Candidate operations are rejected with `NotStarted` instead of being queued. -This gate does not block list/describe/call or direct startup tools from using the -currently committed provider while a candidate is being prepared. +This gate does not block list/describe/call from using the currently committed +provider while a candidate is being prepared. -A changed provider is initialized and listed successfully before it replaces -the previous dynamic instance. A failed candidate leaves the previous working -dynamic instance intact. A removed provider is removed from the dynamic view. -None of those operations changes the frozen startup catalog. +A reload prepares the complete candidate provider set before commit. If any +candidate admission fails, the previous committed set remains intact. On +success, the committed set is replaced atomically; removed providers disappear +immediately. Old provider instances are retired, so old bindings fail closed. +There is no fallback to a retired or removed provider instance. This creates the normal development loop: @@ -210,19 +210,24 @@ edit Plugin code/config -> receive opaque binding -> plugin_tool call(binding, arguments) -> finish debugging - -> restart Runner - -> new startup admission / first-class promotion ``` -If startup provider A is first-class and reload creates dynamic provider B, -direct `search_symbol(...)` still calls A. A fresh dynamic `describe` observes B -and its returned binding calls only that exact B instance. Only a Runner restart -can replace the first-class startup binding. +Running `check` before reload never replaces the current provider set and never +creates a binding. The successful check candidate is disposed instead of being +reused by a later reload. -Running `check` before that reload does not replace A or the current dynamic -provider, does not alter `firstClassRestartRequired`, does not create a binding, -and does not change the direct MCP tool inventory. The successful check candidate -is disposed instead of being reused by a later reload. +`runner_config_reload` and `plugin_tool reload` share the same Plugin candidate +admission/commit primitive. Editing `[plugins]` does not require a Runner restart: +generic Runner config reload live-applies the Plugin candidate as part of the +same activation, while `plugin_tool reload` provides the narrower +`plugin:manage`-scoped operation. Plugin management authority never grants +authority to change unrelated Runner configuration. + +`runner_config_check` remains a structural Runner-config check: it reads/parses +the startup-bound `runner.toml`, validates configuration bounds, and classifies +restart-only fields without starting disposable Plugin processes. Use +`plugin_tool check(runner, plugin)` when you need executable resolution plus the +Plugin `initialize -> tools/list` protocol/admission preflight. ## WebCodex Plugin Protocol v1 @@ -336,15 +341,14 @@ separate worker so stderr flooding cannot backpressure stdout protocol progress. The local ring retains at most 64 lines, 1 KiB per line, and 32 KiB aggregate; control/non-UTF-8 bytes are projected safely and overlong lines are marked truncated. Stderr is never treated as protocol, inserted into a model ToolResult -or Workflow Session ledger, or automatically sent to the Server/startup -registration catalog. +or Workflow Session ledger, or automatically copied into Runner registration. ## OAuth Native Plugin authority is operation-specific: - `plugin:inspect` allows metadata observation such as list and describe. -- `plugin:invoke` allows `plugin_tool call` and first-class startup Plugin tools. +- `plugin:invoke` allows `plugin_tool call`. - `plugin:manage` allows development/management operations that can start or change local Plugin processes, currently check and reload. It does not imply `plugin:invoke`. @@ -365,10 +369,7 @@ If a Plugin does not start, verify the configured profile, prepared `PATH`, absolute `cwd`, runtime executable, and that stdout contains only protocol JSON lines. Use stderr for local diagnostics. -If a dynamic change works through `plugin_tool` but the direct MCP tool still -uses the old behavior, that is expected: restart the Runner to create a new -startup catalog and promote the new provider instance. - -If a tool is available through `plugin_tool` but not directly in MCP -`tools/list`, check for a duplicate caller-visible Plugin tool name or a -WebCodex-reserved name conflict. +Provider tools are intentionally absent from outer MCP `tools/list`; use +`plugin_tool list -> describe -> call`. If an old binding stops working after a +Runner/provider replacement or schema change, re-list and describe again. Never +blindly retry a call whose dispatch certainty is `outcome_unknown`. diff --git a/docs/PLUGINS.zh-CN.md b/docs/PLUGINS.zh-CN.md index 57e21fc9..261c282f 100644 --- a/docs/PLUGINS.zh-CN.md +++ b/docs/PLUGINS.zh-CN.md @@ -57,87 +57,93 @@ Plugin 不再发明一套 runtime/PATH/env 机制,而是真正复用 Runner `uv`、`bun` 这样的 bare command 会从 prepared snapshot 的 `PATH` 解析,而不是 只看 Runner 父进程的 PATH。敏感 WebCodex 进程凭据会被过滤。 +Plugin candidate preparation 可以读取完整的 startup-bound `runner.toml`,因为 provider +可能引用 shell/profile 输入;但 committed Plugin state 不再保存第二份 generic +`ShellConfig` truth。它只保存 Plugin provider config,以及派生出的 Plugin environment +snapshot:base program/argv/dialect/PATH/env,加上 default/referenced profile 真正需要的 +runtime、env 和 init-script 输入。`shell.max_persistent_shells` 等无关 persistent-shell +控制不会替换 Plugin provider;Plugin 相关 profile/env 改动则会在 reload 时产生新的 +provider instance。`plugin:manage` 因此仍然不能激活 generic Runner shell configuration。 + Windows 继续使用 Runner 已有的 `PATH` / `PATHEXT` native executable 规则; `.cmd` / `.bat` 需要 shell 语义,因此 Native Plugin ABI 会明确拒绝它们。 -## Startup 与 Dynamic 两个平面 - -Plugin 只有两个状态平面,没有额外 draft/stable/published 状态。 - -### Startup +## Runner-owned gateway 模型 -Runner 启动时读取 `[plugins]`,为每个 provider 准备环境并启动进程,依次完成 -`initialize`、**一次** `tools/list` 和 bounded schema validation,然后 canonicalize -catalog,并把它冻结到 exact `provider_instance_id`。成功 admission 的 provider 进程会 -保持 persistent,但普通 list/describe/call 不会再要求同一个 provider instance 重新 -执行 `tools/list`。 +Native Plugin 是 Runner-owned capability。provider 的具体 Tool 永远不会加入 +Server-global WebCodex tool namespace,也不会被追加到外层 MCP `tools/list`。Plugin +可以定义 `safe_delete`、`runtime_status` 或其他合法 provider-local 名称;它们不需要 +和 WebCodex builtin、其他 Runner 或同一 Runner 的其他 provider 做全局避让。 -冻结后的 provider catalog 在这个 provider-instance 生命周期内 immutable。canonical -validated catalog 会生成稳定 SHA-256 digest,并保留 exact Tool count,供 stale detection、 -audit 和后续 surface pinning 使用。catalog 只能通过 reload/restart 产生新的 provider -instance 后改变;startup plane 本身在整个 Runner process lifetime 中 immutable。修改 Plugin 源码、 -`runner.toml` 或执行 `plugin_tool reload` 都不会改变一级工具。只有 Runner restart -才会创建新的 Runner/provider instance 并重新做 startup admission。 +唯一稳定的 model-facing 入口是一等 WebCodex 工具 `plugin_tool`。它的 ToolSpec 走和 +其他 WebCodex 工具相同的 canonical metadata/registry 链路,schema 与 Runner 是否在线、 +安装了哪些 Plugin 无关。因此即使当前没有 Plugin-capable Runner, +`tool_manifest(tool_name="plugin_tool")` 也能返回准确 gateway contract。 -完整 provider catalog 与 startup direct-eligible subset 是两个不同 contract。 -`PLUGIN_STARTUP_MAX_DIRECT_TOOLS`(当前为 64)只是 Runner 对 direct subset 的安全上限, -不是“provider 最多只能有 64 个 Tool”,也不是每个 model/surface 的 routing budget。只要 -完整 provider catalog 仍在自身有界 contract 内,即使 direct subset 没有被 admission, -它仍可通过 `plugin_tool` 使用;Server-side surface budget 属于独立 governance 层。 +MCP 与 OpenAPI/GPT Actions 使用的 generic Tool Runtime 都复用同一个 canonical +`plugin_tool` parser 和 action-aware gateway executor;不存在 MCP Plugin 实现和 GPT +Plugin 实现两套逻辑。任何声明暴露 `plugin_tool` 的 canonical model surface 都可以实际 +调用它。对 generic `callRuntimeTool`,完整 Plugin contract 使用 canonical nested +`params`:外层 `tool` 已经用于选择 `plugin_tool`,provider-local `tool` 必须留在 Plugin +业务参数里: -如果 startup tool 名称合法、schema 在边界内、在当前 caller-visible startup -inventory 中唯一,并且没有和 WebCodex reserved tool 冲突,它会直接进入 MCP -`tools/list`。模型可以直接调用: - -```text -search_symbol({"query":"RunnerRegistry"}) +```json +{"tool":"plugin_tool","params":{"action":"describe","runner":"my-runner","plugin":"repo-tools","tool":"safe_delete"}} +{"tool":"plugin_tool","params":{"action":"call","binding":"wc_pbind_...","arguments":{"path":"build/old.bin"}}} ``` -重名或 reserved-name 冲突不会做一级暴露,但仍然可以在 `plugin_tool` dynamic -流程中,于 describe 阶段明确指定 Runner + Plugin 后获得 binding 使用。 - -单个 startup provider 启动失败不会拖死整个 Runner registration。已经 admission 的 -startup provider 如果之后失效,WebCodex 会 retire 这个 exact provider instance, -direct call fail closed;不会在同一个 identity 下静默重启。 +静态 ToolDefinition 只表达 worst-case discovery contract。真正执行 policy 会在校验后的 +`action` 上先分类,再进入 Session/permission governance:list/describe 只要求 +`plugin:inspect` 且是 read-only;call 要求 `plugin:invoke` 并走 local-execution governance; +check/reload 要求 `plugin:manage` 并走 management governance。MCP 与 API transport 共用一个 +specialized executor,它也是 Workflow Session lifecycle 的唯一 owner,因此一次 Plugin +调用只产生一套 authoritative lifecycle。`recording_session_id` 必须显式提供;不会从 +transport、window、credential、Runner 或之前的调用推断。 -### Dynamic - -已提交 Plugin 的 canonical discovery 使用三级 ladder: +routing 永远从 caller-visible 的 exact Runner 开始: ```text plugin_tool(action="list") -> 当前 caller 可见且支持 Plugin 的 Runners plugin_tool(action="list", runner="my-runner") - -> 该 exact Runner 当前 effective providers + -> 该 exact Runner 当前 committed providers plugin_tool(action="list", runner="my-runner", plugin="repo-tools") -> 该 exact provider 当前有界 tool name/title +plugin_tool(action="check", runner="my-runner", plugin="repo-tools") + -> disposable initialize + tools/list 校验;不提交,也不执行 tools/call +plugin_tool(action="reload", runner="my-runner") + -> reread runner.toml,admit candidates,原子替换 committed provider set plugin_tool(action="describe", runner="my-runner", plugin="repo-tools", tool="search_symbol") -> { ..., "binding": "wc_pbind_..." } plugin_tool(action="call", binding="wc_pbind_...", arguments={"query":"foo"}) ``` -`list(runner, plugin)` 只观察已经 committed/effective 的 runtime provider。它先解析 -caller-visible exact Runner 和 provider instance,然后直接读取这个 instance 的 frozen -catalog;**不会**再向 provider 发出 `tools/list` round trip。它也不会重新读取 -`runner.toml`、启动 disposable candidate、调用 `check`、reload provider、修改 dynamic -overlay、创建 binding 或改变 `firstClassRestartRequired`。如果 exact Runner/provider 已被 -replacement,discovery 会 fail closed 并要求重新 list;WebCodex 不会把同名 logical -provider 静默重解析到 replacement,也不会 replay。完整 schema 仍然只能由 `describe` -观察;list 只返回 tool name、可选 title 等有界 discovery metadata。 - -provider list 中的 `status` 只表示当前 effective runtime health。logical provider 如果也存在于 -frozen startup catalog,则 `startupAdmission` 单独表示冻结的 startup admission(`direct`、 -`secondary` 或 `failed`),必要时带 `startupAdmissionCode`。dynamic reload 不会重写这份 -startup admission。即使 `startupAdmission=direct`,也**不保证**所有 caller 最终都能在 MCP -`tools/list` 看见一级工具:Server 仍会因为 reserved name 或 caller-visible duplicate Plugin tool -name 抑制 direct exposure。 +identity 层级固定为: + +```text +exact Runner instance + -> exact provider instance + -> provider-local tool + frozen schema observation +``` + +不同 Runner 使用同名 provider、不同 Runner 使用同名 Tool、同一 Runner 的不同 provider +使用同名 Tool 都是正常情况。只要求单个 provider 自己的 Tool name 唯一。 + +Runner 启动时会 eager prepare 配置中的 provider,执行 initialize + 一次 tools/list,形成 +第一版 committed provider set。成功 provider instance 的 validated catalog 在其生命周期内 +冻结;普通 list/describe/call 只读这份 frozen catalog,不会偷偷 re-list。schema/catalog +变化必须通过 reload 产生新的 provider instance。 + +`list(runner, plugin)` 只观察当前 committed provider instance;不会重新读取 +`runner.toml`、启动 candidate、执行 check/reload、创建 binding 或调用 Plugin Tool。完整 +schema 仍只由 `describe` 返回;list 只返回 bounded name/title 与安全 health metadata。 `check` 是开发时 `reload` 之前推荐使用的预检。Runner 会重新读取当前 `runner.toml`,只定位 requested provider,使用正常 Plugin runtime 的同一套 shell/profile environment preparation 和 native executable resolution,**真实启动**配置的 executable,完成 `initialize`、`tools/list` 与普通 Plugin protocol/bounds validation,随后 终止整个 disposable candidate process tree。WebCodex 在 check 中绝不会调用 provider -`tools/call`,也不会把 candidate 提交到 dynamic overlay。由于 Native Plugin 本身就是任意 +`tools/call`,也不会把 candidate 提交到当前 provider set。由于 Native Plugin 本身就是任意 本地 executable,其 startup/initialize/list 自身仍可能产生外部副作用,因此 `check` 不是 纯静态配置 lint。 @@ -152,11 +158,6 @@ executable,完成 `initialize`、`tools/list` 与普通 Plugin protocol/bounds live provider 与最近一次 disposable `check` candidate 保留 bounded、control-sanitized 的 本机 stderr ring;这个 local projection 不属于 Plugin gateway response。 -`startupToolShape.eligible=true` 只表示这个 checked provider 自己的 Tool definitions 满足 -更严格的 provider-local startup Tool bounds;它**不保证**最终成为一级 MCP tool。实际 -startup admission 还受 whole-catalog bounds 影响,而 Server 一级暴露还取决于 reserved -name 与 caller-visible name uniqueness。 - `call` 的 dispatch identity 只有这个 opaque `binding`;call 阶段不再接受 `runner`、`plugin`、`tool` 作为路由字段。每次 describe 都会创建独立 binding,精确 记录当时观察到的 Runner instance、provider instance、tool name 和 schema;handle @@ -166,12 +167,12 @@ name 与 caller-visible name uniqueness。 自己重新读取自己的 `runner.toml`;Server 不会上传 executable、env 或 raw Plugin config。candidate management 在 `check` 与 `reload` 之间共同串行:第二个 check 返回 `plugin_check_busy`,reload 保持已有 `plugin_reload_busy`;都以 `NotStarted` 明确拒绝, -不排队等待。candidate 准备期间不会长期持有 current dynamic/provider session,因此 -list/describe/call 与 startup direct tools 仍可继续使用当前已提交 provider。 +不排队等待。candidate 准备期间不会长期持有 current committed provider session,因此 +list/describe/call 仍可继续使用当前已提交 provider。 -changed provider 会先准备 candidate,只有 initialize/list 成功后才替换旧 dynamic -instance;candidate 失败会保留已有可工作的 dynamic instance。被删除的 provider 会从 -dynamic view 中消失。这些操作都不会修改 frozen startup catalog。 +reload 会先准备完整 candidate provider set。任意 candidate admission 失败时,旧 committed +set 原样保留;全部成功后才原子替换。被删除的 provider 会立即消失,旧 provider instance +会 retire,因此旧 binding fail closed。不会 fallback 到任何已 retire 或已删除的 provider instance。 标准开发闭环是: @@ -185,18 +186,21 @@ dynamic view 中消失。这些操作都不会修改 frozen startup catalog。 -> 收到 opaque binding -> plugin_tool call(binding, arguments) -> 调试完成 - -> restart Runner - -> 新 startup admission / 一级工具 promotion ``` -如果 startup provider A 已经提供一级 `search_symbol`,reload 后产生 dynamic provider -B,那么直接 `search_symbol(...)` 仍然调用 A;新的 dynamic `describe` 会观察 B,返回的 -binding 也只会调用那个 exact B instance。只有 Runner restart 才能替换一级 startup -绑定。 +reload 前运行 `check` 不会替换当前 committed provider set,也不会创建 binding。成功的 +check candidate 会被销毁,不会偷偷复用成下一次 reload provider。 -在 reload 前运行 `check` 不会替换 A 或当前 dynamic provider,不会改变 -`firstClassRestartRequired`,不会创建 binding,也不会改变 direct MCP tool inventory。 -成功的 check candidate 会被销毁,不会偷偷复用成下一次 reload provider。 +`runner_config_reload` 与 `plugin_tool reload` 共用同一个 Plugin candidate +admission/commit primitive。修改 `[plugins]` 不需要为了 Plugin 生效而重启 Runner:generic +Runner config reload 会在同一次 activation 中 live apply Plugin candidate;`plugin_tool +reload` 则提供更窄、只需要 `plugin:manage` 的专门入口。Plugin management authority 不会 +因此获得修改其他 Runner config 的权限。 + +`runner_config_check` 仍然只是 Runner config 的结构性检查:读取/解析 startup-bound +`runner.toml`、验证配置边界并分类 restart-only 字段,不会启动 disposable Plugin process。 +需要检查 executable resolution 以及 Plugin `initialize -> tools/list` protocol/admission 时, +使用 `plugin_tool check(runner, plugin)`。 ## WebCodex Plugin Protocol v1 @@ -295,15 +299,14 @@ loss、process death 或 response timeout 都返回 `OutcomeUnknown`,不会自 stdout 只用于 protocol。stderr 由独立 worker 持续 drain,因此 stderr flood 不会反向 阻塞 stdout protocol。local ring 最多保留 64 行、每行 1 KiB、aggregate 32 KiB;control / 非 UTF-8 byte 会做安全 projection,超长行会标记 truncated。stderr 不会被当作 protocol, -不会进入 model ToolResult 或 Workflow Session ledger,也不会自动发送到 Server / startup -registration catalog。 +不会进入 model ToolResult 或 Workflow Session ledger,也不会自动复制进 Runner registration。 ## OAuth Native Plugin authority 按 operation 拆分: - `plugin:inspect` 允许 list、describe 等纯 metadata observation; -- `plugin:invoke` 允许 `plugin_tool call` 和 startup Plugin 一级工具; +- `plugin:invoke` 允许 `plugin_tool call`; - `plugin:manage` 允许会启动或改变本地 Plugin process 的开发/管理操作,目前是 check 和 reload;它本身不会隐式授予 `plugin:invoke`; - 以上 scope 都不属于 direct shared-key model baseline; @@ -321,9 +324,7 @@ permission policy;WebCodex 不会从 MCP transport identity 推断 Workflow Se Plugin 无法启动时,优先检查 profile、prepared `PATH`、绝对 `cwd`、runtime executable, 以及 stdout 是否只包含 protocol JSON line;本地诊断写 stderr。 -如果 dynamic 修改通过 `plugin_tool` 已经生效,但一级工具仍然是旧行为,这是预期语义: -restart Runner 后才会生成新的 startup catalog 并完成 promotion。 - -如果 tool 能通过 `plugin_tool` 使用,却没有直接出现在 MCP `tools/list`,检查当前 -caller-visible inventory 是否存在同名 Plugin tool,或是否与 WebCodex reserved name -冲突。 +provider Tool **本来就不会**直接出现在外层 MCP `tools/list`;使用 +`plugin_tool list -> describe -> call`。Runner/provider replacement 或 schema 变化后旧 +binding 失效时,重新 list + describe。只要 dispatch certainty 是 `outcome_unknown`,就不要 +盲目重试。 diff --git a/docs/RUNNER.md b/docs/RUNNER.md index 05f43787..6b1a013f 100644 --- a/docs/RUNNER.md +++ b/docs/RUNNER.md @@ -456,13 +456,18 @@ of finding its PID or sending signals manually: 4. Inspect `runtime_status(client_id=...)` (or `list_runners`) after reload. `runner_config_reload` never writes `runner.toml`; it only activates the candidate -already on disk. Hot-reloadable policy, shell, and static SSH-resource changes can +already on disk. Hot-reloadable policy, shell, Native Plugin, and static SSH-resource changes can become active immediately, while fields reported in `restart_required_fields` remain startup-only until the Runner restarts. Invalid candidates leave the active snapshot and generation unchanged. Managed `ssh_resource` mutations are different: they use a frozen startup snapshot and require a Runner restart exactly when the tool reports `restart_required=true`. +Plugin configuration is live-applied through the same validated Plugin candidate +admission/commit primitive used by `plugin_tool reload`; changing `[plugins]` is +not a restart-only operation. `plugin_tool reload` remains the narrower +`plugin:manage`-scoped entry when only Plugin state should be reloaded. + On Unix, service reload/SIGHUP remains an optional compatibility trigger and calls the same authoritative reload primitive. Windows and macOS use the first-class operation directly; no signal emulation or PID management is required. When a diff --git a/docs/RUNNER.zh-CN.md b/docs/RUNNER.zh-CN.md index 04c106a8..b4579984 100644 --- a/docs/RUNNER.zh-CN.md +++ b/docs/RUNNER.zh-CN.md @@ -371,11 +371,15 @@ User scope 使用 `systemctl --user`;system scope 使用 `/etc/systemd/system` 4. reload 后调用 `runtime_status(client_id=...)`(或 `list_runners`)检查当前运行状态。 `runner_config_reload` 不写 `runner.toml`,只激活磁盘上已经存在的 candidate。policy、 -shell 与静态 SSH resource 中可热加载的字段可以立即生效;`restart_required_fields` +shell、Native Plugin 与静态 SSH resource 中可热加载的字段可以立即生效;`restart_required_fields` 报告的字段仍保持 startup-only,重启前不会假装已在线生效。无效 candidate 保留旧 active snapshot 与 generation。`ssh_resource` managed mutation 不同:它使用 frozen startup snapshot,且只在工具返回 `restart_required=true` 时要求重启 Runner。 +Plugin 配置通过与 `plugin_tool reload` 相同的 validated candidate admission/commit primitive +进行 live apply,因此修改 `[plugins]` 不属于 restart-only 变更。只想 reload Plugin state 时, +仍使用权限更窄、要求 `plugin:manage` 的 `plugin_tool reload`。 + Unix 上 service reload/SIGHUP 仍可作为兼容 trigger,并调用同一个 authoritative reload primitive。Windows 与 macOS 直接使用 first-class operation,不模拟信号,也不需要 PID 管理。对于可安全分类的 validation failure,config operation 只报告闭集、非 secret 的 diff --git a/plugins/safe-delete/README.md b/plugins/safe-delete/README.md index 6fb2974b..8ccda38c 100644 --- a/plugins/safe-delete/README.md +++ b/plugins/safe-delete/README.md @@ -65,21 +65,20 @@ timeout_secs = 30 During development, use the normal Plugin loop: ```text -plugin_tool check --> plugin_tool reload --> plugin_tool list --> plugin_tool describe --> plugin_tool call +plugin_tool(action="check", runner="my-runner", plugin="safe-delete") +-> plugin_tool(action="reload", runner="my-runner") +-> plugin_tool(action="list", runner="my-runner", plugin="safe-delete") +-> plugin_tool(action="describe", runner="my-runner", plugin="safe-delete", tool="safe_delete") +-> plugin_tool(action="call", binding="wc_pbind_...", arguments={"path":"build/old-output.bin"}) ``` -Restart the Runner when you want the newly admitted `safe_delete` tool to become -eligible for first-class startup exposure. +No Runner restart is required for Plugin discovery after a successful reload. +`safe_delete` remains provider-local and never becomes an outer MCP tool name. ## Tool -```text -safe_delete({"path":"build/old-output.bin"}) -``` +Call it only through the opaque binding returned by `plugin_tool describe`; do +not call outer MCP `tools/call(name="safe_delete")`. Possible structured outcomes are `trashed`, `already_absent`, `rejected`, `failed`, and `unknown`. diff --git a/plugins/safe-delete/README.zh-CN.md b/plugins/safe-delete/README.zh-CN.md index db8dfa4d..f5d91e63 100644 --- a/plugins/safe-delete/README.zh-CN.md +++ b/plugins/safe-delete/README.zh-CN.md @@ -54,20 +54,20 @@ timeout_secs = 30 开发阶段使用标准 Plugin 闭环: ```text -plugin_tool check --> plugin_tool reload --> plugin_tool list --> plugin_tool describe --> plugin_tool call +plugin_tool(action="check", runner="my-runner", plugin="safe-delete") +-> plugin_tool(action="reload", runner="my-runner") +-> plugin_tool(action="list", runner="my-runner", plugin="safe-delete") +-> plugin_tool(action="describe", runner="my-runner", plugin="safe-delete", tool="safe_delete") +-> plugin_tool(action="call", binding="wc_pbind_...", arguments={"path":"build/old-output.bin"}) ``` -需要让新的 `safe_delete` 进入 startup first-class 候选时,再重启 Runner。 +成功 reload 后不需要重启 Runner 才能发现/调用 Plugin。`safe_delete` 始终是 +provider-local Tool,不会成为外层 MCP tool name。 ## Tool -```text -safe_delete({"path":"build/old-output.bin"}) -``` +只能使用 `plugin_tool describe` 返回的 opaque binding 调用它;不要直接调用外层 MCP +`tools/call(name="safe_delete")`。 结构化结果可能是 `trashed`、`already_absent`、`rejected`、`failed` 或 `unknown`。 diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index f590565b..9bbc4204 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -23,16 +23,24 @@ use crate::tool_runtime::tool_definition::{ }; #[cfg(test)] use crate::tool_runtime::ToolResult; -use crate::tool_runtime::{registered_tool_specs, ToolRuntime, ToolSpec}; +use crate::tool_runtime::{registered_tool_specs, ToolCall, ToolRuntime, ToolSpec}; use serde::Deserialize; use serde_json::{json, Value}; -use std::collections::{BTreeMap, BTreeSet}; -use std::sync::OnceLock; fn filter_specs_for_oauth(mut specs: Vec, auth: Option<&AuthContext>) -> Vec { - if auth.is_some_and(AuthContext::is_oauth_token) { - specs.retain(|spec| check_runtime_tool_scope(auth, &spec.name).is_ok()); - } + let oauth_scope_projection = auth.is_some_and(AuthContext::is_oauth_token); + specs.retain(|spec| { + let authority = crate::tool_runtime::metadata::lookup_tool_metadata(&spec.name) + .map(|metadata| metadata.authority); + matches!( + authority, + Some(webcodex_core::authority::ToolAuthorityPolicy::RequireAny(_)) + ) + .then(|| check_runtime_tool_scope(auth, &spec.name).is_ok()) + .unwrap_or_else(|| { + !oauth_scope_projection || check_runtime_tool_scope(auth, &spec.name).is_ok() + }) + }); specs } @@ -43,7 +51,16 @@ fn full_operator_runtime_specs_for_auth( let oauth_scope_projection = auth.is_some_and(AuthContext::is_oauth_token); let mut specs = registered_tool_specs(); specs.retain(|spec| { - !oauth_scope_projection || check_runtime_tool_scope(auth, &spec.name).is_ok() + let authority = crate::tool_runtime::metadata::lookup_tool_metadata(&spec.name) + .map(|metadata| metadata.authority); + matches!( + authority, + Some(webcodex_core::authority::ToolAuthorityPolicy::RequireAny(_)) + ) + .then(|| check_runtime_tool_scope(auth, &spec.name).is_ok()) + .unwrap_or_else(|| { + !oauth_scope_projection || check_runtime_tool_scope(auth, &spec.name).is_ok() + }) }); if stateless_2026 { specs.extend( @@ -131,7 +148,6 @@ fn adaptive_runtime_gateway_target_allowed(target: &str, stateless_2026: bool) - return false; } if target == crate::mcp_gateway::MCP_TOOL_NAME - || target == crate::plugin_gateway::PLUGIN_TOOL_NAME || target == crate::ssh_resource_gateway::SSH_RESOURCE_TOOL_NAME { return true; @@ -141,91 +157,6 @@ fn adaptive_runtime_gateway_target_allowed(target: &str, stateless_2026: bool) - .any(|spec| spec.name == target) } -fn startup_plugin_reserved_tool_names() -> &'static BTreeSet { - static RESERVED: OnceLock> = OnceLock::new(); - RESERVED.get_or_init(|| { - let mut names = registered_tool_specs() - .into_iter() - .map(|spec| spec.name) - .collect::>(); - for spec in crate::tool_runtime::skill_runtime_tool_specs() - .into_iter() - .chain(crate::tool_runtime::skill_management_tool_specs()) - .chain(crate::tool_runtime::memory_runtime_tool_specs()) - .chain(crate::tool_runtime::memory_management_tool_specs()) - .chain(crate::tool_runtime::operator_diagnostic_tool_specs()) - { - names.insert(spec.name); - } - names.insert(ADAPTIVE_RUNTIME_GATEWAY_TOOL_NAME.to_string()); - names.insert(crate::mcp_gateway::MCP_TOOL_NAME.to_string()); - names.insert(crate::plugin_gateway::PLUGIN_TOOL_NAME.to_string()); - names.insert(crate::ssh_resource_gateway::SSH_RESOURCE_TOOL_NAME.to_string()); - names - }) -} - -enum StartupPluginDirectResolution { - None, - Unique(crate::plugin_gateway::StartupPluginToolCandidate), - Ambiguous, -} - -async fn resolve_startup_plugin_direct_tool( - runtime: &ToolRuntime, - auth: Option<&AuthContext>, - name: &str, -) -> StartupPluginDirectResolution { - if startup_plugin_reserved_tool_names().contains(name) { - return StartupPluginDirectResolution::None; - } - let mut matches = crate::plugin_gateway::startup_tool_candidates(runtime, auth) - .await - .into_iter() - .filter(|candidate| candidate.tool.name == name); - let Some(first) = matches.next() else { - return StartupPluginDirectResolution::None; - }; - if matches.next().is_some() { - StartupPluginDirectResolution::Ambiguous - } else { - StartupPluginDirectResolution::Unique(first) - } -} - -async fn append_startup_plugin_direct_tools( - runtime: &ToolRuntime, - auth: Option<&AuthContext>, - result: &mut Value, -) { - if !crate::plugin_gateway::invoke_authorized(auth) { - return; - } - let reserved = startup_plugin_reserved_tool_names(); - let mut by_name = - BTreeMap::>::new(); - for candidate in crate::plugin_gateway::startup_tool_candidates(runtime, auth).await { - if reserved.contains(&candidate.tool.name) { - continue; - } - by_name - .entry(candidate.tool.name.clone()) - .or_default() - .push(candidate); - } - let Some(tools) = result.get_mut("tools").and_then(Value::as_array_mut) else { - return; - }; - for candidates in by_name.into_values() { - if candidates.len() != 1 { - continue; - } - if let Ok(tool) = serde_json::to_value(&candidates[0].tool) { - tools.push(tool); - } - } -} - fn unwrap_adaptive_runtime_gateway_arguments( arguments: Value, stateless_2026: bool, @@ -657,21 +588,11 @@ pub(super) async fn handle_list( if stateless_2026 { add_stateless_workflow_recorder_metadata(&mut result, model_surface); } - // Plugin ToolSpecs are appended only after WebCodex stateless - // metadata augmentation so arbitrary Plugin schemas remain exact. - // The list is derived solely from frozen startup registration and - // caller-visible unique names; no provider process is contacted. - append_startup_plugin_direct_tools(runtime, auth, &mut result).await; if crate::mcp_gateway::authorized(auth) { if let Some(tools) = result.get_mut("tools").and_then(Value::as_array_mut) { tools.push(crate::mcp_gateway::tool_spec()); } } - if crate::plugin_gateway::authorized(auth) { - if let Some(tools) = result.get_mut("tools").and_then(Value::as_array_mut) { - tools.push(crate::plugin_gateway::tool_spec()); - } - } if crate::ssh_resource_gateway::authorized(auth) { if let Some(tools) = result.get_mut("tools").and_then(Value::as_array_mut) { tools.push(crate::ssh_resource_gateway::tool_spec()); @@ -1270,35 +1191,11 @@ pub(super) async fn handle_call( params.name = target; params.arguments = arguments; } - let startup_plugin_resolution = if matches!( - params.name.as_str(), - crate::mcp_gateway::MCP_TOOL_NAME - | crate::plugin_gateway::PLUGIN_TOOL_NAME - | crate::ssh_resource_gateway::SSH_RESOURCE_TOOL_NAME - ) { - StartupPluginDirectResolution::None - } else { - resolve_startup_plugin_direct_tool(runtime, auth, ¶ms.name).await - }; if let Some(lc) = lifecycle.as_deref() { let audit = if params.name == crate::plugin_gateway::PLUGIN_TOOL_NAME { - crate::plugin_gateway::audit_arguments_with_identity(runtime, ¶ms.arguments, auth) - .await + crate::plugin_gateway::audit_arguments(¶ms.arguments) } else if params.name == crate::ssh_resource_gateway::SSH_RESOURCE_TOOL_NAME { crate::ssh_resource_gateway::audit_arguments(¶ms.arguments) - } else if let StartupPluginDirectResolution::Unique(candidate) = &startup_plugin_resolution - { - crate::plugin_gateway::audit_startup_direct(candidate) - } else if matches!( - startup_plugin_resolution, - StartupPluginDirectResolution::Ambiguous - ) { - json!({ - "source": "plugin", - "tool": params.name, - "arguments_present": true, - "provider_identity": "ambiguous" - }) } else if via_adaptive_runtime_gateway { json!({"tool": params.name, "arguments_present": true}) } else { @@ -1348,53 +1245,40 @@ pub(super) async fn handle_call( return McpOutcome::BadRequest(rpc_error(id, -32602, message)); } }; - let policy = match crate::plugin_gateway::operation_policy(¶ms.arguments) { - Ok(policy) => policy, - Err(_) => { - let audit = crate::plugin_gateway::audit_arguments_with_identity( - runtime, - ¶ms.arguments, - auth, - ) - .await; - if let Some(lc) = lifecycle.as_deref() { - lc.capture_payload("effective_arguments", &audit); - } - let result = crate::plugin_gateway::call(runtime, params.arguments, auth).await; - let ok = result.get("isError").and_then(Value::as_bool) != Some(true); + let call = match ToolCall::from_tool_name(¶ms.name, params.arguments.clone()) { + Ok(call) => call, + Err(message) => { if let Some(lc) = lifecycle.as_deref() { - lc.dispatch_finished(true, Some(ok), if ok { "success" } else { "tool_error" }); + lc.dispatch_failed("invalid_arguments"); + lc.dispatch_finished(false, Some(false), "invalid_arguments"); } - return McpOutcome::Ok(rpc_result( - id, - if stateless_2026 { - mcp_stateless_result(result, false) - } else { - result - }, - )); + return McpOutcome::BadRequest(rpc_error(id, -32602, message)); } }; - let audit = - crate::plugin_gateway::audit_arguments_with_identity(runtime, ¶ms.arguments, auth) - .await; - let permit = match runtime - .govern_specialized_invocation( - ¶ms.name, - policy, - recording_session_id.as_deref(), - auth, - &audit, - ) - .await + let ToolCall::PluginTool(plugin) = call else { + unreachable!("plugin_tool parser must yield ToolCall::PluginTool"); + }; + if let Some(lc) = lifecycle.as_deref() { + lc.capture_payload( + "effective_arguments", + &crate::plugin_gateway::audit_arguments(¶ms.arguments), + ); + } + let invocation = match crate::plugin_gateway::invoke( + runtime, + plugin, + recording_session_id.as_deref(), + auth, + crate::tool_runtime::sessions::SessionTransport::Mcp, + ) + .await { - Ok(permit) => permit, + Ok(invocation) => invocation, Err(SpecializedGovernanceDenial::Scope { required_scope, description, }) => { if let Some(lc) = lifecycle.as_deref() { - lc.capture_payload("specialized_governance", &policy.audit_projection()); lc.dispatch_failed("forbidden"); lc.dispatch_finished(false, Some(false), "forbidden"); } @@ -1402,7 +1286,6 @@ pub(super) async fn handle_call( } Err(SpecializedGovernanceDenial::Tool(result)) => { if let Some(lc) = lifecycle.as_deref() { - lc.capture_payload("specialized_governance", &policy.audit_projection()); lc.dispatch_failed("specialized_governance_denied"); lc.dispatch_finished(true, Some(false), "tool_error"); } @@ -1417,23 +1300,15 @@ pub(super) async fn handle_call( )); } }; + let ok = invocation.success(); if let Some(lc) = lifecycle.as_deref() { - lc.capture_payload("effective_arguments", &audit); - lc.capture_payload("specialized_governance", &permit.audit_projection()); - } - let result = crate::plugin_gateway::call(runtime, params.arguments, auth).await; - let ok = result.get("isError").and_then(Value::as_bool) != Some(true); - let failure_kind = result - .pointer("/structuredContent/error/code") - .and_then(Value::as_str); - let dispatch_certainty = result - .pointer("/structuredContent/dispatchState") - .and_then(Value::as_str) - .unwrap_or("completed"); - runtime.finish_specialized_invocation(permit, ok, dispatch_certainty, failure_kind); - if let Some(lc) = lifecycle.as_deref() { + lc.capture_payload( + "specialized_governance", + &invocation.policy().audit_projection(), + ); lc.dispatch_finished(true, Some(ok), if ok { "success" } else { "tool_error" }); } + let result = invocation.to_mcp_result(); return McpOutcome::Ok(rpc_result( id, if stateless_2026 { @@ -1484,6 +1359,7 @@ pub(super) async fn handle_call( .govern_specialized_invocation( ¶ms.name, policy, + crate::tool_runtime::sessions::SessionTransport::Mcp, recording_session_id.as_deref(), auth, &audit, @@ -1545,114 +1421,6 @@ pub(super) async fn handle_call( }, )); } - match startup_plugin_resolution { - StartupPluginDirectResolution::Unique(candidate) => { - let recording_session_id = match strip_recording_session_id(&mut params.arguments) { - Ok(session_id) => session_id, - Err(message) => { - if let Some(lc) = lifecycle.as_deref() { - lc.dispatch_failed("invalid_arguments"); - lc.dispatch_finished(false, Some(false), "invalid_arguments"); - } - return McpOutcome::BadRequest(rpc_error(id, -32602, message)); - } - }; - let policy = crate::plugin_gateway::PluginOperation::Call.policy(); - let audit = crate::plugin_gateway::audit_startup_direct(&candidate); - let permit = match runtime - .govern_specialized_invocation( - ¶ms.name, - policy, - recording_session_id.as_deref(), - auth, - &audit, - ) - .await - { - Ok(permit) => permit, - Err(SpecializedGovernanceDenial::Scope { - required_scope, - description, - }) => { - if let Some(lc) = lifecycle.as_deref() { - lc.capture_payload("specialized_governance", &policy.audit_projection()); - lc.dispatch_failed("forbidden"); - lc.dispatch_finished(false, Some(false), "forbidden"); - } - return scope_forbidden(auth, Some(required_scope), description); - } - Err(SpecializedGovernanceDenial::Tool(result)) => { - if let Some(lc) = lifecycle.as_deref() { - lc.capture_payload("specialized_governance", &policy.audit_projection()); - lc.dispatch_failed("specialized_governance_denied"); - lc.dispatch_finished(true, Some(false), "tool_error"); - } - let result = mcp_runtime_tool_result_fallback(result); - return McpOutcome::Ok(rpc_result( - id, - if stateless_2026 { - mcp_stateless_result(result, false) - } else { - result - }, - )); - } - }; - if let Some(lc) = lifecycle.as_deref() { - lc.capture_payload("effective_arguments", &audit); - lc.capture_payload("specialized_governance", &permit.audit_projection()); - } - let result = crate::plugin_gateway::call_startup_direct( - runtime, - &candidate, - params.arguments, - auth, - ) - .await; - let ok = result.get("isError").and_then(Value::as_bool) != Some(true); - let failure_kind = result - .pointer("/structuredContent/error/code") - .and_then(Value::as_str); - let dispatch_certainty = result - .pointer("/structuredContent/dispatchState") - .and_then(Value::as_str) - .unwrap_or("completed"); - runtime.finish_specialized_invocation(permit, ok, dispatch_certainty, failure_kind); - if let Some(lc) = lifecycle.as_deref() { - lc.dispatch_finished(true, Some(ok), if ok { "success" } else { "tool_error" }); - } - return McpOutcome::Ok(rpc_result( - id, - if stateless_2026 { - mcp_stateless_result(result, false) - } else { - result - }, - )); - } - StartupPluginDirectResolution::Ambiguous => { - if let Some(outcome) = require_mcp_scope(auth, crate::auth::SCOPE_PLUGIN_INVOKE) { - if let Some(lc) = lifecycle.as_deref() { - lc.dispatch_failed("forbidden"); - lc.dispatch_finished(false, Some(false), "forbidden"); - } - return outcome; - } - if let Some(lc) = lifecycle.as_deref() { - lc.dispatch_failed("ambiguous_plugin_tool"); - lc.dispatch_finished(false, Some(false), "ambiguous_plugin_tool"); - } - return McpOutcome::BadRequest(rpc_error( - id, - -32602, - format!( - "startup Plugin tool '{}' is ambiguous across caller-visible Runners/providers; use plugin_tool with an exact runner and plugin", - params.name - ), - )); - } - StartupPluginDirectResolution::None => {} - } // Focused model surfaces reject direct tools they do not advertise at the // MCP boundary. Adaptive gateway calls are already reduced to an allowed // full-operator target and continue through the normal runtime checks. diff --git a/src/mcp_tests/oauth_scope.rs b/src/mcp_tests/oauth_scope.rs index 279ce25d..3e3fc339 100644 --- a/src/mcp_tests/oauth_scope.rs +++ b/src/mcp_tests/oauth_scope.rs @@ -18,9 +18,8 @@ fn oauth_mcp_service(scopes: &str) -> (tempfile::TempDir, Service, String) { oauth_mcp_service_with_surface(scopes, ModelSurface::LocalCoding) } -async fn oauth_mcp_service_with_startup_plugin( +async fn oauth_mcp_service_with_plugin_runner( scopes: &str, - tool_name: &str, ) -> (tempfile::TempDir, Service, String) { let config = test_config_oauth2(Some("secret")); let (tmp, db) = test_db(); @@ -42,30 +41,7 @@ async fn oauth_mcp_service_with_startup_plugin( hostname: None, host_context: None, capabilities, - policy: Some(crate::runner_protocol::RunnerPolicySummary { - plugin_providers: Some(vec![webcodex_core::plugin::StartupPluginProvider { - provider_id: "repo-tools".to_string(), - provider_instance_id: "oauth-plugin-provider-instance".to_string(), - name: "Repo Tools".to_string(), - status: "ready".to_string(), - error_code: None, - catalog_tool_count: 1, - catalog_digest: None, - tools: vec![webcodex_core::plugin::PluginTool { - name: tool_name.to_string(), - title: None, - description: Some("OAuth first-class Plugin test tool".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"value": {"type": "string"}}, - "additionalProperties": false - }), - output_schema: None, - annotations: None, - }], - }]), - ..Default::default() - }), + policy: Some(Default::default()), process_started_at: None, build: None, job_concurrency_limit: None, @@ -264,9 +240,7 @@ async fn oauth2_native_plugin_catalog_and_call_require_explicit_plugin_scope() { "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, "arguments": { "action": "call", - "runner": "runner-a", - "plugin": "repo-tools", - "tool": "echo", + "binding": "wc_pbind_00000000000000000000000000000000", "arguments": {"value": "hello"} } }), @@ -275,8 +249,8 @@ async fn oauth2_native_plugin_catalog_and_call_require_explicit_plugin_scope() { assert_eq!(status, StatusCode::OK, "body: {body:?}"); assert_eq!(body["result"]["isError"], true); assert_eq!( - body["result"]["structuredContent"]["error"]["code"], "invalid_arguments", - "the pre-binding call shape must be rejected instead of treated as a describe lookup" + body["result"]["structuredContent"]["error"]["code"], "describe_required", + "a syntactically valid call must reach binding resolution after plugin:invoke scope" ); let (status, body, challenge) = oauth_mcp_request( @@ -285,7 +259,7 @@ async fn oauth2_native_plugin_catalog_and_call_require_explicit_plugin_scope() { "tools/call", json!({ "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, - "arguments": {"action": "check", "runner": "runner-a"} + "arguments": {"action": "check", "runner": "runner-a", "plugin": "repo-tools"} }), ) .await; @@ -353,44 +327,34 @@ async fn oauth2_managed_ssh_resource_surface_requires_explicit_ssh_local_scope() } #[tokio::test] -async fn oauth2_first_class_startup_plugin_visibility_and_direct_spoof_require_plugin_scope() { +async fn oauth2_plugin_gateway_visibility_uses_any_plugin_scope_and_provider_names_stay_hidden() { let tool_name = "oauth_plugin_echo"; - let (_tmp, service, token) = - oauth_mcp_service_with_startup_plugin("runtime:read", tool_name).await; + let (_tmp, service, token) = oauth_mcp_service_with_plugin_runner("runtime:read").await; let (status, body, _) = oauth_mcp_request(&service, &token, "tools/list", json!({})).await; assert_eq!(status, StatusCode::OK, "body: {body:?}"); - assert!(!body["result"]["tools"] - .as_array() - .unwrap() - .iter() - .any(|tool| tool["name"] == tool_name)); - - let (status, body, challenge) = oauth_mcp_request( - &service, - &token, - "tools/call", - json!({"name": tool_name, "arguments": {"value": "hidden"}}), - ) - .await; - assert_mcp_oauth_scope_rejected( - status, - &body, - challenge.as_deref(), - Some(crate::auth::SCOPE_PLUGIN_INVOKE), - ); + let names = listed_tool_names(&body); + assert!(!names.contains(crate::plugin_gateway::PLUGIN_TOOL_NAME)); + assert!(!names.contains(tool_name)); - let (_tmp, service, token) = oauth_mcp_service_with_startup_plugin( - "runtime:read plugin:inspect plugin:invoke", - tool_name, - ) - .await; - let (status, body, _) = oauth_mcp_request(&service, &token, "tools/list", json!({})).await; - assert_eq!(status, StatusCode::OK, "body: {body:?}"); - assert!(body["result"]["tools"] - .as_array() - .unwrap() - .iter() - .any(|tool| tool["name"] == tool_name)); + for scope in [ + crate::auth::SCOPE_PLUGIN_INSPECT, + crate::auth::SCOPE_PLUGIN_INVOKE, + crate::auth::SCOPE_PLUGIN_MANAGE, + ] { + let scopes = format!("runtime:read {scope}"); + let (_tmp, service, token) = oauth_mcp_service_with_plugin_runner(&scopes).await; + let (status, body, _) = oauth_mcp_request(&service, &token, "tools/list", json!({})).await; + assert_eq!(status, StatusCode::OK, "scope={scope}, body={body:?}"); + let names = listed_tool_names(&body); + assert!( + names.contains(crate::plugin_gateway::PLUGIN_TOOL_NAME), + "scope={scope} must make the stable Plugin gateway visible" + ); + assert!( + !names.contains(tool_name), + "provider-local names must never become outer MCP tools" + ); + } } #[tokio::test] diff --git a/src/mcp_tests/plugin_check.rs b/src/mcp_tests/plugin_check.rs index e1920732..d6409232 100644 --- a/src/mcp_tests/plugin_check.rs +++ b/src/mcp_tests/plugin_check.rs @@ -1,10 +1,9 @@ use super::*; -use crate::runner_protocol::{RunnerPolicySummary, RunnerResultPayload}; +use crate::runner_protocol::RunnerResultPayload; use std::sync::Arc; use webcodex_core::plugin::{ PluginCheckDiagnostic, PluginCheckPhase, PluginCheckReport, PluginCheckToolSummary, PluginGatewayRequest, PluginGatewayResponse, PluginGatewayResponsePayload, - PluginStartupToolShape, PluginTool, StartupPluginProvider, }; fn plugin_auth(include_scope: bool) -> crate::auth::AuthContext { @@ -19,28 +18,7 @@ fn plugin_auth(include_scope: bool) -> crate::auth::AuthContext { auth } -fn direct_tool(name: &str) -> PluginTool { - PluginTool { - name: name.to_string(), - title: Some("Search Symbol".to_string()), - description: Some("Search repository symbols".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"query": {"type": "string"}}, - "required": ["query"], - "additionalProperties": false - }), - output_schema: None, - annotations: Some(json!({"readOnlyHint": true})), - } -} - -async fn register_plugin_runner( - runtime: &ToolRuntime, - client_id: &str, - runner_instance_id: &str, - tool_name: &str, -) { +async fn register_plugin_runner(runtime: &ToolRuntime, client_id: &str, runner_instance_id: &str) { let mut capabilities = RunnerCapabilities::default(); capabilities.native_tool_plugins = true; runtime @@ -55,19 +33,7 @@ async fn register_plugin_runner( hostname: None, host_context: None, capabilities, - policy: Some(RunnerPolicySummary { - plugin_providers: Some(vec![StartupPluginProvider { - provider_id: "repo-tools".to_string(), - provider_instance_id: format!("startup-{runner_instance_id}"), - name: "Repo Tools".to_string(), - status: "ready".to_string(), - error_code: None, - catalog_tool_count: 1, - catalog_digest: None, - tools: vec![direct_tool(tool_name)], - }]), - ..Default::default() - }), + policy: Some(Default::default()), process_started_at: None, build: None, job_concurrency_limit: None, @@ -168,15 +134,19 @@ async fn tools_list(runtime: &ToolRuntime, auth: &crate::auth::AuthContext) -> V #[tokio::test] async fn plugin_check_tool_spec_and_argument_contract_fail_closed_before_dispatch() { - let spec = crate::plugin_gateway::tool_spec(); + let spec = registered_tool_specs() + .into_iter() + .find(|spec| spec.name == crate::plugin_gateway::PLUGIN_TOOL_NAME) + .expect("canonical plugin_tool spec"); + let spec = serde_json::to_value(spec).unwrap(); assert!(spec["inputSchema"]["properties"]["action"]["enum"] .as_array() .unwrap() .iter() .any(|action| action == "check")); let description = spec["description"].as_str().unwrap(); - assert!(description.contains("Prefer action=check before reload")); - assert!(description.contains("never calls tools/call")); + assert!(description.contains("Provider tools are never outer WebCodex MCP tools")); + assert!(description.contains("call accepts only binding + arguments")); let runtime = test_runtime(); let no_scope = handle_mcp_request( @@ -200,14 +170,12 @@ async fn plugin_check_tool_spec_and_argument_contract_fail_closed_before_dispatc ] { let outcome = handle_mcp_request(&runtime, check_call("runner-a", extra), Some(&auth)).await; - let McpOutcome::Ok(value) = outcome else { - panic!("invalid check arguments should render a tool result: {outcome:?}"); + let McpOutcome::BadRequest(value) = outcome else { + panic!( + "invalid check arguments must fail at the canonical ToolCall parser: {outcome:?}" + ); }; - assert_eq!(value["result"]["isError"], true); - assert_eq!( - value["result"]["structuredContent"]["error"]["code"], - "invalid_arguments" - ); + assert_eq!(value["error"]["code"], -32602); } let missing_runner = handle_mcp_request( @@ -223,13 +191,10 @@ async fn plugin_check_tool_spec_and_argument_contract_fail_closed_before_dispatc Some(&auth), ) .await; - let McpOutcome::Ok(missing_runner) = missing_runner else { - panic!("missing runner should render a tool result"); + let McpOutcome::BadRequest(missing_runner) = missing_runner else { + panic!("missing runner must fail at the canonical ToolCall parser"); }; - assert_eq!( - missing_runner["result"]["structuredContent"]["error"]["code"], - "invalid_arguments" - ); + assert_eq!(missing_runner["error"]["code"], -32602); let missing_plugin = handle_mcp_request( &runtime, @@ -244,13 +209,10 @@ async fn plugin_check_tool_spec_and_argument_contract_fail_closed_before_dispatc Some(&auth), ) .await; - let McpOutcome::Ok(missing_plugin) = missing_plugin else { - panic!("missing plugin should render a tool result"); + let McpOutcome::BadRequest(missing_plugin) = missing_plugin else { + panic!("missing plugin must fail at the canonical ToolCall parser"); }; - assert_eq!( - missing_plugin["result"]["structuredContent"]["error"]["code"], - "invalid_arguments" - ); + assert_eq!(missing_plugin["error"]["code"], -32602); assert!(runtime .runner_registry .poll(RunnerPollRequest { @@ -262,18 +224,22 @@ async fn plugin_check_tool_spec_and_argument_contract_fail_closed_before_dispatc } #[tokio::test] -async fn plugin_check_routes_exact_runner_renders_sanitized_report_and_preserves_direct_inventory() -{ +async fn plugin_check_routes_exact_runner_and_never_changes_outer_mcp_inventory() { let runtime = Arc::new(test_runtime()); let auth = plugin_auth(true); - register_plugin_runner(&runtime, "runner-a", "runner-instance-a", "search_symbol").await; - register_plugin_runner(&runtime, "runner-b", "runner-instance-b", "other_symbol").await; + register_plugin_runner(&runtime, "runner-a", "runner-instance-a").await; + register_plugin_runner(&runtime, "runner-b", "runner-instance-b").await; let before = tools_list(&runtime, &auth).await; - assert!(before["result"]["tools"] + assert!(!before["result"]["tools"] .as_array() .unwrap() .iter() .any(|tool| tool["name"] == "search_symbol")); + assert!(before["result"]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == crate::plugin_gateway::PLUGIN_TOOL_NAME)); let task_runtime = Arc::clone(&runtime); let task_auth = auth.clone(); @@ -317,12 +283,6 @@ async fn plugin_check_routes_exact_runner_renders_sanitized_report_and_preserves title: Some("Search Symbol".to_string()), }], diagnostic: None, - startup_tool_shape: Some(PluginStartupToolShape { - eligible: true, - code: None, - tool: None, - field: None, - }), }, }), ) @@ -339,7 +299,7 @@ async fn plugin_check_routes_exact_runner_renders_sanitized_report_and_preserves assert_eq!(report["phase"], "ready"); assert_eq!(report["toolCount"], 1); assert_eq!(report["tools"][0]["name"], "search_symbol"); - assert_eq!(report["startupToolShape"]["eligible"], true); + assert!(report.get("startupToolShape").is_none()); assert!(report.get("binding").is_none()); let encoded = serde_json::to_string(report).unwrap(); for forbidden in [ @@ -357,18 +317,23 @@ async fn plugin_check_routes_exact_runner_renders_sanitized_report_and_preserves } let after = tools_list(&runtime, &auth).await; - assert!(after["result"]["tools"] + assert!(!after["result"]["tools"] .as_array() .unwrap() .iter() .any(|tool| tool["name"] == "search_symbol")); + assert!(after["result"]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == crate::plugin_gateway::PLUGIN_TOOL_NAME)); } #[tokio::test] async fn broken_plugin_candidate_is_a_successful_check_diagnostic_result() { let runtime = Arc::new(test_runtime()); let auth = plugin_auth(true); - register_plugin_runner(&runtime, "runner-a", "runner-instance-a", "search_symbol").await; + register_plugin_runner(&runtime, "runner-a", "runner-instance-a").await; let task_runtime = Arc::clone(&runtime); let task_auth = auth.clone(); let task = tokio::spawn(async move { @@ -400,7 +365,6 @@ async fn broken_plugin_candidate_is_a_successful_check_diagnostic_result() { tool: Some("search_symbol".to_string()), field: Some("name".to_string()), }), - startup_tool_shape: None, }, }), ) diff --git a/src/mcp_tests/plugin_tools.rs b/src/mcp_tests/plugin_tools.rs index 3468350b..c0680063 100644 --- a/src/mcp_tests/plugin_tools.rs +++ b/src/mcp_tests/plugin_tools.rs @@ -3,8 +3,7 @@ use crate::runner_protocol::{RunnerPolicySummary, RunnerResultPayload}; use std::sync::Arc; use webcodex_core::plugin::{ PluginContent, PluginDispatchState, PluginGatewayRequest, PluginGatewayResponse, - PluginGatewayResponsePayload, PluginPlane, PluginProviderView, PluginTool, PluginToolResult, - StartupPluginProvider, + PluginGatewayResponsePayload, PluginProviderView, PluginTool, PluginToolResult, }; async fn wait_for_plugin_request( @@ -26,7 +25,7 @@ async fn wait_for_plugin_request( } assert!( tokio::time::Instant::now() < deadline, - "direct startup Plugin call did not dispatch within 10 seconds" + "Plugin gateway request did not dispatch within 10 seconds" ); tokio::task::yield_now().await; } @@ -105,6 +104,35 @@ fn plugin_tool(name: &str) -> PluginTool { } } +fn spawn_generic_plugin_call( + runtime: &Arc, + auth: &crate::auth::AuthContext, + arguments: Value, + recording_session_id: Option, +) -> tokio::task::JoinHandle { + let runtime = Arc::clone(runtime); + let auth = auth.clone(); + tokio::spawn(async move { + runtime + .call_tool_with_context( + crate::tool_runtime::kernel::ToolCallRequest { + tool_name: crate::plugin_gateway::PLUGIN_TOOL_NAME.to_string(), + arguments, + }, + crate::tool_runtime::kernel::ToolCallContext { + transport: crate::tool_runtime::kernel::ToolTransport::Api, + session_id: recording_session_id.as_deref(), + auth: Some(&auth), + window: None, + record_oauth_scope_denials: true, + host_file_import_trust: + crate::tool_runtime::kernel::HostFileImportTrust::Untrusted, + }, + ) + .await + }) +} + async fn register_plugin_runner( runtime: &ToolRuntime, client_id: &str, @@ -151,11 +179,11 @@ async fn register_plugin_runner_with_status( runtime: &ToolRuntime, client_id: &str, runner_instance_id: &str, - provider_id: &str, - provider_instance_id: &str, + _provider_id: &str, + _provider_instance_id: &str, owner: &str, - status: &str, - tools: Vec, + _status: &str, + _tools: Vec, ) { let mut capabilities = RunnerCapabilities::default(); capabilities.native_tool_plugins = true; @@ -171,23 +199,7 @@ async fn register_plugin_runner_with_status( hostname: None, host_context: None, capabilities, - policy: Some(RunnerPolicySummary { - plugin_providers: Some(vec![StartupPluginProvider { - provider_id: provider_id.to_string(), - provider_instance_id: provider_instance_id.to_string(), - name: "Repo Tools".to_string(), - status: status.to_string(), - error_code: match status { - "ready" => None, - "ready_secondary" => Some("first_class_catalog_too_large".to_string()), - _ => Some("plugin_initialize_failed".to_string()), - }, - catalog_tool_count: tools.len(), - catalog_digest: None, - tools, - }]), - ..Default::default() - }), + policy: Some(RunnerPolicySummary::default()), process_started_at: None, build: None, job_concurrency_limit: None, @@ -261,6 +273,29 @@ async fn describe_dynamic_binding( provider: PluginProviderView, tool: PluginTool, rpc_id: u64, +) -> (String, Value) { + describe_binding_with_provider_set( + runtime, + auth, + runner_id, + runner_instance_id, + provider.clone(), + vec![provider], + tool, + rpc_id, + ) + .await +} + +async fn describe_binding_with_provider_set( + runtime: &Arc, + auth: &crate::auth::AuthContext, + runner_id: &str, + runner_instance_id: &str, + provider: PluginProviderView, + providers: Vec, + tool: PluginTool, + rpc_id: u64, ) -> (String, Value) { let request_runtime = Arc::clone(runtime); let request_auth = auth.clone(); @@ -298,10 +333,7 @@ async fn describe_dynamic_binding( runtime, providers_request, runner_instance_id, - PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { - providers: vec![provider.clone()], - first_class_restart_required: true, - }), + PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { providers }), ) .await; @@ -310,7 +342,6 @@ async fn describe_dynamic_binding( assert!(matches!( tools_request.plugin_gateway, Some(PluginGatewayRequest::ToolsList { - plane: PluginPlane::Effective, ref provider_id, ref provider_instance_id, }) if provider_id == &provider.provider_id @@ -422,17 +453,21 @@ async fn plugin_operation_scopes_are_independent_and_fail_closed() { for (id, arguments, required) in [ ( 681, - json!({"action":"call"}), + json!({ + "action":"call", + "binding":"wc_pbind_00000000000000000000000000000000", + "arguments":{} + }), crate::auth::SCOPE_PLUGIN_INVOKE, ), ( 682, - json!({"action":"check"}), + json!({"action":"check","runner":"runner-a","plugin":"repo-tools"}), crate::auth::SCOPE_PLUGIN_MANAGE, ), ( 683, - json!({"action":"reload"}), + json!({"action":"reload","runner":"runner-a"}), crate::auth::SCOPE_PLUGIN_MANAGE, ), ] { @@ -461,7 +496,11 @@ async fn plugin_operation_scopes_are_independent_and_fail_closed() { Some(json!(684)), json!({ "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, - "arguments": {"action":"call"} + "arguments": { + "action":"call", + "binding":"wc_pbind_00000000000000000000000000000000", + "arguments":{} + } }), ), Some(&invoke), @@ -473,9 +512,15 @@ async fn plugin_operation_scopes_are_independent_and_fail_closed() { assert_eq!(invoke_call["result"]["isError"], true); assert_eq!( invoke_call["result"]["structuredContent"]["error"]["code"], - "invalid_arguments" + "describe_required" ); - for (id, action) in [(685, "check"), (686, "reload")] { + for (id, arguments) in [ + ( + 685, + json!({"action":"check","runner":"runner-a","plugin":"repo-tools"}), + ), + (686, json!({"action":"reload","runner":"runner-a"})), + ] { let outcome = handle_mcp_request( &runtime, rpc( @@ -483,7 +528,7 @@ async fn plugin_operation_scopes_are_independent_and_fail_closed() { Some(json!(id)), json!({ "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, - "arguments": {"action":action} + "arguments": arguments }), ), Some(&invoke), @@ -504,7 +549,11 @@ async fn plugin_operation_scopes_are_independent_and_fail_closed() { Some(json!(687)), json!({ "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, - "arguments": {"action":"call"} + "arguments": { + "action":"call", + "binding":"wc_pbind_00000000000000000000000000000000", + "arguments":{} + } }), ), Some(&manage), @@ -524,7 +573,7 @@ async fn plugin_operation_scopes_are_independent_and_fail_closed() { Some(json!(688)), json!({ "name": crate::plugin_gateway::PLUGIN_TOOL_NAME, - "arguments": {"action":"reload"} + "arguments": {"action":"reload","runner":"runner-a"} }), ), Some(&manage), @@ -536,7 +585,7 @@ async fn plugin_operation_scopes_are_independent_and_fail_closed() { assert_eq!(manage_reload["result"]["isError"], true); assert_eq!( manage_reload["result"]["structuredContent"]["error"]["code"], - "invalid_arguments" + "runner_unavailable" ); } @@ -669,7 +718,7 @@ async fn specialized_recording_session_authority_fails_closed_at_mcp_boundary() } #[tokio::test] -async fn restricted_permission_denies_plugin_call_and_direct_tool_before_provider_dispatch() { +async fn restricted_permission_denies_plugin_call_and_outer_direct_name_never_dispatches() { let runtime = test_runtime().with_permission_evaluator( crate::tool_runtime::PermissionEvaluator::with_mode( crate::tool_runtime::AuthorityMode::Restricted, @@ -729,17 +778,9 @@ async fn restricted_permission_denies_plugin_call_and_direct_tool_before_provide Some(&auth), ) .await; - let McpOutcome::Ok(direct) = direct else { - panic!("direct Plugin permission denial must render a normal MCP tool result"); - }; - assert_eq!(direct["result"]["isError"], true); - assert_eq!( - direct["result"]["structuredContent"]["output"]["failure_kind"], - "permission_denied" - ); - assert_eq!( - direct["result"]["structuredContent"]["output"]["dispatch_certainty"], - "not_started" + assert!( + matches!(direct, McpOutcome::BadRequest(_)), + "a provider-local name is not an outer WebCodex tool and must never enter Plugin governance/dispatch" ); assert!(runtime .runner_registry @@ -831,7 +872,7 @@ async fn plugin_tool_list_provider_and_tools_is_bounded_and_binding_free() { "repo-tools", "startup-provider-instance", "alice", - "ready_secondary", + "ready", vec![], ) .await; @@ -839,10 +880,8 @@ async fn plugin_tool_list_provider_and_tools_is_bounded_and_binding_free() { provider_id: "repo-tools".to_string(), provider_instance_id: "dynamic-provider-instance".to_string(), name: "Repo Tools".to_string(), - plane: PluginPlane::Effective, status: "ready".to_string(), error_code: None, - startup_direct_tool_count: 0, }; let provider_task = spawn_plugin_metadata_call( @@ -863,7 +902,6 @@ async fn plugin_tool_list_provider_and_tools_is_bounded_and_binding_free() { "runner-instance-a", PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { providers: vec![provider.clone()], - first_class_restart_required: true, }), ) .await; @@ -873,13 +911,8 @@ async fn plugin_tool_list_provider_and_tools_is_bounded_and_binding_free() { let provider_view = &provider_result["result"]["structuredContent"]["plugins"][0]; assert_eq!(provider_view["plugin"], "repo-tools"); assert_eq!(provider_view["status"], "ready"); - assert_eq!(provider_view["source"], "dynamic"); - assert_eq!(provider_view["startupAdmission"], "secondary"); - assert_eq!( - provider_view["startupAdmissionCode"], - "first_class_catalog_too_large" - ); - assert_eq!(provider_view["startupDirectToolCount"], 0); + assert!(provider_view.get("source").is_none()); + assert!(provider_view.get("startupAdmission").is_none()); let bindings_before = runtime.plugin_gateway.binding_count(); let tools_task = spawn_plugin_metadata_call( @@ -904,7 +937,6 @@ async fn plugin_tool_list_provider_and_tools_is_bounded_and_binding_free() { "runner-instance-a", PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { providers: vec![provider.clone()], - first_class_restart_required: true, }), ) .await; @@ -913,7 +945,6 @@ async fn plugin_tool_list_provider_and_tools_is_bounded_and_binding_free() { assert!(matches!( tools_request.plugin_gateway, Some(PluginGatewayRequest::ToolsList { - plane: PluginPlane::Effective, ref provider_id, ref provider_instance_id, }) if provider_id == "repo-tools" && provider_instance_id == "dynamic-provider-instance" @@ -935,8 +966,8 @@ async fn plugin_tool_list_provider_and_tools_is_bounded_and_binding_free() { assert_eq!(discovery["plugin"], "repo-tools"); assert_eq!(discovery["name"], "Repo Tools"); assert_eq!(discovery["status"], "ready"); - assert_eq!(discovery["source"], "dynamic"); - assert_eq!(discovery["startupAdmission"], "secondary"); + assert!(discovery.get("source").is_none()); + assert!(discovery.get("startupAdmission").is_none()); assert_eq!(discovery["toolCount"], 1); assert_eq!(discovery["tools"][0]["name"], "search_symbol"); assert_eq!(discovery["tools"][0]["title"], "Repository Search"); @@ -981,10 +1012,8 @@ async fn plugin_tool_list_unknown_provider_fails_without_tools_request() { provider_id: "repo-tools".to_string(), provider_instance_id: "provider-instance-a".to_string(), name: "Repo Tools".to_string(), - plane: PluginPlane::Startup, status: "ready".to_string(), error_code: None, - startup_direct_tool_count: 1, }; let task = spawn_plugin_metadata_call( &runtime, @@ -1004,7 +1033,6 @@ async fn plugin_tool_list_unknown_provider_fails_without_tools_request() { "runner-instance-a", PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { providers: vec![provider], - first_class_restart_required: false, }), ) .await; @@ -1044,10 +1072,8 @@ async fn plugin_tool_list_provider_replacement_fails_closed_without_reresolve_or provider_id: "repo-tools".to_string(), provider_instance_id: "provider-instance-a".to_string(), name: "Repo Tools".to_string(), - plane: PluginPlane::Effective, status: "ready".to_string(), error_code: None, - startup_direct_tool_count: 1, }; let task = spawn_plugin_metadata_call( &runtime, @@ -1063,7 +1089,6 @@ async fn plugin_tool_list_provider_replacement_fails_closed_without_reresolve_or "runner-instance-a", PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { providers: vec![provider], - first_class_restart_required: true, }), ) .await; @@ -1136,10 +1161,8 @@ async fn plugin_tool_list_provider_busy_is_not_started_and_not_replayed() { provider_id: "repo-tools".to_string(), provider_instance_id: "provider-instance-a".to_string(), name: "Repo Tools".to_string(), - plane: PluginPlane::Effective, status: "ready".to_string(), error_code: None, - startup_direct_tool_count: 1, }; let task = spawn_plugin_metadata_call( &runtime, @@ -1155,7 +1178,6 @@ async fn plugin_tool_list_provider_busy_is_not_started_and_not_replayed() { "runner-instance-a", PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { providers: vec![provider], - first_class_restart_required: false, }), ) .await; @@ -1211,10 +1233,8 @@ async fn plugin_tool_list_runner_replacement_fails_closed_and_never_replays_on_r provider_id: "repo-tools".to_string(), provider_instance_id: "provider-instance-a".to_string(), name: "Repo Tools".to_string(), - plane: PluginPlane::Effective, status: "ready".to_string(), error_code: None, - startup_direct_tool_count: 1, }; let task = spawn_plugin_metadata_call( &runtime, @@ -1230,7 +1250,6 @@ async fn plugin_tool_list_runner_replacement_fails_closed_and_never_replays_on_r "runner-instance-a", PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { providers: vec![provider], - first_class_restart_required: false, }), ) .await; @@ -1329,13 +1348,10 @@ async fn plugin_tool_list_argument_matrix_rejects_ambiguous_inputs_before_dispat Some(&auth), ) .await; - let McpOutcome::Ok(result) = outcome else { - panic!("invalid list arguments should render a Plugin tool error"); + let McpOutcome::BadRequest(result) = outcome else { + panic!("invalid list arguments must fail at the canonical ToolCall parser"); }; - assert_eq!( - result["result"]["structuredContent"]["error"]["code"], - "invalid_arguments" - ); + assert_eq!(result["error"]["code"], -32602); } assert!(runtime .runner_registry @@ -1349,67 +1365,203 @@ async fn plugin_tool_list_argument_matrix_rejects_ambiguous_inputs_before_dispat } #[tokio::test] -async fn non_ready_startup_provider_is_never_exposed_directly() { - let runtime = test_runtime(); +async fn generic_runtime_plugin_gateway_list_describe_call_and_error_certainty_share_exact_dispatch( +) { + let runtime = Arc::new(test_runtime()); let auth = plugin_auth(true); - register_plugin_runner_with_status( + register_plugin_runner( &runtime, - "runner-failed", - "runner-instance-failed", + "runner-a", + "runner-instance-a", "repo-tools", - "provider-instance-failed", - "alice", - "failed", - vec![plugin_tool("should_not_leak")], + "provider-instance-a", + vec![plugin_tool("safe_delete")], ) .await; + let provider = PluginProviderView { + provider_id: "repo-tools".to_string(), + provider_instance_id: "provider-instance-a".to_string(), + name: "Repo Tools".to_string(), + status: "ready".to_string(), + error_code: None, + }; + let tool = plugin_tool("safe_delete"); - let value = tools_list(&runtime, &auth, false).await; - assert!(!value["result"]["tools"] - .as_array() + let list = spawn_generic_plugin_call( + &runtime, + &auth, + json!({"action":"list","runner":"runner-a"}), + None, + ); + let list_request = + wait_for_plugin_request(&runtime.runner_registry, "runner-a", "runner-instance-a").await; + assert!(matches!( + list_request.plugin_gateway, + Some(PluginGatewayRequest::ProvidersList) + )); + complete_plugin_request( + &runtime, + list_request, + "runner-instance-a", + PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { + providers: vec![provider.clone()], + }), + ) + .await; + let list = list.await.unwrap(); + assert!(list.success); + assert_eq!( + list.result.as_ref().unwrap().output["plugins"][0]["plugin"], + "repo-tools" + ); + + let describe = spawn_generic_plugin_call( + &runtime, + &auth, + json!({ + "action":"describe", + "runner":"runner-a", + "plugin":"repo-tools", + "tool":"safe_delete" + }), + None, + ); + let providers_request = + wait_for_plugin_request(&runtime.runner_registry, "runner-a", "runner-instance-a").await; + complete_plugin_request( + &runtime, + providers_request, + "runner-instance-a", + PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { + providers: vec![provider.clone()], + }), + ) + .await; + let tools_request = + wait_for_plugin_request(&runtime.runner_registry, "runner-a", "runner-instance-a").await; + assert!(matches!( + tools_request.plugin_gateway, + Some(PluginGatewayRequest::ToolsList { + ref provider_id, + ref provider_instance_id, + }) if provider_id == "repo-tools" && provider_instance_id == "provider-instance-a" + )); + complete_plugin_request( + &runtime, + tools_request, + "runner-instance-a", + PluginGatewayResponse::success(PluginGatewayResponsePayload::Tools { + tools: vec![tool.clone()], + }), + ) + .await; + let describe = describe.await.unwrap(); + assert!(describe.success); + let binding = describe.result.as_ref().unwrap().output["binding"] + .as_str() .unwrap() - .iter() - .any(|tool| tool["name"] == "should_not_leak")); -} + .to_string(); + assert!(binding.starts_with("wc_pbind_")); -#[tokio::test] -async fn startup_plugin_direct_inventory_respects_exact_runner_owner_visibility() { - let runtime = test_runtime(); - let auth = plugin_auth(true); - register_plugin_runner_for_owner( + let call = spawn_generic_plugin_call( &runtime, - "runner-bob", - "runner-instance-bob", - "repo-tools", - "provider-instance-bob", - "bob", - vec![plugin_tool("private_search")], + &auth, + json!({"action":"call","binding":binding,"arguments":{"query":"ok"}}), + None, + ); + let call_request = + wait_for_plugin_request(&runtime.runner_registry, "runner-a", "runner-instance-a").await; + assert!(matches!( + call_request.plugin_gateway, + Some(PluginGatewayRequest::ToolsCall { + ref provider_id, + ref provider_instance_id, + ref name, + .. + }) if provider_id == "repo-tools" + && provider_instance_id == "provider-instance-a" + && name == "safe_delete" + )); + complete_plugin_request( + &runtime, + call_request, + "runner-instance-a", + PluginGatewayResponse::success(PluginGatewayResponsePayload::ToolResult { + result: PluginToolResult { + content: vec![PluginContent::Text { + text: "ok".to_string(), + }], + structured_content: Some(json!({"matches":[]})), + is_error: false, + }, + }), ) .await; + let call = call.await.unwrap(); + assert!(call.success); + let call_output = &call.result.as_ref().unwrap().output; + assert_eq!(call_output["isError"], false); + assert_eq!(call_output["dispatch_certainty"], "completed"); - let value = tools_list(&runtime, &auth, false).await; - assert!(!value["result"]["tools"] - .as_array() - .unwrap() - .iter() - .any(|tool| tool["name"] == "private_search")); + let tool_error = spawn_generic_plugin_call( + &runtime, + &auth, + json!({"action":"call","binding":binding,"arguments":{"query":"business-error"}}), + None, + ); + let tool_error_request = + wait_for_plugin_request(&runtime.runner_registry, "runner-a", "runner-instance-a").await; + complete_plugin_request( + &runtime, + tool_error_request, + "runner-instance-a", + PluginGatewayResponse::success(PluginGatewayResponsePayload::ToolResult { + result: PluginToolResult { + content: vec![PluginContent::Text { + text: "no".to_string(), + }], + structured_content: Some(json!({"matches":[]})), + is_error: true, + }, + }), + ) + .await; + let tool_error = tool_error.await.unwrap(); + assert!(!tool_error.success); + let tool_error_output = &tool_error.result.as_ref().unwrap().output; + assert_eq!(tool_error_output["isError"], true); + assert_eq!(tool_error_output["failure_kind"], "plugin_tool_error"); + assert_eq!(tool_error_output["dispatch_certainty"], "completed"); - let outcome = handle_mcp_request( + let unknown = spawn_generic_plugin_call( &runtime, - rpc( - "tools/call", - Some(json!(709)), - json!({"name":"private_search","arguments":{"query":"x"}}), + &auth, + json!({"action":"call","binding":binding,"arguments":{"query":"unknown"}}), + None, + ); + let unknown_request = + wait_for_plugin_request(&runtime.runner_registry, "runner-a", "runner-instance-a").await; + complete_plugin_request( + &runtime, + unknown_request, + "runner-instance-a", + PluginGatewayResponse::error( + PluginDispatchState::OutcomeUnknown, + "plugin_timeout", + "terminal Plugin outcome is unknown", ), - Some(&auth), ) .await; - assert!(matches!(outcome, McpOutcome::BadRequest(_))); + let unknown = unknown.await.unwrap(); + assert!(!unknown.success); + let unknown_output = &unknown.result.as_ref().unwrap().output; + assert_eq!(unknown_output["failure_kind"], "plugin_timeout"); + assert_eq!(unknown_output["dispatch_certainty"], "outcome_unknown"); assert!(runtime .runner_registry .poll(RunnerPollRequest { - client_id: "runner-bob".to_string(), - runner_instance_id: "runner-instance-bob".to_string(), + client_id: "runner-a".to_string(), + runner_instance_id: "runner-instance-a".to_string(), }) .await .unwrap() @@ -1417,7 +1569,103 @@ async fn startup_plugin_direct_inventory_respects_exact_runner_owner_visibility( } #[tokio::test] -async fn startup_plugin_direct_tools_are_scoped_unique_and_keep_exact_schema() { +async fn generic_runtime_plugin_governance_is_action_aware_and_records_one_api_lifecycle() { + let runtime = Arc::new(test_runtime()); + let inspect = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_INSPECT]); + let invoke = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_INVOKE]); + let manage = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_MANAGE]); + let session = + start_authorized_test_session(&runtime, &inspect, crate::tool_runtime::SessionMode::Normal); + let list = spawn_generic_plugin_call( + &runtime, + &inspect, + json!({"action":"list"}), + Some(session.session_id.clone()), + ) + .await + .unwrap(); + assert!(list.success); + let summary = runtime + .sessions + .summary(&session.session_id, Some(20)) + .unwrap(); + assert_eq!( + summary.counts.tool_calls, 1, + "Plugin gateway must own one lifecycle" + ); + let finished = + crate::tool_runtime::sessions::canonical_tool_call_finished_events(&summary.events); + assert_eq!(finished.len(), 1); + assert_eq!( + finished[0].tool_name, + crate::plugin_gateway::PLUGIN_TOOL_NAME + ); + assert_eq!(finished[0].transport, "api"); + assert!(finished[0].read_like); + for auth in [&invoke, &manage] { + let denied = spawn_generic_plugin_call(&runtime, auth, json!({"action":"list"}), None) + .await + .unwrap(); + assert!(!denied.success); + assert!(matches!( + denied.error_status, + Some(crate::tool_runtime::kernel::ToolCallErrorStatus::InsufficientScope { .. }) + )); + } + let inspect_reload = spawn_generic_plugin_call( + &runtime, + &inspect, + json!({"action":"reload","runner":"runner-a"}), + None, + ) + .await + .unwrap(); + assert!(matches!( + inspect_reload.error_status, + Some(crate::tool_runtime::kernel::ToolCallErrorStatus::InsufficientScope { .. }) + )); + let restricted = Arc::new(test_runtime().with_permission_evaluator( + crate::tool_runtime::PermissionEvaluator::with_mode( + crate::tool_runtime::AuthorityMode::Restricted, + ), + )); + let permission_denied = spawn_generic_plugin_call( + &restricted, + &invoke, + json!({ + "action":"call", + "binding":"wc_pbind_00000000000000000000000000000000", + "arguments":{} + }), + None, + ) + .await + .unwrap(); + assert!(!permission_denied.success); + let permission_output = &permission_denied.result.as_ref().unwrap().output; + assert_eq!(permission_output["failure_kind"], "permission_denied"); + assert_eq!(permission_output["dispatch_certainty"], "not_started"); + let read_only = start_authorized_test_session( + &runtime, + &manage, + crate::tool_runtime::SessionMode::ReadOnly, + ); + let session_denied = spawn_generic_plugin_call( + &runtime, + &manage, + json!({"action":"reload","runner":"runner-a"}), + Some(read_only.session_id), + ) + .await + .unwrap(); + assert!(!session_denied.success); + let session_output = &session_denied.result.as_ref().unwrap().output; + assert_eq!(session_output["error_kind"], "session_guard_denied"); + assert_eq!(session_output["dispatch_certainty"], "not_started"); +} + +#[tokio::test] +async fn provider_tool_names_never_enter_outer_mcp_inventory_on_any_model_surface() { let auth = plugin_auth(true); for surface in [ ModelSurface::LocalCoding, @@ -1429,65 +1677,114 @@ async fn startup_plugin_direct_tools_are_scoped_unique_and_keep_exact_schema() { &runtime, "runner-a", "runner-instance-a", - "repo-tools", + "repo-tools-a", "provider-instance-a", - vec![plugin_tool("search_symbol")], + vec![plugin_tool("safe_delete"), plugin_tool("runtime_status")], + ) + .await; + register_plugin_runner( + &runtime, + "runner-b", + "runner-instance-b", + "repo-tools-b", + "provider-instance-b", + vec![plugin_tool("safe_delete")], ) .await; + let value = tools_list(&runtime, &auth, true).await; - let tools = value["result"]["tools"].as_array().unwrap(); - let direct = tools + let names = value["result"]["tools"] + .as_array() + .unwrap() .iter() - .find(|tool| tool["name"] == "search_symbol") - .unwrap_or_else(|| panic!("missing direct Plugin tool on {surface:?}")); + .filter_map(|tool| tool["name"].as_str()) + .collect::>(); + assert!( + names.contains(&crate::plugin_gateway::PLUGIN_TOOL_NAME), + "stable gateway missing on {surface:?}" + ); + assert!( + !names.contains(&"safe_delete"), + "provider-local name leaked into outer MCP on {surface:?}" + ); + // A provider-local collision with a built-in is legal because the + // provider tool never contributes a second outer ToolSpec. assert_eq!( - direct["inputSchema"], - plugin_tool("search_symbol").input_schema + names + .iter() + .filter(|name| **name == "runtime_status") + .count(), + usize::from(surface != ModelSurface::LocalCoding) ); - let properties = direct["inputSchema"]["properties"].as_object().unwrap(); - for sidecar in [ - "recording_session_id", - "ack_session_context_revision", - "ack_session_message_ids", - "context_request", - ] { - assert!( - !properties.contains_key(sidecar), - "Plugin schema received WebCodex sidecar {sidecar} on {surface:?}" - ); - } - assert!(tools - .iter() - .any(|tool| tool["name"] == crate::plugin_gateway::PLUGIN_TOOL_NAME)); } - assert!(registered_tool_specs() - .iter() - .all(|spec| spec.name != "search_symbol")); + let specs = registered_tool_specs(); + assert_eq!( + specs + .iter() + .filter(|spec| spec.name == crate::plugin_gateway::PLUGIN_TOOL_NAME) + .count(), + 1 + ); + assert!(specs.iter().all(|spec| spec.name != "safe_delete")); +} - let runtime = test_runtime(); +#[tokio::test] +async fn outer_direct_provider_tool_call_is_never_plugin_dispatch() { + let runtime = test_runtime_with_surface(ModelSurface::LocalCoding); + let auth = plugin_auth(true); + register_plugin_runner( + &runtime, + "runner-a", + "runner-instance-a", + "repo-tools-a", + "provider-instance-a", + vec![plugin_tool("safe_delete")], + ) + .await; register_plugin_runner( &runtime, "runner-b", "runner-instance-b", - "repo-tools", + "repo-tools-b", "provider-instance-b", - vec![plugin_tool("search_symbol")], + vec![plugin_tool("safe_delete")], ) .await; - let without_scope = tools_list(&runtime, &plugin_auth(false), false).await; - assert!(!without_scope["result"]["tools"] - .as_array() - .unwrap() - .iter() - .any(|tool| tool["name"] == "search_symbol")); + + let outcome = handle_mcp_request( + &runtime, + rpc( + "tools/call", + Some(json!(711)), + json!({"name":"safe_delete","arguments":{"path":"README.md"}}), + ), + Some(&auth), + ) + .await; + assert!(matches!(outcome, McpOutcome::BadRequest(_))); + for (client_id, instance) in [ + ("runner-a", "runner-instance-a"), + ("runner-b", "runner-instance-b"), + ] { + assert!(runtime + .runner_registry + .poll(RunnerPollRequest { + client_id: client_id.to_string(), + runner_instance_id: instance.to_string(), + }) + .await + .unwrap() + .is_none()); + } } #[tokio::test] -async fn startup_plugin_direct_inventory_requires_invoke_not_inspect_or_manage() { +async fn any_plugin_scope_exposes_only_the_stable_gateway() { for (id, scope) in [ (704, crate::auth::SCOPE_PLUGIN_INSPECT), - (705, crate::auth::SCOPE_PLUGIN_MANAGE), + (705, crate::auth::SCOPE_PLUGIN_INVOKE), + (706, crate::auth::SCOPE_PLUGIN_MANAGE), ] { let runtime = test_runtime_with_surface(ModelSurface::LocalCoding); register_plugin_runner( @@ -1496,7 +1793,7 @@ async fn startup_plugin_direct_inventory_requires_invoke_not_inspect_or_manage() "runner-instance-a", "repo-tools", "provider-instance-a", - vec![plugin_tool("invoke_only_direct")], + vec![plugin_tool("provider_local")], ) .await; let auth = plugin_auth_with_scopes(&[scope]); @@ -1516,259 +1813,250 @@ async fn startup_plugin_direct_inventory_requires_invoke_not_inspect_or_manage() .filter_map(|tool| tool["name"].as_str()) .collect::>(); assert!(names.contains(&crate::plugin_gateway::PLUGIN_TOOL_NAME)); - assert!(!names.contains(&"invoke_only_direct")); - - let spoof = handle_mcp_request( - &runtime, - rpc( - "tools/call", - Some(json!(id + 10)), - json!({"name":"invoke_only_direct","arguments":{"query":"x"}}), - ), - Some(&auth), - ) - .await; - match spoof { - McpOutcome::Forbidden { required_scope, .. } => { - assert_eq!(required_scope, Some(crate::auth::SCOPE_PLUGIN_INVOKE)); - } - other => panic!("direct Plugin spoof must require invoke: {other:?}"), - } - assert!(runtime - .runner_registry - .poll(RunnerPollRequest { - client_id: "runner-a".to_string(), - runner_instance_id: "runner-instance-a".to_string(), - }) - .await - .unwrap() - .is_none()); + assert!(!names.contains(&"provider_local")); } } #[tokio::test] -async fn startup_plugin_reserved_and_duplicate_names_are_not_directly_exposed() { - let runtime = test_runtime_with_surface(ModelSurface::LocalCoding); - let auth = plugin_auth(true); - register_plugin_runner( +async fn tool_manifest_returns_canonical_static_plugin_tool_contract_without_runner_inventory() { + let runtime = test_runtime_with_surface(ModelSurface::AdaptiveRuntime); + let auth = plugin_auth_with_scopes(&[ + crate::auth::SCOPE_PLUGIN_INSPECT, + crate::auth::SCOPE_RUNTIME_READ, + ]); + let outcome = handle_mcp_request( &runtime, - "runner-a", - "runner-instance-a", - "repo-tools-a", - "provider-instance-a", - vec![plugin_tool("runtime_status"), plugin_tool("search")], + rpc( + "tools/call", + Some(json!(7061)), + mcp_2026_params(json!({ + "name": "tool_manifest", + "arguments": { + "tool_name": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "include_recommended_flows": false, + "include_risk_summary": false + } + })), + ), + None, ) .await; + let McpOutcome::Ok(value) = outcome else { + panic!("tool_manifest(plugin_tool) must succeed without any Runner inventory"); + }; + let output = &value["result"]["structuredContent"]["output"]; + assert_eq!(output["tool_name"], crate::plugin_gateway::PLUGIN_TOOL_NAME); + assert_eq!( + output["contract"]["name"], + crate::plugin_gateway::PLUGIN_TOOL_NAME + ); + assert_eq!(output["contract"]["availability"], "direct"); + assert_eq!( + output["contract"]["input_schema"]["properties"]["binding"]["pattern"], + "^wc_pbind_[0-9a-f]{32}$" + ); + assert_eq!(output["tools"][0]["authority"]["policy"], "require_any"); + let scopes = output["tools"][0]["authority"]["scopes"] + .as_array() + .expect("Plugin gateway authority scopes"); + for scope in [ + crate::auth::SCOPE_PLUGIN_INSPECT, + crate::auth::SCOPE_PLUGIN_INVOKE, + crate::auth::SCOPE_PLUGIN_MANAGE, + ] { + assert!(scopes.iter().any(|value| value == scope)); + } + register_plugin_runner( &runtime, - "runner-b", - "runner-instance-b", - "repo-tools-b", - "provider-instance-b", - vec![plugin_tool("search")], + "runner-a", + "runner-instance-a", + "provider-a", + "provider-a-instance", + vec![plugin_tool("safe_delete")], ) .await; + let listed = tools_list(&runtime, &auth, false).await; + let gateway = listed["result"]["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == crate::plugin_gateway::PLUGIN_TOOL_NAME) + .expect("plugin_tool after Runner registration"); + assert_eq!(gateway["inputSchema"], output["contract"]["input_schema"]); + assert!(!listed["result"]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "safe_delete")); - let value = tools_list(&runtime, &auth, false).await; - let names = value["result"]["tools"] + let stateless = tools_list(&runtime, &auth, true).await; + let stateless_gateway = stateless["result"]["tools"] .as_array() .unwrap() .iter() - .filter_map(|tool| tool["name"].as_str()) - .collect::>(); - assert!( - !names.contains(&"search"), - "duplicate Plugin name must be omitted" + .find(|tool| tool["name"] == crate::plugin_gateway::PLUGIN_TOOL_NAME) + .expect("stateless plugin_tool"); + for field in ["action", "runner", "plugin", "tool", "binding", "arguments"] { + assert_eq!( + stateless_gateway["inputSchema"]["properties"][field], + output["contract"]["input_schema"]["properties"][field], + "Stateless MCP must preserve canonical Plugin business schema for {field}" + ); + } + assert_eq!( + stateless_gateway["inputSchema"]["properties"]["recording_session_id"]["pattern"], + "^wc_sess_[A-Za-z0-9_]+$" ); - // runtime_status is a globally reserved WebCodex name even though the - // local_coding surface does not itself advertise that built-in tool. - assert!(!names.contains(&"runtime_status")); - assert!(names.contains(&crate::plugin_gateway::PLUGIN_TOOL_NAME)); } #[tokio::test] -async fn direct_startup_plugin_call_routes_exact_startup_provider_and_renders_result() { - let runtime = Arc::new(test_runtime_with_surface(ModelSurface::LocalCoding)); - let auth = plugin_auth_with_scopes(&[crate::auth::SCOPE_PLUGIN_INVOKE]); +async fn same_tool_name_is_legal_across_runners_and_providers_and_bindings_dispatch_exactly() { + let runtime = Arc::new(test_runtime()); + let auth = plugin_auth(true); register_plugin_runner( &runtime, "runner-a", "runner-instance-a", - "repo-tools", - "provider-instance-a", - vec![plugin_tool("search_symbol")], + "provider-a", + "provider-a-instance", + vec![plugin_tool("safe_delete")], + ) + .await; + register_plugin_runner( + &runtime, + "runner-b", + "runner-instance-b", + "provider-c", + "provider-c-instance", + vec![plugin_tool("safe_delete")], ) .await; - let request_runtime = Arc::clone(&runtime); - let request_auth = auth.clone(); - let task = tokio::spawn(async move { - handle_mcp_request( - &request_runtime, - rpc( - "tools/call", - Some(json!(711)), - json!({ - "name": "search_symbol", - "arguments": {"query": "RunnerRegistry"} - }), - ), - Some(&request_auth), - ) - .await - }); - - let request = - wait_for_plugin_request(&runtime.runner_registry, "runner-a", "runner-instance-a").await; - let Some(PluginGatewayRequest::ToolsCall { - plane, - provider_id, - provider_instance_id, - name, - arguments, - expected_schema, - }) = request.plugin_gateway.clone() - else { - panic!("direct Plugin call did not use typed plugin_gateway: {request:?}"); + let provider_a = PluginProviderView { + provider_id: "provider-a".to_string(), + provider_instance_id: "provider-a-instance".to_string(), + name: "Provider A".to_string(), + status: "ready".to_string(), + error_code: None, }; - assert_eq!(request.kind, "plugin_gateway"); - assert_eq!(plane, PluginPlane::Startup); - assert_eq!(provider_id, "repo-tools"); - assert_eq!(provider_instance_id, "provider-instance-a"); - assert_eq!(name, "search_symbol"); - assert_eq!(arguments, json!({"query":"RunnerRegistry"})); - assert_eq!( - expected_schema, - plugin_tool("search_symbol").schema_observation() - ); - - runtime - .runner_registry - .complete(RunnerResultPayload { - result: RunnerResultRequest { - client_id: "runner-a".to_string(), - runner_instance_id: "runner-instance-a".to_string(), - request_id: request.request_id, - exit_code: None, - stdout: None, - stderr: None, - duration_ms: None, - error: None, - }, - command_execution_state: None, - mcp_gateway: None, - plugin_gateway: Some(PluginGatewayResponse::success( - PluginGatewayResponsePayload::ToolResult { - result: PluginToolResult { - content: vec![PluginContent::Text { - text: "found RunnerRegistry".to_string(), - }], - structured_content: Some(json!({"matches":["RunnerRegistry"]})), - is_error: false, - }, - }, - )), - coding_agent: None, - }) - .await - .unwrap(); - - let outcome = task.await.unwrap(); - let McpOutcome::Ok(value) = outcome else { - panic!("direct Plugin call failed: {outcome:?}"); + let provider_b = PluginProviderView { + provider_id: "provider-b".to_string(), + provider_instance_id: "provider-b-instance".to_string(), + name: "Provider B".to_string(), + status: "ready".to_string(), + error_code: None, + }; + let provider_c = PluginProviderView { + provider_id: "provider-c".to_string(), + provider_instance_id: "provider-c-instance".to_string(), + name: "Provider C".to_string(), + status: "ready".to_string(), + error_code: None, }; - assert_eq!( - value["result"]["content"][0]["text"], - "found RunnerRegistry" - ); - assert_eq!( - value["result"]["structuredContent"], - json!({"matches":["RunnerRegistry"]}) - ); - assert_eq!(value["result"]["isError"], false); -} -#[tokio::test] -async fn direct_plugin_scope_and_ambiguity_fail_before_runner_dispatch() { - let runtime = test_runtime(); - register_plugin_runner( + let runner_a_providers = vec![provider_a.clone(), provider_b.clone()]; + let (binding_a, _) = describe_binding_with_provider_set( &runtime, + &auth, "runner-a", "runner-instance-a", - "repo-tools-a", - "provider-instance-a", - vec![plugin_tool("search")], + provider_a.clone(), + runner_a_providers.clone(), + plugin_tool("safe_delete"), + 707, ) .await; - - let outcome = handle_mcp_request( + let (binding_b, _) = describe_binding_with_provider_set( &runtime, - rpc( - "tools/call", - Some(json!(712)), - json!({"name":"search","arguments":{"query":"x"}}), - ), - Some(&plugin_auth(false)), + &auth, + "runner-a", + "runner-instance-a", + provider_b.clone(), + runner_a_providers, + plugin_tool("safe_delete"), + 708, ) .await; - match outcome { - McpOutcome::Forbidden { required_scope, .. } => { - assert_eq!(required_scope, Some(crate::auth::SCOPE_PLUGIN_INVOKE)); - } - other => panic!("missing Plugin scope must be forbidden, got {other:?}"), - } - assert!(runtime - .runner_registry - .poll(RunnerPollRequest { - client_id: "runner-a".to_string(), - runner_instance_id: "runner-instance-a".to_string(), - }) - .await - .unwrap() - .is_none()); - - register_plugin_runner( + let (binding_c, _) = describe_dynamic_binding( &runtime, + &auth, "runner-b", "runner-instance-b", - "repo-tools-b", - "provider-instance-b", - vec![plugin_tool("search")], + provider_c.clone(), + plugin_tool("safe_delete"), + 709, ) .await; - let outcome = handle_mcp_request( - &runtime, - rpc( - "tools/call", - Some(json!(713)), - json!({"name":"search","arguments":{"query":"x"}}), + assert_ne!(binding_a, binding_b); + assert_ne!(binding_a, binding_c); + assert_ne!(binding_b, binding_c); + + for (rpc_id, binding, runner, runner_instance, provider, provider_instance, marker) in [ + ( + 710, + binding_a, + "runner-a", + "runner-instance-a", + "provider-a", + "provider-a-instance", + "a", + ), + ( + 711, + binding_b, + "runner-a", + "runner-instance-a", + "provider-b", + "provider-b-instance", + "b", + ), + ( + 712, + binding_c, + "runner-b", + "runner-instance-b", + "provider-c", + "provider-c-instance", + "c", ), - Some(&plugin_auth(true)), - ) - .await; - match outcome { - McpOutcome::BadRequest(value) => { - assert_eq!(value["error"]["code"], -32602); - assert!(value["error"]["message"] - .as_str() - .unwrap_or_default() - .contains("ambiguous")); - } - other => panic!("ambiguous Plugin name must fail closed, got {other:?}"), - } - for (client_id, instance) in [ - ("runner-a", "runner-instance-a"), - ("runner-b", "runner-instance-b"), ] { - assert!(runtime - .runner_registry - .poll(RunnerPollRequest { - client_id: client_id.to_string(), - runner_instance_id: instance.to_string(), - }) - .await - .unwrap() - .is_none()); + let call = spawn_binding_call(&runtime, &auth, binding, json!({"query": marker}), rpc_id); + let request = + wait_for_plugin_request(&runtime.runner_registry, runner, runner_instance).await; + assert!(matches!( + request.plugin_gateway, + Some(PluginGatewayRequest::ToolsCall { + ref provider_id, + ref provider_instance_id, + ref name, + .. + }) if provider_id == provider + && provider_instance_id == provider_instance + && name == "safe_delete" + )); + complete_plugin_request( + &runtime, + request, + runner_instance, + PluginGatewayResponse::success(PluginGatewayResponsePayload::ToolResult { + result: PluginToolResult { + content: vec![PluginContent::Text { + text: format!("called-{marker}"), + }], + structured_content: Some(json!({"provider": marker})), + is_error: false, + }, + }), + ) + .await; + let McpOutcome::Ok(result) = call.await.unwrap() else { + panic!("exact binding call failed for {provider}"); + }; + assert_eq!( + result["result"]["content"][0]["text"], + format!("called-{marker}") + ); } } @@ -1790,10 +2078,8 @@ async fn plugin_tool_reload_describe_call_binds_exact_dynamic_provider_and_forge provider_id: "repo-tools".to_string(), provider_instance_id: "dynamic-provider-instance".to_string(), name: "Repo Tools".to_string(), - plane: PluginPlane::Effective, status: "ready".to_string(), error_code: None, - startup_direct_tool_count: 1, }; let reload_runtime = Arc::clone(&runtime); @@ -1826,7 +2112,6 @@ async fn plugin_tool_reload_describe_call_binds_exact_dynamic_provider_and_forge PluginGatewayResponse::success(PluginGatewayResponsePayload::Reloaded { providers: vec![dynamic_provider.clone()], failures: vec![], - first_class_restart_required: true, }), ) .await; @@ -1834,10 +2119,9 @@ async fn plugin_tool_reload_describe_call_binds_exact_dynamic_provider_and_forge panic!("plugin_tool reload did not complete successfully"); }; assert_eq!(reload_result["result"]["isError"], false); - assert_eq!( - reload_result["result"]["structuredContent"]["firstClassRestartRequired"], - true - ); + assert!(reload_result["result"]["structuredContent"] + .get("firstClassRestartRequired") + .is_none()); let describe_runtime = Arc::clone(&runtime); let describe_auth = auth.clone(); @@ -1873,7 +2157,6 @@ async fn plugin_tool_reload_describe_call_binds_exact_dynamic_provider_and_forge "runner-instance-a", PluginGatewayResponse::success(PluginGatewayResponsePayload::Providers { providers: vec![dynamic_provider.clone()], - first_class_restart_required: true, }), ) .await; @@ -1882,7 +2165,6 @@ async fn plugin_tool_reload_describe_call_binds_exact_dynamic_provider_and_forge assert!(matches!( tools_request.plugin_gateway, Some(PluginGatewayRequest::ToolsList { - plane: PluginPlane::Effective, ref provider_id, ref provider_instance_id, }) if provider_id == "repo-tools" && provider_instance_id == "dynamic-provider-instance" @@ -1920,7 +2202,6 @@ async fn plugin_tool_reload_describe_call_binds_exact_dynamic_provider_and_forge let call_request = wait_for_plugin_request(&runtime.runner_registry, "runner-a", "runner-instance-a").await; let Some(PluginGatewayRequest::ToolsCall { - plane, provider_instance_id, name, arguments, @@ -1930,7 +2211,6 @@ async fn plugin_tool_reload_describe_call_binds_exact_dynamic_provider_and_forge else { panic!("plugin_tool call did not use typed Plugin gateway"); }; - assert_eq!(plane, PluginPlane::Effective); assert_eq!(provider_instance_id, "dynamic-provider-instance"); assert_eq!(name, "search_symbol"); assert_eq!(arguments, json!({"query":"PluginManager"})); @@ -1973,7 +2253,6 @@ async fn plugin_tool_reload_describe_call_binds_exact_dynamic_provider_and_forge assert!(matches!( replacement_request.plugin_gateway, Some(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Effective, ref provider_instance_id, .. }) if provider_instance_id == "dynamic-provider-instance" @@ -2052,10 +2331,8 @@ async fn plugin_binding_a_never_retargets_across_reload_and_binding_b_still_call provider_id: "repo-tools".to_string(), provider_instance_id: "dynamic-provider-v1".to_string(), name: "Repo Tools".to_string(), - plane: PluginPlane::Effective, status: "ready".to_string(), error_code: None, - startup_direct_tool_count: 1, }; let provider_v2 = PluginProviderView { provider_instance_id: "dynamic-provider-v2".to_string(), @@ -2103,7 +2380,6 @@ async fn plugin_binding_a_never_retargets_across_reload_and_binding_b_still_call PluginGatewayResponse::success(PluginGatewayResponsePayload::Reloaded { providers: vec![provider_v2.clone()], failures: vec![], - first_class_restart_required: true, }), ) .await; @@ -2130,7 +2406,6 @@ async fn plugin_binding_a_never_retargets_across_reload_and_binding_b_still_call assert!(matches!( request_a.plugin_gateway, Some(PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Effective, ref provider_instance_id, ref name, .. @@ -2163,7 +2438,6 @@ async fn plugin_binding_a_never_retargets_across_reload_and_binding_b_still_call let request_b = wait_for_plugin_request(&runtime.runner_registry, "runner-a", "runner-instance-a").await; let Some(PluginGatewayRequest::ToolsCall { - plane, provider_instance_id, name, arguments, @@ -2172,7 +2446,6 @@ async fn plugin_binding_a_never_retargets_across_reload_and_binding_b_still_call else { panic!("binding B did not dispatch a typed call"); }; - assert_eq!(plane, PluginPlane::Effective); assert_eq!(provider_instance_id, "dynamic-provider-v2"); assert_eq!(name, "search_symbol"); assert_eq!(arguments, json!({"query":"live-b"})); @@ -2214,10 +2487,8 @@ async fn plugin_binding_rechecks_scope_and_runner_owner_without_invalidating_own provider_id: "repo-tools".to_string(), provider_instance_id: "dynamic-provider-instance".to_string(), name: "Repo Tools".to_string(), - plane: PluginPlane::Effective, status: "ready".to_string(), error_code: None, - startup_direct_tool_count: 1, }; let (binding, _) = describe_dynamic_binding( &runtime, @@ -2336,10 +2607,8 @@ async fn plugin_binding_runner_replacement_and_schema_change_fail_closed() { provider_id: "repo-tools".to_string(), provider_instance_id: "dynamic-provider-instance".to_string(), name: "Repo Tools".to_string(), - plane: PluginPlane::Effective, status: "ready".to_string(), error_code: None, - startup_direct_tool_count: 1, }; let (runner_binding, _) = describe_dynamic_binding( diff --git a/src/openapi_tests.rs b/src/openapi_tests.rs index c914f993..8637c57d 100644 --- a/src/openapi_tests.rs +++ b/src/openapi_tests.rs @@ -1047,6 +1047,39 @@ fn openapi_call_runtime_tool_exposes_only_canonical_params_envelope() { assert_eq!(params["additionalProperties"], true); } +#[test] +fn openapi_generic_runtime_exposes_plugin_gateway_and_preserves_canonical_nested_schema() { + let spec = build_openapi_spec(); + let tool_call = &spec["components"]["schemas"]["ToolCallRequest"]; + let properties = tool_call["properties"].as_object().unwrap(); + let selector_description = properties[TOOL_CALL_TOOL_FIELD]["description"] + .as_str() + .unwrap(); + assert!(selector_description.contains("plugin_tool")); + assert!(properties.contains_key("action")); + assert!(properties.contains_key("runner")); + assert!(properties.contains_key("plugin")); + assert!(properties.contains_key("binding")); + // `tool` is already the outer runtime selector and `arguments` is a retired + // wrapper name, so the complete Plugin describe/call contract intentionally + // uses the canonical non-null `params` object rather than aliases. + assert_eq!(properties[TOOL_CALL_PARAMS_FIELD]["type"], "object"); + assert_eq!( + properties[TOOL_CALL_PARAMS_FIELD]["additionalProperties"], + true + ); + + let plugin = registered_tool_specs() + .into_iter() + .find(|candidate| candidate.name == "plugin_tool") + .expect("plugin_tool canonical ToolSpec"); + assert_eq!(plugin.input_schema["properties"]["tool"]["type"], "string"); + assert_eq!( + plugin.input_schema["properties"]["arguments"]["type"], + "object" + ); +} + #[test] fn openapi_call_runtime_tool_declares_flattened_action_fields() { // `tool_name` is a tool_manifest argument, distinct from the outer `tool` selector. diff --git a/src/plugin_gateway.rs b/src/plugin_gateway.rs index 27476e5a..65bdb709 100644 --- a/src/plugin_gateway.rs +++ b/src/plugin_gateway.rs @@ -8,9 +8,11 @@ pub(crate) use webcodex_core::plugin::*; use crate::auth::{AuthContext, SCOPE_PLUGIN_INSPECT, SCOPE_PLUGIN_INVOKE, SCOPE_PLUGIN_MANAGE}; -use crate::tool_runtime::specialized::{SpecializedOperationPolicy, SpecializedSource}; -use crate::tool_runtime::ToolRuntime; -use serde::Deserialize; +use crate::tool_runtime::sessions::SessionTransport; +use crate::tool_runtime::specialized::{ + SpecializedGovernanceDenial, SpecializedOperationPolicy, SpecializedSource, +}; +use crate::tool_runtime::{PluginToolCall, ToolResult, ToolRuntime}; use serde_json::{json, Value}; use std::collections::{HashMap, VecDeque}; use std::sync::Mutex; @@ -97,22 +99,9 @@ pub(crate) struct ResolvedPluginRunner { pub(crate) client_id: String, pub(crate) runner_instance_id: String, pub(crate) display_name: Option, - pub(crate) startup_providers: Vec, } -/// One exact startup Tool binding projected from the current caller-visible -/// frozen Runner registrations. The adapter recomputes uniqueness from these -/// candidates for every list/call; it is not a Server-side Plugin cache. #[derive(Debug, Clone)] -pub(crate) struct StartupPluginToolCandidate { - pub(crate) client_id: String, - pub(crate) runner_instance_id: String, - pub(crate) provider_id: String, - pub(crate) provider_instance_id: String, - pub(crate) tool: PluginTool, -} - -#[derive(Debug)] pub(crate) struct GatewayError { code: String, message: String, @@ -136,28 +125,12 @@ impl GatewayError { } } -#[derive(Debug)] +#[derive(Debug, Clone)] enum GatewaySuccess { Metadata(Value), ToolResult(PluginToolResult), } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PluginToolArguments { - action: String, - #[serde(default)] - runner: Option, - #[serde(default)] - plugin: Option, - #[serde(default)] - tool: Option, - #[serde(default)] - binding: Option, - #[serde(default)] - arguments: Option, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PluginOperation { List, @@ -214,35 +187,10 @@ impl PluginOperation { } } -pub(crate) fn operation_policy( - arguments: &Value, -) -> Result { - let action = arguments - .get("action") - .and_then(Value::as_str) - .ok_or("plugin_tool action is required")?; - PluginOperation::parse(action) - .map(PluginOperation::policy) - .ok_or("plugin_tool action must be one of list, check, reload, describe, or call") -} - -pub(crate) fn authorized(auth: Option<&AuthContext>) -> bool { - auth.is_some_and(|auth| { - auth.has_scope(SCOPE_PLUGIN_INSPECT) - || auth.has_scope(SCOPE_PLUGIN_INVOKE) - || auth.has_scope(SCOPE_PLUGIN_MANAGE) - }) -} - pub(crate) fn invoke_authorized(auth: Option<&AuthContext>) -> bool { auth.is_some_and(|auth| auth.has_scope(SCOPE_PLUGIN_INVOKE)) } -fn authorized_for_operation(auth: Option<&AuthContext>, operation: PluginOperation) -> bool { - let required = operation.policy().required_scope; - auth.is_some_and(|auth| auth.has_scope(required)) -} - /// Bounded model/ledger projection. Arbitrary Plugin arguments and opaque /// bindings are deliberately represented only by presence bits. pub(crate) fn audit_arguments(arguments: &Value) -> Value { @@ -250,8 +198,13 @@ pub(crate) fn audit_arguments(arguments: &Value) -> Value { let action = object .and_then(|o| o.get("action")) .and_then(Value::as_str) - .and_then(PluginOperation::parse) - .map(|operation| operation.policy().operation); + .filter(|value| { + !value.is_empty() + && value.len() <= 16 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'_') + }); let runner = object .and_then(|o| o.get("runner")) .and_then(Value::as_str) @@ -286,12 +239,13 @@ fn bounded_runner_id(value: &str) -> bool { /// be resolved without executing the Plugin. The binding value, provider /// instance identity, schema observation, and arbitrary arguments stay out of /// the projection. -pub(crate) async fn audit_arguments_with_identity( +async fn audit_request_with_identity( runtime: &ToolRuntime, - arguments: &Value, + request: &PluginToolCall, auth: Option<&AuthContext>, ) -> Value { - let audit = audit_arguments(arguments); + let arguments = serde_json::to_value(request).unwrap_or_else(|_| json!({})); + let audit = audit_arguments(&arguments); if !invoke_authorized(auth) { return audit; } @@ -321,116 +275,140 @@ fn audit_arguments_with_resolved_binding(mut audit: Value, binding: &PluginBindi audit } -pub(crate) fn audit_startup_direct(candidate: &StartupPluginToolCandidate) -> Value { - json!({ - "runner": candidate.client_id, - "plugin": candidate.provider_id, - "tool": candidate.tool.name, - "plane": "startup", - "arguments_present": true, - }) +#[derive(Debug, Clone)] +pub(crate) struct PluginInvocationResult { + operation: PluginOperation, + result: Result, } -pub(crate) fn tool_spec() -> Value { - json!({ - "name": PLUGIN_TOOL_NAME, - "description": "Develop and call Runner-local WebCodex native Tool Plugins. Canonical discovery is list -> list(runner) -> list(runner, plugin) -> describe -> call: provider-level list observes only the current committed/effective exact provider and returns bounded tool names/titles without creating a binding. Prefer action=check before reload while developing: check rereads runner.toml, starts one disposable candidate, initializes and lists tools, never calls tools/call, and returns bounded WebCodex-generated diagnostics without mutating committed Plugin state. Then use reload -> list(runner, plugin) -> describe -> call; restart the Runner only for startup first-class promotion. list never checks, reloads, or mutates Plugin state. describe creates the opaque exact binding; call accepts only that binding plus arguments. Stale Runner/provider observations never retarget or replay, and Plugin execution configuration stays Runner-local.", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["list", "check", "reload", "describe", "call"] - }, - "runner": { - "type": "string", - "description": "Exact caller-visible Runner client id. Optional for list; required for list(plugin), check, reload, and describe; not accepted for call." - }, - "plugin": { - "type": "string", - "description": "Logical Plugin provider id. Optional only for list when runner is also present; required for check and describe; not accepted for reload or call." - }, - "tool": { - "type": "string", - "description": "Logical Plugin tool name. Required only for describe; not accepted for call." - }, - "binding": { - "type": "string", - "pattern": "^wc_pbind_[0-9a-f]{32}$", - "description": "Opaque exact-observation handle returned by action=describe. Required for call. It does not grant authorization." - }, - "arguments": { - "type": "object", - "description": "Required for action=call. Arguments must match the schema returned by the describe that created binding." - }, - "recording_session_id": { - "type": "string", - "pattern": "^wc_sess_[A-Za-z0-9_]+$", - "description": "Optional explicit Workflow Session used for authority, read-only/guard, permission, and audit governance. It is never inferred from MCP transport identity." +impl PluginInvocationResult { + pub(crate) fn policy(&self) -> SpecializedOperationPolicy { + self.operation.policy() + } + + pub(crate) fn success(&self) -> bool { + match &self.result { + Ok(GatewaySuccess::Metadata(_)) => true, + Ok(GatewaySuccess::ToolResult(result)) => !result.is_error, + Err(_) => false, + } + } + + pub(crate) fn dispatch_certainty(&self) -> &'static str { + match &self.result { + Err(error) => dispatch_state_name( + error + .dispatch_state + .unwrap_or(PluginDispatchState::NotStarted), + ), + Ok(_) => "completed", + } + } + + pub(crate) fn failure_kind(&self) -> Option<&str> { + match &self.result { + Ok(GatewaySuccess::ToolResult(result)) if result.is_error => Some("plugin_tool_error"), + Err(error) => Some(error.code.as_str()), + _ => None, + } + } + + pub(crate) fn to_mcp_result(&self) -> Value { + render_gateway_result(self.result.clone()) + } + + pub(crate) fn to_tool_result(&self) -> ToolResult { + match &self.result { + Ok(GatewaySuccess::Metadata(value)) => ToolResult::ok(value.clone()), + Ok(GatewaySuccess::ToolResult(result)) => { + let mut output = serde_json::to_value(result).unwrap_or_else(|_| json!({})); + if let Some(object) = output.as_object_mut() { + object.insert( + "dispatch_certainty".to_string(), + Value::String("completed".to_string()), + ); + if result.is_error { + object.insert( + "failure_kind".to_string(), + Value::String("plugin_tool_error".to_string()), + ); + } } - }, - "required": ["action"], - "additionalProperties": false - }, - "annotations": { - "readOnlyHint": false + if result.is_error { + ToolResult::err_with_output("Plugin tool reported an error", output) + } else { + ToolResult::ok(output) + } + } + Err(error) => gateway_error_tool_result(error), } - }) + } } -pub(crate) async fn call( +/// Canonical action-aware Plugin invocation shared by MCP and the generic Tool +/// Runtime. This is the only owner of Plugin scope/session/permission lifecycle. +pub(crate) async fn invoke( runtime: &ToolRuntime, - arguments: Value, + request: PluginToolCall, + recording_session_id: Option<&str>, auth: Option<&AuthContext>, -) -> Value { - let parsed: PluginToolArguments = match serde_json::from_value(arguments) { - Ok(parsed) => parsed, - Err(_) => { - return gateway_error_result(GatewayError::local( - "invalid_arguments", - "plugin_tool arguments are invalid", - )) - } - }; - let Some(operation) = PluginOperation::parse(&parsed.action) else { - return gateway_error_result(GatewayError::local( - "invalid_action", - "action must be one of list, check, reload, describe, or call", - )); - }; - if !authorized_for_operation(auth, operation) { - return gateway_error_result(GatewayError::local( - "insufficient_scope", - format!( - "Plugin operation '{}' requires the {} scope", - operation.policy().operation, - operation.policy().required_scope - ), - )); - } + transport: SessionTransport, +) -> Result { + let operation = PluginOperation::parse(&request.action) + .expect("PluginToolCall parser admits only the closed action vocabulary"); + let policy = operation.policy(); + let audit = audit_request_with_identity(runtime, &request, auth).await; + let permit = runtime + .govern_specialized_invocation( + PLUGIN_TOOL_NAME, + policy, + transport, + recording_session_id, + auth, + &audit, + ) + .await?; + + let result = execute_business(runtime, operation, request, auth).await; + let invocation = PluginInvocationResult { operation, result }; + runtime.finish_specialized_invocation( + permit, + invocation.success(), + invocation.dispatch_certainty(), + invocation.failure_kind(), + ); + Ok(invocation) +} + +async fn execute_business( + runtime: &ToolRuntime, + operation: PluginOperation, + request: PluginToolCall, + auth: Option<&AuthContext>, +) -> Result { let result = match operation { - PluginOperation::List => list(runtime, parsed, auth) + PluginOperation::List => list(runtime, request, auth) .await .map(GatewaySuccess::Metadata), - PluginOperation::Check => check(runtime, parsed, auth) + PluginOperation::Check => check(runtime, request, auth) .await .map(GatewaySuccess::Metadata), - PluginOperation::Reload => reload(runtime, parsed, auth) + PluginOperation::Reload => reload(runtime, request, auth) .await .map(GatewaySuccess::Metadata), - PluginOperation::Describe => describe(runtime, parsed, auth) + PluginOperation::Describe => describe(runtime, request, auth) .await .map(GatewaySuccess::Metadata), - PluginOperation::Call => call_plugin(runtime, parsed, auth) + PluginOperation::Call => call_plugin(runtime, request, auth) .await .map(GatewaySuccess::ToolResult), }; - render_gateway_result(result) + result } async fn list( runtime: &ToolRuntime, - args: PluginToolArguments, + args: PluginToolCall, auth: Option<&AuthContext>, ) -> Result { if args.tool.is_some() || args.binding.is_some() || args.arguments.is_some() { @@ -451,7 +429,7 @@ async fn list( let plugin = required_provider(Some(plugin))?; let (provider, tools) = observe_effective_provider_tools(runtime, &runner, plugin, auth).await?; - let mut value = sanitize_provider_view_for_runner(&runner, provider); + let mut value = sanitize_provider_view(provider); value["runner"] = Value::String(runner.client_id.clone()); value["toolCount"] = Value::from(tools.len()); value["tools"] = Value::Array( @@ -464,14 +442,13 @@ async fn list( } let response = execute_exact(runtime, &runner, PluginGatewayRequest::ProvidersList, auth).await?; - let (providers, restart_required) = response_providers(response)?; + let providers = response_providers(response)?; return Ok(json!({ "runner": runner.client_id, "plugins": providers .into_iter() - .map(|provider| sanitize_provider_view_for_runner(&runner, provider)) - .collect::>(), - "firstClassRestartRequired": restart_required + .map(sanitize_provider_view) + .collect::>() })); } @@ -491,7 +468,7 @@ async fn list( async fn check( runtime: &ToolRuntime, - args: PluginToolArguments, + args: PluginToolCall, auth: Option<&AuthContext>, ) -> Result { if args.tool.is_some() || args.binding.is_some() || args.arguments.is_some() { @@ -528,7 +505,7 @@ async fn check( async fn reload( runtime: &ToolRuntime, - args: PluginToolArguments, + args: PluginToolCall, auth: Option<&AuthContext>, ) -> Result { if args.plugin.is_some() @@ -551,15 +528,13 @@ async fn reload( Some(PluginGatewayResponsePayload::Reloaded { providers, failures, - first_class_restart_required, }) => Ok(json!({ "runner": runner.client_id, "plugins": providers .into_iter() - .map(|provider| sanitize_provider_view_for_runner(&runner, provider)) + .map(sanitize_provider_view) .collect::>(), - "failures": failures, - "firstClassRestartRequired": first_class_restart_required + "failures": failures })), _ => Err(GatewayError::local( "invalid_plugin_response", @@ -570,7 +545,7 @@ async fn reload( async fn describe( runtime: &ToolRuntime, - args: PluginToolArguments, + args: PluginToolCall, auth: Option<&AuthContext>, ) -> Result { if args.binding.is_some() || args.arguments.is_some() { @@ -619,7 +594,7 @@ async fn observe_effective_provider_tools( ) -> Result<(PluginProviderView, Vec), GatewayError> { let providers_response = execute_exact(runtime, runner, PluginGatewayRequest::ProvidersList, auth).await?; - let (providers, _) = match response_providers(providers_response) { + let providers = match response_providers(providers_response) { Ok(value) => value, Err(mut error) if matches!(error.code.as_str(), "stale_runner" | "runner_unavailable") => { error.code = "plugin_replaced".to_string(); @@ -643,7 +618,6 @@ async fn observe_effective_provider_tools( runtime, runner, PluginGatewayRequest::ToolsList { - plane: PluginPlane::Effective, provider_id: provider.provider_id.clone(), provider_instance_id: provider.provider_instance_id.clone(), }, @@ -673,7 +647,7 @@ async fn observe_effective_provider_tools( async fn call_plugin( runtime: &ToolRuntime, - args: PluginToolArguments, + args: PluginToolCall, auth: Option<&AuthContext>, ) -> Result { if args.runner.is_some() || args.plugin.is_some() || args.tool.is_some() { @@ -722,7 +696,6 @@ async fn call_plugin( runtime, &runner, PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Effective, provider_id: observed.provider_id.clone(), provider_instance_id: observed.provider_instance_id.clone(), name: observed.tool_name.clone(), @@ -806,134 +779,11 @@ pub(crate) async fn visible_plugin_runners( client_id: runner.client_id, runner_instance_id: runner.runner_instance_id, display_name: runner.display_name, - startup_providers: runner - .policy - .and_then(|policy| policy.plugin_providers) - .unwrap_or_default(), }); } runners } -/// Return exact startup Tool candidates from sanitized immutable registration -/// inventory. This helper intentionally does not require Plugin invocation authority: call -/// dispatch uses it before the scope check so a spoofed direct Plugin name is -/// rejected as forbidden instead of falling through to an unrelated unknown -/// static tool path. Runner visibility policy remains authoritative. -pub(crate) async fn startup_tool_candidates( - runtime: &ToolRuntime, - auth: Option<&AuthContext>, -) -> Vec { - let access = crate::runner_http::runner_access_from_auth(auth); - let mut candidates = Vec::new(); - for runner in runtime - .runner_registry - .list_runners_for_auth(access.as_ref()) - .await - { - if !runner.connected - || !runner.capabilities.native_tool_plugins - || runtime - .runner_registry - .assert_runner_access(access.as_ref(), &runner.client_id) - .await - .is_err() - { - continue; - } - let Some(providers) = runner - .policy - .as_ref() - .and_then(|policy| policy.plugin_providers.as_ref()) - else { - continue; - }; - for provider in providers { - if provider.status != "ready" { - continue; - } - // Failed/secondary-only startup providers have no admitted direct - // ToolSpecs. Do not infer process health or fetch tools remotely. - for tool in &provider.tools { - candidates.push(StartupPluginToolCandidate { - client_id: runner.client_id.clone(), - runner_instance_id: runner.runner_instance_id.clone(), - provider_id: provider.provider_id.clone(), - provider_instance_id: provider.provider_instance_id.clone(), - tool: tool.clone(), - }); - } - } - } - candidates -} - -pub(crate) async fn call_startup_direct( - runtime: &ToolRuntime, - candidate: &StartupPluginToolCandidate, - arguments: Value, - auth: Option<&AuthContext>, -) -> Value { - if !invoke_authorized(auth) { - return gateway_error_result(GatewayError::local( - "insufficient_scope", - "direct native Plugin calls require the plugin:invoke scope", - )); - } - if !arguments.is_object() { - return gateway_error_result(GatewayError::local( - "invalid_arguments", - "direct Plugin arguments must be a JSON object", - )); - } - if validate_json_value(&arguments, PLUGIN_MAX_ARGUMENT_BYTES, "tool arguments").is_err() { - return gateway_error_result(GatewayError::local( - "invalid_arguments", - "direct Plugin arguments exceed Plugin bounds", - )); - } - let runner = ResolvedPluginRunner { - client_id: candidate.client_id.clone(), - runner_instance_id: candidate.runner_instance_id.clone(), - display_name: None, - startup_providers: Vec::new(), - }; - let response = match execute_exact( - runtime, - &runner, - PluginGatewayRequest::ToolsCall { - plane: PluginPlane::Startup, - provider_id: candidate.provider_id.clone(), - provider_instance_id: candidate.provider_instance_id.clone(), - name: candidate.tool.name.clone(), - arguments, - expected_schema: candidate.tool.schema_observation(), - }, - auth, - ) - .await - { - Ok(response) => response, - Err(error) => return gateway_error_result(error), - }; - if let Some(error) = response.error { - return gateway_error_result(response_error(response.dispatch_state, error)); - } - match response.payload { - Some(PluginGatewayResponsePayload::ToolResult { result }) => serde_json::to_value(result) - .unwrap_or_else(|_| { - gateway_error_result(GatewayError::local( - "invalid_plugin_result", - "Plugin result could not be encoded", - )) - }), - _ => gateway_error_result(GatewayError::local( - "invalid_plugin_result", - "Runner returned an unexpected direct Plugin tool result", - )), - } -} - pub(crate) async fn resolve_runner( runtime: &ToolRuntime, runner_id: &str, @@ -1026,15 +876,12 @@ pub(crate) async fn execute_exact( fn response_providers( response: PluginGatewayResponse, -) -> Result<(Vec, bool), GatewayError> { +) -> Result, GatewayError> { if let Some(error) = response.error { return Err(response_error(response.dispatch_state, error)); } match response.payload { - Some(PluginGatewayResponsePayload::Providers { - providers, - first_class_restart_required, - }) => Ok((providers, first_class_restart_required)), + Some(PluginGatewayResponsePayload::Providers { providers }) => Ok(providers), _ => Err(GatewayError::local( "invalid_plugin_response", "Runner returned an unexpected Plugin provider response", @@ -1099,19 +946,6 @@ fn sanitize_check_report(runner: &str, report: PluginCheckReport) -> Value { } value["diagnostic"] = summary; } - if let Some(shape) = report.startup_tool_shape { - let mut startup_shape = json!({"eligible": shape.eligible}); - if let Some(code) = shape.code { - startup_shape["code"] = Value::String(code); - } - if let Some(tool) = shape.tool { - startup_shape["tool"] = Value::String(tool); - } - if let Some(field) = shape.field { - startup_shape["field"] = Value::String(field); - } - value["startupToolShape"] = startup_shape; - } value } @@ -1137,40 +971,13 @@ fn sanitize_tool_summary(tool: PluginTool) -> Value { summary } -fn sanitize_provider_view_for_runner( - runner: &ResolvedPluginRunner, - provider: PluginProviderView, -) -> Value { - let startup_admission = runner - .startup_providers - .iter() - .find(|startup| startup.provider_id == provider.provider_id); - let mut value = json!({ +fn sanitize_provider_view(provider: PluginProviderView) -> Value { + json!({ "plugin": provider.provider_id, "name": provider.name, "status": provider.status, - "errorCode": provider.error_code, - "source": match provider.plane { - PluginPlane::Startup => "startup", - PluginPlane::Effective => "dynamic" - }, - "startupDirectToolCount": provider.startup_direct_tool_count - }); - if let Some(startup) = startup_admission { - let admission = match startup.status.as_str() { - "ready" => Some("direct"), - "ready_secondary" => Some("secondary"), - "failed" => Some("failed"), - _ => None, - }; - if let Some(admission) = admission { - value["startupAdmission"] = Value::String(admission.to_string()); - if let Some(code) = startup.error_code.as_ref() { - value["startupAdmissionCode"] = Value::String(code.clone()); - } - } - } - value + "errorCode": provider.error_code + }) } fn required_runner(value: Option<&str>) -> Result<&str, GatewayError> { @@ -1243,6 +1050,24 @@ fn render_gateway_result(result: Result) -> Value } } +fn gateway_error_tool_result(error: &GatewayError) -> ToolResult { + let state = error + .dispatch_state + .unwrap_or(PluginDispatchState::NotStarted); + let mut output = json!({ + "error": { + "code": error.code, + "message": error.message, + }, + "failure_kind": error.code, + "dispatch_certainty": dispatch_state_name(state), + }); + if let Some(recovery) = error.recovery { + output["recovery"] = Value::String(recovery.to_string()); + } + ToolResult::err_with_output(error.message.clone(), output) +} + fn gateway_success_result(value: Value) -> Value { let text = serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string()); json!({ @@ -1328,11 +1153,15 @@ mod tests { } #[test] - fn fixed_plugin_tool_catalog_hides_runtime_identity_and_provider_defined_output_schema() { - let spec = tool_spec(); + fn canonical_plugin_tool_catalog_hides_runtime_identity_and_provider_defined_output_schema() { + let spec = webcodex_tool_contracts::registered_tool_specs() + .into_iter() + .find(|spec| spec.name == PLUGIN_TOOL_NAME) + .expect("plugin_tool must be a canonical registered ToolSpec"); + let spec = serde_json::to_value(spec).unwrap(); let encoded = serde_json::to_string(&spec).unwrap(); assert_eq!(spec["name"], PLUGIN_TOOL_NAME); - assert!(spec.get("outputSchema").is_none()); + assert!(spec["outputSchema"].is_object()); assert!(spec["inputSchema"]["properties"]["binding"].is_object()); assert!(spec["inputSchema"]["properties"]["plugin"].is_object()); assert!(spec["inputSchema"]["properties"]["runner"].is_object()); @@ -1348,7 +1177,7 @@ mod tests { assert!(spec["description"] .as_str() .unwrap() - .contains("call accepts only that binding plus arguments")); + .contains("call accepts only binding + arguments")); assert!(!encoded.contains("provider_instance_id")); assert!(!encoded.contains("runner_instance_id")); assert!(!encoded.contains("revision")); diff --git a/src/runtime_http.rs b/src/runtime_http.rs index e2b52abc..4ed1e5a8 100644 --- a/src/runtime_http.rs +++ b/src/runtime_http.rs @@ -272,7 +272,7 @@ pub async fn tools_call(req: &mut Request, depot: &mut Depot, res: &mut Response return; } }; - guard.capture_payload("raw_request_body", &body); + guard.capture_payload("raw_request_body", &tool_call_trace_raw_body(&body)); let (tool, params) = match extract_tool_call(&body) { Ok(pair) => pair, Err(msg) => { @@ -293,7 +293,10 @@ pub async fn tools_call(req: &mut Request, depot: &mut Depot, res: &mut Response }; guard.set_tool_name(Some(tool.clone())); guard.parsed("ok"); - guard.capture_payload("effective_arguments", ¶ms); + guard.capture_payload( + "effective_arguments", + &tool_call_trace_effective_arguments(&tool, ¶ms), + ); // dispatch_started only after argument extraction succeeds and immediately // before ToolRuntime dispatch. guard.dispatch_started(); @@ -439,6 +442,49 @@ pub async fn tools_call(req: &mut Request, depot: &mut Depot, res: &mut Response } } +fn tool_call_trace_raw_body(body: &Value) -> Value { + let Some(object) = body.as_object() else { + return body.clone(); + }; + if object.get(TOOL_CALL_TOOL_FIELD).and_then(Value::as_str) + != Some(crate::plugin_gateway::PLUGIN_TOOL_NAME) + { + return body.clone(); + } + let plugin_arguments = object + .get(TOOL_CALL_PARAMS_FIELD) + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + let mut flattened = serde_json::Map::new(); + for (key, value) in object { + if key == TOOL_CALL_TOOL_FIELD + || key == TOOL_CALL_PARAMS_FIELD + || key == TOOL_CALL_RECORDING_SESSION_ID_FIELD + { + continue; + } + flattened.insert(key.clone(), value.clone()); + } + Value::Object(flattened) + }); + json!({ + "tool": crate::plugin_gateway::PLUGIN_TOOL_NAME, + "arguments": crate::plugin_gateway::audit_arguments(&plugin_arguments), + "recording_session_id_present": object + .get(TOOL_CALL_RECORDING_SESSION_ID_FIELD) + .is_some(), + }) +} + +fn tool_call_trace_effective_arguments(tool: &str, params: &Value) -> Value { + if tool == crate::plugin_gateway::PLUGIN_TOOL_NAME { + crate::plugin_gateway::audit_arguments(params) + } else { + params.clone() + } +} + /// Extract `(tool, params)` from a raw `callRuntimeTool` request body. /// /// Accepted shapes (all route to the same tool dispatch): diff --git a/src/runtime_http_tests.rs b/src/runtime_http_tests.rs index 912e56cd..74d2d425 100644 --- a/src/runtime_http_tests.rs +++ b/src/runtime_http_tests.rs @@ -843,6 +843,75 @@ fn extract_tool_call_params_precede_flattened_fields() { assert_eq!(params, json!({"project": "right"})); } +#[test] +fn extract_tool_call_plugin_tool_preserves_provider_local_tool_inside_params() { + let body = json!({ + "tool": "plugin_tool", + "params": { + "action": "describe", + "runner": "runner-a", + "plugin": "repo-tools", + "tool": "safe_delete" + }, + TOOL_CALL_RECORDING_SESSION_ID_FIELD: "wc_sess_plugin_record" + }); + let (tool, params) = extract_tool_call(&body).unwrap(); + assert_eq!(tool, "plugin_tool"); + assert_eq!(params["action"], "describe"); + assert_eq!(params["tool"], "safe_delete"); + let parsed = ToolCall::from_tool_name(&tool, params).unwrap(); + assert_eq!(parsed.tool_name(), "plugin_tool"); + assert!(matches!(parsed, ToolCall::PluginTool(_))); + assert_eq!( + extract_recording_session_id(&body), + Some("wc_sess_plugin_record".to_string()) + ); + + let (tool, params) = extract_tool_call(&json!({ + "tool": "plugin_tool", + "params": { + "action": "call", + "binding": "wc_pbind_00000000000000000000000000000000", + "arguments": {"path": "build/old.bin"} + } + })) + .unwrap(); + assert!(matches!( + ToolCall::from_tool_name(&tool, params).unwrap(), + ToolCall::PluginTool(_) + )); +} + +#[test] +fn plugin_tool_api_trace_projection_hides_binding_and_raw_arguments() { + let body = json!({ + "tool": "plugin_tool", + "params": { + "action": "call", + "binding": "wc_pbind_0123456789abcdef0123456789abcdef", + "arguments": {"path": "private/target.txt", "secret": "must-not-leak"} + }, + TOOL_CALL_RECORDING_SESSION_ID_FIELD: "wc_sess_plugin_record" + }); + let raw = tool_call_trace_raw_body(&body); + let encoded = serde_json::to_string(&raw).unwrap(); + assert!(!encoded.contains("wc_pbind_")); + assert!(!encoded.contains("private/target.txt")); + assert!(!encoded.contains("must-not-leak")); + assert_eq!(raw["tool"], "plugin_tool"); + assert_eq!(raw["arguments"]["binding_present"], true); + assert_eq!(raw["arguments"]["arguments_present"], true); + assert_eq!(raw["recording_session_id_present"], true); + + let effective = tool_call_trace_effective_arguments("plugin_tool", &body["params"]); + let encoded = serde_json::to_string(&effective).unwrap(); + assert!(!encoded.contains("wc_pbind_")); + assert!(!encoded.contains("private/target.txt")); + assert!(!encoded.contains("must-not-leak")); + assert_eq!(effective["binding_present"], true); + assert_eq!(effective["arguments_present"], true); +} + #[test] fn extract_tool_call_rejects_retired_arguments_envelope() { for arguments in [json!(null), json!({"project": "right"})] { diff --git a/src/tool_runtime/dispatch.rs b/src/tool_runtime/dispatch.rs index 154a352f..09ad4d97 100644 --- a/src/tool_runtime/dispatch.rs +++ b/src/tool_runtime/dispatch.rs @@ -874,6 +874,33 @@ impl ToolRuntime { ) -> ToolResult { call = call .with_coding_agent_recording_session_id(recorder_metadata.recording_session_id.clone()); + if let ToolCall::PluginTool(plugin) = call { + return match crate::plugin_gateway::invoke( + self, + plugin, + recorder_metadata.recording_session_id.as_deref(), + auth, + transport, + ) + .await + { + Ok(invocation) => invocation.to_tool_result(), + Err(crate::tool_runtime::specialized::SpecializedGovernanceDenial::Scope { + required_scope, + description, + }) => ToolResult::err_with_output( + description, + serde_json::json!({ + "failure_kind": "insufficient_scope", + "required_scope": required_scope, + "dispatch_certainty": "not_started", + }), + ), + Err(crate::tool_runtime::specialized::SpecializedGovernanceDenial::Tool( + result, + )) => result, + }; + } // Kernel requests arrive with the same trusted logical identity already // used by the outer recorder. Mark only this concrete ledger path as the // authoritative business role; direct/internal dispatch without a kernel @@ -1362,6 +1389,12 @@ impl ToolRuntime { self.dispatch_runner_config_tool(call, auth).await } + ToolCall::PluginTool(_) => { + unreachable!( + "plugin_tool is dispatched before generic static ToolDefinition policy" + ) + } + call @ (ToolCall::StartSession { .. } | ToolCall::SessionSummary { .. } | ToolCall::UpdateSessionContext { .. } diff --git a/src/tool_runtime/kernel.rs b/src/tool_runtime/kernel.rs index 9edb5849..780e02d2 100644 --- a/src/tool_runtime/kernel.rs +++ b/src/tool_runtime/kernel.rs @@ -8,6 +8,7 @@ use super::{ }; use crate::auth::scopes::OAuthToolScopePolicy; use crate::auth::AuthContext; +use crate::tool_runtime::specialized::SpecializedGovernanceDenial; use serde_json::Value; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -116,6 +117,12 @@ pub(crate) fn check_runtime_tool_scope( // credential. This derives only from the canonical ToolDefinition // authority policy; it is not a tool-name registry. let required_explicit_scope = match policy { + OAuthToolScopePolicy::RequireAny(scopes) => { + return Err(ToolCallErrorStatus::InsufficientScope { + required_scope: None, + description: format!("missing any required scope: {}", scopes.join(", ")), + }); + } OAuthToolScopePolicy::Require(scope) if matches!( scope, @@ -146,6 +153,16 @@ pub(crate) fn check_runtime_tool_scope( }; match policy { + OAuthToolScopePolicy::RequireAny(scopes) => { + if scopes.iter().copied().any(|scope| auth.has_scope(scope)) { + Ok(()) + } else { + Err(ToolCallErrorStatus::InsufficientScope { + required_scope: None, + description: format!("missing any required scope: {}", scopes.join(", ")), + }) + } + } OAuthToolScopePolicy::Require(scope) => { if auth.has_scope(scope) { Ok(()) @@ -350,6 +367,70 @@ impl ToolRuntime { model_ergonomics: None, }; } + // `plugin_tool` is a heterogeneous canonical gateway. Its static + // ToolDefinition intentionally describes worst-case visibility/risk, + // but exact execution policy comes from the validated `action`. Route + // it before the generic static Session/permission lifecycle so + // list/describe remain read-only and one invocation owns one ledger. + if request.tool_name == crate::plugin_gateway::PLUGIN_TOOL_NAME { + let concrete_arguments = + strip_tool_call_expectation_metadata(request.arguments.clone()); + let call = match ToolCall::from_tool_name(&request.tool_name, concrete_arguments) { + Ok(call) => call, + Err(message) => { + return ToolCallOutcome { + success: false, + result: None, + error_status: Some(ToolCallErrorStatus::InvalidArguments { message }), + project: None, + model_ergonomics: None, + } + } + }; + let ToolCall::PluginTool(plugin) = call else { + unreachable!("plugin_tool parser must yield ToolCall::PluginTool"); + }; + return match crate::plugin_gateway::invoke( + self, + plugin, + context.session_id, + context.auth, + context.transport.into(), + ) + .await + { + Ok(invocation) => { + let result = invocation.to_tool_result(); + ToolCallOutcome { + success: result.success, + result: Some(result), + error_status: None, + project: None, + model_ergonomics: None, + } + } + Err(SpecializedGovernanceDenial::Scope { + required_scope, + description, + }) => ToolCallOutcome { + success: false, + result: None, + error_status: Some(ToolCallErrorStatus::InsufficientScope { + required_scope: Some(required_scope), + description, + }), + project: None, + model_ergonomics: None, + }, + Err(SpecializedGovernanceDenial::Tool(result)) => ToolCallOutcome { + success: result.success, + result: Some(result), + error_status: None, + project: None, + model_ergonomics: None, + }, + }; + } let concrete_arguments = strip_tool_call_expectation_metadata(request.arguments.clone()); let context_request = if capabilities.context_sidecar { super::context_projection::context_request_from_arguments(&request.arguments) diff --git a/src/tool_runtime/mod.rs b/src/tool_runtime/mod.rs index 178df65e..ece9a812 100644 --- a/src/tool_runtime/mod.rs +++ b/src/tool_runtime/mod.rs @@ -110,8 +110,8 @@ pub(crate) use session_context::workflow_session_authority_fingerprint; #[cfg(test)] pub(crate) use sessions::{SessionCreateOptions, SessionGuards, SessionSummary}; pub use tool_call::{ - ObserveJobsItem, ReadFilesItem, SearchPatternMode, SearchProjectTextsQuery, SearchResultMode, - ToolCall, + ObserveJobsItem, PluginToolCall, ReadFilesItem, SearchPatternMode, SearchProjectTextsQuery, + SearchResultMode, ToolCall, }; pub(crate) use tool_call::{ TOOL_CALL_PARAMS_FIELD, TOOL_CALL_TOOL_FIELD, TOOL_CALL_WRAPPER_FIELDS, diff --git a/src/tool_runtime/specialized.rs b/src/tool_runtime/specialized.rs index f1676663..63fd930e 100644 --- a/src/tool_runtime/specialized.rs +++ b/src/tool_runtime/specialized.rs @@ -197,12 +197,13 @@ fn denial_terminal_projection(policy: SpecializedOperationPolicy, kind: &str) -> impl ToolRuntime { /// Resolve the shared authority boundary for one already-classified - /// specialized operation. A returned permit is the only path to effectful - /// gateway execution from MCP. + /// specialized operation. Transport is explicit provenance only; it never + /// changes authority or infers a Workflow Session. pub(crate) async fn govern_specialized_invocation( &self, external_tool_name: &str, policy: SpecializedOperationPolicy, + transport: SessionTransport, recording_session_id: Option<&str>, auth: Option<&AuthContext>, identity: &Value, @@ -244,7 +245,7 @@ impl ToolRuntime { }; let mut session_start = self.sessions.record_tool_call_started_with_metadata( recording_session_id, - SessionTransport::Mcp, + transport, external_tool_name, &bounded_ledger_arguments(policy, identity), resolved_session_project.clone(), @@ -401,6 +402,7 @@ mod tests { "list", SCOPE_PLUGIN_INSPECT, ), + SessionTransport::Mcp, Some(&session.session_id), Some(&auth), &json!({"plugin": "repo-tools"}), @@ -417,6 +419,7 @@ mod tests { "call", SCOPE_PLUGIN_INVOKE, ), + SessionTransport::Mcp, Some(&session.session_id), Some(&auth), &json!({"plugin": "repo-tools"}), @@ -447,6 +450,7 @@ mod tests { "list", SCOPE_PLUGIN_INSPECT, ), + SessionTransport::Mcp, None, Some(&auth), &json!({}), @@ -464,6 +468,7 @@ mod tests { "call", SCOPE_PLUGIN_INVOKE, ), + SessionTransport::Mcp, None, Some(&auth), &json!({}), @@ -493,6 +498,7 @@ mod tests { "list", SCOPE_PLUGIN_INSPECT, ), + SessionTransport::Mcp, Some(&session.session_id), Some(&foreign), &json!({}), diff --git a/src/tool_runtime/surface.rs b/src/tool_runtime/surface.rs index fb66faf7..6f7a1a1c 100644 --- a/src/tool_runtime/surface.rs +++ b/src/tool_runtime/surface.rs @@ -505,6 +505,10 @@ fn manifest_authority(policy: ToolAuthorityPolicy) -> Value { "policy": "require", "scopes": [scope], }), + ToolAuthorityPolicy::RequireAny(scopes) => json!({ + "policy": "require_any", + "scopes": scopes, + }), ToolAuthorityPolicy::RequireAll(scopes) => json!({ "policy": "require_all", "scopes": scopes, diff --git a/src/tool_runtime/tests/metadata.rs b/src/tool_runtime/tests/metadata.rs index 12975dd1..0c6ef6e0 100644 --- a/src/tool_runtime/tests/metadata.rs +++ b/src/tool_runtime/tests/metadata.rs @@ -1219,7 +1219,6 @@ async fn list_projects_shows_shell_profile_resolution() { shell_profiles: Some(summary), tool_providers: None, mcp_gateway_providers: None, - plugin_providers: None, }; let mut configured = registered_project("rust-proj", "/root/git/rust"); configured.shell_profile = Some("rust".to_string()); @@ -1369,7 +1368,6 @@ async fn runtime_status_shell_profiles_summary_is_sanitized() { shell_profiles: Some(summary), tool_providers: None, mcp_gateway_providers: None, - plugin_providers: None, }), }) .await @@ -2782,7 +2780,6 @@ async fn runtime_status_includes_sanitized_policy_summary() { config_reload: RunnerConfigReloadStatus::default(), }), mcp_gateway_providers: None, - plugin_providers: None, }); registry.register(registration).await.unwrap(); let current_provider = ToolProvidersStatus { @@ -3216,7 +3213,6 @@ async fn list_runners_includes_sanitized_policy_summary() { shell_profiles: None, tool_providers: None, mcp_gateway_providers: None, - plugin_providers: None, }); registry.register(registration).await.unwrap(); let runtime = ToolRuntime::new(registry, Arc::new(RuntimeInfo::default())); diff --git a/src/tool_runtime/tests/schema/migration.rs b/src/tool_runtime/tests/schema/migration.rs index 7bf8e0af..26a76790 100644 --- a/src/tool_runtime/tests/schema/migration.rs +++ b/src/tool_runtime/tests/schema/migration.rs @@ -55,6 +55,35 @@ fn tool_definition_explains_all_tool_call_runtime_names() { } } +#[test] +fn plugin_tool_call_parser_is_typed_bounded_and_closed() { + let list = ToolCall::from_tool_name("plugin_tool", json!({"action":"list"})).unwrap(); + assert_eq!(list.tool_name(), "plugin_tool"); + assert!(matches!(list, ToolCall::PluginTool(_))); + for invalid in [ + json!({"action":"list","unknown":true}), + json!({"action":"list","plugin":"repo-tools"}), + json!({"action":"call","binding":"wc_pbind_bad","arguments":{}}), + json!({"action":"call","binding":"wc_pbind_00000000000000000000000000000000"}), + json!({"action":"describe","runner":"runner-a","plugin":"repo-tools"}), + ] { + assert!( + ToolCall::from_tool_name("plugin_tool", invalid).is_err(), + "invalid Plugin gateway arguments must fail closed" + ); + } + let call = ToolCall::from_tool_name( + "plugin_tool", + json!({ + "action":"call", + "binding":"wc_pbind_0123456789abcdef0123456789abcdef", + "arguments":{} + }), + ) + .unwrap(); + assert!(matches!(call, ToolCall::PluginTool(_))); +} + #[test] fn tool_definition_metadata_fallback_facade_is_unknown_only() { use crate::tool_runtime::metadata::{lookup_tool_metadata, tool_metadata}; diff --git a/src/tool_runtime/tests/support/runtime.rs b/src/tool_runtime/tests/support/runtime.rs index c502179a..ca395998 100644 --- a/src/tool_runtime/tests/support/runtime.rs +++ b/src/tool_runtime/tests/support/runtime.rs @@ -59,6 +59,9 @@ pub(in crate::tool_runtime::tests) fn sample_tool_args_for_spec(spec: &ToolSpec) "observe_jobs" => { args.insert("items".to_string(), json!([{"job_id": "job_123"}])); } + "plugin_tool" => { + args.insert("action".to_string(), json!("list")); + } _ => {} } Value::Object(args) @@ -110,7 +113,7 @@ pub(in crate::tool_runtime::tests) fn sample_field_value(field: &str) -> Value { "task_id" => json!(format!("wc_agent_task_{}", "1".repeat(32))), "attempt_id" => json!(format!("wc_agent_task_attempt_{}", "2".repeat(32))), "attempt_fence" => json!(format!("wc_agent_task_fence_{}", "3".repeat(32))), - "attempt_controller_generation" => json!(1), + "attempt_controller_generation" | "expected_generation" => json!(1), "outcome" => json!("succeeded"), "agent_ids" => json!([format!("wc_dagent_{}", "a".repeat(32))]), "endpoint_id" => json!(format!("wc_endpoint_{}", "b".repeat(32))),