diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 5c7da5bbd9..9f0c2de5c7 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -127,6 +127,12 @@ export const productCoreFeatureAssemblyRules = [ requiredFeatures: ['product-full'], reason: 'CLI must explicitly assemble the full bitfun-core product runtime', }, + { + manifestPath: 'src/apps/server/Cargo.toml', + dependencyName: 'bitfun-core', + requiredFeatures: ['product-full'], + reason: 'Server must explicitly assemble the full bitfun-core product runtime', + }, { manifestPath: 'src/crates/interfaces/acp/Cargo.toml', dependencyName: 'bitfun-core', diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index a6abb5c445..16e41c0d1d 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -9,6 +9,7 @@ export const publicApiContractSlices = [ 'external-source-tool-contract', 'external-source-subagent-contract', 'external-source-mcp-contract', + 'external-integration-policy-contract', ]; const contractSlices = { @@ -20,6 +21,7 @@ const contractSlices = { externalSourceToolContract: 'external-source-tool-contract', externalSourceSubagentContract: 'external-source-subagent-contract', externalSourceMcpContract: 'external-source-mcp-contract', + externalIntegrationPolicyContract: 'external-integration-policy-contract', }; function pluginRuntimeEntry(symbol, p0, consumer, verification, contractSlice, wireImpact = true) { @@ -230,6 +232,55 @@ function externalSourceEntry(symbol, owner, consumer, wireImpact = false) { }; } +function externalIntegrationPolicyEntry( + symbol, + owner = 'product-domains external integration policy contract owner', + consumer = 'bitfun-core product composition and cross-host product surfaces', + wireImpact = true, +) { + return { + symbol, + owner, + consumer, + verification: + 'external integration policy contract tests, core policy lifecycle tests, cross-host route tests, and Web policy-control tests', + p0: 'host-owned external integration policy and OpenCode-compatible product defaults', + contractSlice: contractSlices.externalIntegrationPolicyContract, + wireImpact, + rationale: + 'all product surfaces need one ecosystem-neutral, versioned, fail-closed policy contract while concrete ecosystem defaults remain in product assembly', + exit: + 'remove only through a reviewed policy-contract migration with equivalent compatibility, safety-ceiling, and cross-host behavior tests', + }; +} + +export const externalIntegrationPolicyPublicApiEntries = [ + 'EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR', + 'ExternalIntegrationMode', + 'ExternalIntegrationAccess', + 'ExternalEcosystemPolicy', + 'ExternalIntegrationPolicySettings', + 'ExternalIntegrationPolicySettingsView', + 'ExternalEcosystemPolicyOverride', + 'ExternalEcosystemPolicyOverrideView', + 'ExternalIntegrationPolicyOverride', + 'ExternalIntegrationPolicyOverrideView', + 'ExternalIntegrationPolicyDocument', + 'ExternalIntegrationCapabilityDescriptor', + 'ExternalIntegrationEcosystemDescriptor', + 'ExternalEcosystemPolicyView', + 'EffectiveExternalEcosystemPolicy', + 'EffectiveExternalIntegrationPolicy', + 'ExternalIntegrationPolicyStatus', + 'ExternalIntegrationPolicySnapshot', + 'ExternalIntegrationPolicyScope', + 'ExternalIntegrationPolicyOperation', + 'ExternalIntegrationPolicyMutation', + 'evaluate_external_integration_policy', + 'external_integration_policy_snapshot', + 'incompatible_external_integration_policy_snapshot', +].map((symbol) => externalIntegrationPolicyEntry(symbol)); + function externalToolEntry(symbol, owner, consumer, wireImpact = false) { return { symbol, @@ -298,6 +349,9 @@ export const externalSourceContractPublicApiEntries = [ 'ExternalSourceContext', 'ExternalWatchRoot', 'ExternalSourceProviderError', + 'ExternalSourceOperationErrorCode', + 'ExternalSourceOperationError', + 'ExternalSourceOperationResult', 'PromptCommandSourceProvider', 'ExternalSourceLifecycleState', 'ExternalSourceCatalogEntry', @@ -306,6 +360,10 @@ export const externalSourceContractPublicApiEntries = [ 'PromptCommandConflict', 'prompt_command_conflict_key', 'ExternalSourceCatalogSnapshot', + 'ExternalPromptCommandDefinitionSummary', + 'ExternalPromptCommandSummary', + 'ExternalSourcePublicSnapshot', + 'ExternalSourceHostCapabilities', ].map((symbol) => externalSourceEntry( symbol, @@ -459,6 +517,30 @@ export const externalSourceCoordinatorPublicApiEntries = [ ]; export const externalSourceCorePublicApiEntries = [ + ...[ + 'ExternalIntegrationAccess', + 'ExternalIntegrationMode', + 'ExternalIntegrationPolicyMutation', + 'ExternalIntegrationPolicyOperation', + 'ExternalIntegrationPolicyScope', + 'EffectiveExternalIntegrationPolicy', + 'ExternalIntegrationPolicySnapshot', + 'ExternalIntegrationPolicyStatus', + 'EcosystemId', + 'ExternalIntegrationCapabilityId', + 'EXTERNAL_CAPABILITY_COMMAND', + 'EXTERNAL_CAPABILITY_TOOL', + 'EXTERNAL_CAPABILITY_SUBAGENT', + 'EXTERNAL_CAPABILITY_MCP', + 'update_external_integration_policy', + ].map((symbol) => + externalIntegrationPolicyEntry( + symbol, + 'bitfun-core external integration policy composition facade', + 'bitfun-cli, Desktop, Server, Peer Host, and Web API adapters', + true, + ), + ), ...[ 'ExpandedPromptCommand', 'ExternalSourceCatalogEntry', @@ -467,6 +549,10 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalSourceDiagnostic', 'ExternalSourceDiagnosticSeverity', 'ExternalSourceLifecycleState', + 'ExternalSourceHostCapabilities', + 'ExternalSourceOperationError', + 'ExternalSourceOperationErrorCode', + 'ExternalSourceOperationResult', 'PromptCommandAvailability', 'PromptCommandCatalogEntry', 'PromptCommandDefinition', @@ -476,10 +562,13 @@ export const externalSourceCorePublicApiEntries = [ 'remember_external_source_conflict_choice', 'set_external_prompt_command_conflict_choice', 'external_source_snapshot', + 'external_source_read_only_snapshot', 'set_external_source_enabled', 'expand_external_prompt_command', + 'sanitize_external_source_operation_error', 'subscribe_external_source_updates', 'ExternalSourceSubscription', + 'ExternalSourcePublicSnapshot', ].map((symbol) => externalSourceEntry( symbol, @@ -493,6 +582,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExternalToolCapability', 'ExternalToolCatalogEntry', 'ExternalToolConflict', + 'ExternalToolConflictCandidateKind', 'ExternalToolRuntimeKind', 'set_external_tool_target_decision', 'set_external_tool_conflict_choice', @@ -657,6 +747,12 @@ export const publicApiAllowlistRules = [ 'managed plugin package and trust contracts must stay explicitly budgeted and ecosystem-neutral', allowedSymbolEntries: pluginSourceContractPublicApiEntries, }, + { + path: 'src/crates/contracts/product-domains/src/external_integration_policy.rs', + reason: + 'external integration policy contracts must stay ecosystem-neutral, versioned, fail-closed, and explicitly consumer-backed', + allowedSymbolEntries: externalIntegrationPolicyPublicApiEntries, + }, { path: 'src/crates/contracts/product-domains/src/external_sources.rs', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index bc3ba63bab..cdb46edf29 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -165,6 +165,7 @@ export function runManifestParserSelfTest({ for (const manifestPath of [ 'src/apps/desktop/Cargo.toml', 'src/apps/cli/Cargo.toml', + 'src/apps/server/Cargo.toml', 'src/crates/interfaces/acp/Cargo.toml', ]) { if (!productCoreRulePaths.has(manifestPath)) { @@ -195,15 +196,21 @@ export function runManifestParserSelfTest({ }, { manifestPath: 'src/apps/server/Cargo.toml', - text: '[dependencies]\naxum = { workspace = true }', + text: + '[dependencies]\nbitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] }', }, { manifestPath: 'src/crates/interfaces/acp/Cargo.toml', text: '[dependencies."bitfun-core"]\npath = "../../assembly/core"\ndefault-features = false\nfeatures = ["product-full"]', }, ]); - if (discoveredProductCoreManifests.join(',') !== 'src/apps/desktop/Cargo.toml,src/crates/interfaces/acp/Cargo.toml') { - throw new Error('product core dependency scanner must discover only manifests that depend on bitfun-core'); + if ( + discoveredProductCoreManifests.join(',') !== + 'src/apps/desktop/Cargo.toml,src/apps/server/Cargo.toml,src/crates/interfaces/acp/Cargo.toml' + ) { + throw new Error( + 'product core dependency scanner must discover only manifests that depend on bitfun-core', + ); } const ownerFeatureRulePaths = new Set( ownerCrateFeatureAssemblyRules.map((rule) => rule.manifestPath), diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 3351a2ef44..05d6e0d97b 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -28,7 +28,7 @@ mod ui; use anyhow::{anyhow, Result}; use bitfun_core::service::remote_connect::DeviceIdentity; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::OnceLock; @@ -400,6 +400,78 @@ enum ConfigAction { Edit, /// Reset to default configuration Reset, + /// Inspect or change external AI application compatibility + External { + #[command(subcommand)] + action: ExternalConfigAction, + }, +} + +#[derive(Subcommand)] +enum ExternalConfigAction { + /// Show effective global and project compatibility settings + Status, + /// Enable or disable external compatibility + SetEnabled { + enabled: bool, + #[arg(long, value_enum, default_value = "project")] + scope: ExternalPolicyScopeArg, + }, + /// Select an external ecosystem compatibility mode + SetMode { + #[arg(value_enum)] + mode: ExternalPolicyModeArg, + /// Ecosystem id; optional when exactly one ecosystem is registered + #[arg(long)] + ecosystem: Option, + #[arg(long, value_enum, default_value = "project")] + scope: ExternalPolicyScopeArg, + }, + /// Customize one external ecosystem capability + SetCapability { + #[arg(value_enum)] + capability: ExternalCapabilityArg, + #[arg(value_enum)] + access: ExternalAccessArg, + /// Ecosystem id; optional when exactly one ecosystem is registered + #[arg(long)] + ecosystem: Option, + #[arg(long, value_enum, default_value = "project")] + scope: ExternalPolicyScopeArg, + }, + /// Remove this project's overrides and inherit global settings + ResetProject, + /// Back up and reset a policy written by an incompatible BitFun version + ResetIncompatible, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum ExternalPolicyScopeArg { + Global, + Project, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum ExternalPolicyModeArg { + Recommended, + DiscoverOnly, + Off, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum ExternalCapabilityArg { + Command, + Tool, + Agent, + Mcp, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum ExternalAccessArg { + Off, + Discover, + Ask, + Auto, } #[derive(Subcommand)] @@ -887,7 +959,7 @@ async fn run_cli() -> Result<()> { } Some(Commands::Config { action }) => { - root_handlers::handle_config_action(action, &config)?; + root_handlers::handle_config_action(action, &config).await?; } Some(Commands::Health) => { @@ -1143,6 +1215,88 @@ mod plugin_command_tests { } } +#[cfg(test)] +mod external_config_command_tests { + use super::{ + Cli, Commands, ConfigAction, ExternalAccessArg, ExternalCapabilityArg, + ExternalConfigAction, ExternalPolicyModeArg, ExternalPolicyScopeArg, + }; + use clap::Parser; + + #[test] + fn external_config_commands_keep_scope_and_capability_explicit() { + let status = Cli::try_parse_from(["bitfun-cli", "config", "external", "status"]) + .expect("parse external status"); + assert!(matches!( + status.command, + Some(Commands::Config { + action: ConfigAction::External { + action: ExternalConfigAction::Status + } + }) + )); + + let mode = Cli::try_parse_from([ + "bitfun-cli", + "config", + "external", + "set-mode", + "discover-only", + "--scope", + "global", + ]) + .expect("parse external mode"); + assert!(matches!( + mode.command, + Some(Commands::Config { + action: ConfigAction::External { + action: ExternalConfigAction::SetMode { + mode: ExternalPolicyModeArg::DiscoverOnly, + ecosystem: None, + scope: ExternalPolicyScopeArg::Global, + } + } + }) + )); + + let capability = Cli::try_parse_from([ + "bitfun-cli", + "config", + "external", + "set-capability", + "mcp", + "ask", + "--ecosystem", + "opencode", + ]) + .expect("parse external capability"); + assert!(matches!( + capability.command, + Some(Commands::Config { + action: ConfigAction::External { + action: ExternalConfigAction::SetCapability { + capability: ExternalCapabilityArg::Mcp, + access: ExternalAccessArg::Ask, + ecosystem: Some(ref ecosystem), + scope: ExternalPolicyScopeArg::Project, + } + } + }) if ecosystem == "opencode" + )); + + let reset = Cli::try_parse_from(["bitfun-cli", "config", "external", "reset-incompatible"]) + .expect("parse incompatible policy reset"); + assert!(matches!( + reset.command, + Some(Commands::Config { + action: ConfigAction::External { + action: ExternalConfigAction::ResetIncompatible + } + }) + )); + } +} + #[cfg(test)] mod bootstrap_profile_tests { use super::{exec_requests_json_output, BootstrapProfile, SessionAction}; diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index e8829a7fe2..b83824e271 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -68,13 +68,14 @@ use bitfun_core::agentic::tools::implementations::skills::{ use bitfun_core::external_sources::{ choose_external_subagent_conflict, expand_external_prompt_command, external_source_conflict_choices, external_source_snapshot, prompt_command_conflict_key, - remember_external_source_conflict_choice, set_external_prompt_command_conflict_choice, - set_external_subagent_activation, set_external_tool_conflict_choice, - set_external_tool_target_decision, subscribe_external_source_updates, ExternalSourceAssetKind, - ExternalSourceCatalogSnapshot, ExternalSourceDiagnosticSeverity, - ExternalSubagentActivationState, ExternalSubagentCompatibilityState, - ExternalToolActivationState, ExternalToolCapability, ExternalToolCatalogEntry, - ExternalToolRuntimeKind, PromptCommandAvailability, + remember_external_source_conflict_choice, sanitize_external_source_operation_error, + set_external_prompt_command_conflict_choice, set_external_subagent_activation, + set_external_tool_conflict_choice, set_external_tool_target_decision, + subscribe_external_source_updates, ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, + ExternalSourceDiagnosticSeverity, ExternalSourceOperationError, + ExternalSourceOperationErrorCode, ExternalSubagentActivationState, + ExternalSubagentCompatibilityState, ExternalToolActivationState, ExternalToolCapability, + ExternalToolCatalogEntry, ExternalToolRuntimeKind, PromptCommandAvailability, }; use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::session_usage::render_usage_report_markdown; diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 67b7ae7f1f..65eaea8649 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -395,15 +395,26 @@ impl ChatMode { chat_view: &mut ChatView, rt_handle: &tokio::runtime::Handle, ) { + let expected_preference_revision = self + .external_source_snapshot + .as_ref() + .map(|snapshot| snapshot.preference_revision) + .unwrap_or(0); let persisted = tokio::task::block_in_place(|| { rt_handle.block_on(remember_external_source_conflict_choice( conflict_key, candidate_id, participants.clone(), + expected_preference_revision, )) }); match persisted { - Ok(preferences) => self.replace_external_conflict_preferences(preferences.into()), + Ok((choices, lineage, candidates, preference_revision)) => { + self.replace_external_conflict_preferences((choices, lineage, candidates).into()); + if let Some(snapshot) = &mut self.external_source_snapshot { + snapshot.preference_revision = preference_revision; + } + } Err(error) => { tracing::warn!( "Failed to persist external command conflict choice: {}", @@ -437,11 +448,17 @@ impl ChatMode { } if let Some(provider_conflict_key) = &projection.provider_conflict_key { let workspace = self.agent.workspace_path_buf(); + let expected_preference_revision = self + .external_source_snapshot + .as_ref() + .map(|snapshot| snapshot.preference_revision) + .unwrap_or(0); let snapshot = tokio::task::block_in_place(|| { rt_handle.block_on(set_external_prompt_command_conflict_choice( Some(&workspace), provider_conflict_key, &projection.candidate_id, + expected_preference_revision, )) }); let snapshot = match snapshot { @@ -454,6 +471,7 @@ impl ChatMode { return Ok(None); } }; + self.external_source_snapshot = Some(snapshot); if let Some(collision) = &projection.native_collision { self.remember_native_command_choice( collision, @@ -462,7 +480,6 @@ impl ChatMode { rt_handle, ); } - self.external_source_snapshot = Some(snapshot); let Some(active) = self.external_command_projection(&projection.command_name) else { chat_state.add_system_message(format!( "Selected external command /{} is no longer available; refresh and choose again.", diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index 7cef55cb0c..ed73b480b1 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -169,6 +169,85 @@ fn external_command_counts(snapshot: &ExternalSourceCatalogSnapshot) -> (usize, }) } +fn external_integration_policy_lines(snapshot: &ExternalSourceCatalogSnapshot) -> Vec { + let policy = &snapshot.integration_policy; + if policy.status + == bitfun_core::external_sources::ExternalIntegrationPolicyStatus::IncompatibleSchema + { + return vec![ + format!( + "Access: safely off; unsupported policy schema {}", + policy.schema_major + ), + "Recover: bitfun-cli config external reset-incompatible".to_string(), + ]; + } + if !policy.status.is_compatible() { + return vec![ + format!( + "Access: safely off; unsupported policy status '{}'", + policy.status.as_str() + ), + "Recover: upgrade BitFun or connect through a compatible workspace host".to_string(), + ]; + } + let scope = if policy.workspace_override.is_some() { + "this project overrides global settings" + } else { + "this project inherits global settings" + }; + if policy.registered_ecosystems.is_empty() { + return vec![format!("Access: unavailable; {scope}")]; + } + let mut lines = vec![format!( + "Access: {}; {scope}", + if policy.effective.enabled { + "enabled" + } else { + "disabled" + } + )]; + for descriptor in &policy.registered_ecosystems { + let Some(ecosystem) = policy.effective.ecosystems.get(&descriptor.ecosystem_id) else { + lines.push(format!("{}: unavailable", descriptor.display_name)); + continue; + }; + let mode = match ecosystem.mode.as_str() { + "recommended" => "recommended", + "discover_only" => "discover only", + "disabled" => "off", + "custom" => "custom", + _ => "unsupported, safely off", + }; + let capability_summary = descriptor + .capabilities + .iter() + .filter_map(|capability| { + ecosystem + .capabilities + .get(&capability.capability_id) + .map(|access| { + let access = match access.as_str() { + "disabled" => "off", + "discover_only" => "discover", + "ask_before_use" => "ask", + "auto" => "auto", + _ => "unsupported, safely off", + }; + format!("{} {access}", capability.capability_id.as_str()) + }) + }) + .collect::>() + .join(", "); + lines.push(format!( + "{}: {mode}; {capability_summary}", + descriptor.display_name + )); + } + lines.push("Manage: bitfun-cli config external --help".to_string()); + lines +} + #[derive(Debug, Clone, PartialEq, Eq)] enum ExternalToolReviewAction { Show, @@ -186,7 +265,49 @@ enum ExternalToolReviewAction { struct ExternalToolMutationResult { action: ExternalToolReviewAction, - result: std::result::Result, + result: std::result::Result, +} + +fn external_operation_error_status(surface: &str, error: &ExternalSourceOperationError) -> String { + let reason = match error.code { + ExternalSourceOperationErrorCode::InvalidRequest => { + "The requested change is no longer valid." + } + ExternalSourceOperationErrorCode::HostUnavailable => "The workspace host is not available.", + ExternalSourceOperationErrorCode::HostCapabilityUnavailable => { + "This workspace host is read-only for external integrations." + } + ExternalSourceOperationErrorCode::PolicyIncompatible => { + "Compatibility settings were written by a newer BitFun version." + } + ExternalSourceOperationErrorCode::PolicyLimited => { + "The current safety policy does not allow this change." + } + ExternalSourceOperationErrorCode::StaleRevision => { + "Compatibility settings changed before the update completed." + } + ExternalSourceOperationErrorCode::Conflict => { + "The available choices changed before the update completed." + } + ExternalSourceOperationErrorCode::NotFound => "That external item is no longer available.", + ExternalSourceOperationErrorCode::Unavailable => { + "The external integration is temporarily unavailable." + } + ExternalSourceOperationErrorCode::Internal => { + "BitFun could not complete the external integration update." + } + }; + let next_step = if error.retryable { + format!(" Run /builtin:{surface} refresh and try again.") + } else { + format!(" Run /builtin:{surface} refresh to review the current state.") + }; + let reference = error + .correlation_id + .as_deref() + .map(|id| format!(" Reference: {id}.")) + .unwrap_or_default(); + format!("{reason}{next_step}{reference}") } struct ExternalToolTargetSummary<'a> { @@ -413,6 +534,8 @@ fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) - "BitFun does not run external code while checking sources. Enabling tools runs their code with your user permissions and inherited environment variables. The code is not isolated by an OS sandbox, and processes it starts may keep running after cancellation." .to_string(), ]; + lines.push(String::new()); + lines.extend(external_integration_policy_lines(snapshot)); if snapshot.discovery_pending { lines.push(String::new()); @@ -632,7 +755,7 @@ enum ExternalAgentReviewAction { struct ExternalAgentMutationResult { action: ExternalAgentReviewAction, - result: std::result::Result, + result: std::result::Result, } fn external_tool_run_location_label(execution_domain_id: &str) -> &'static str { @@ -790,6 +913,8 @@ fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) "BitFun only reads supported settings while checking sources. Agent instructions stay hidden and are not added to the current agent. Once enabled, those instructions guide the selected model and may call the tools listed below. Before enabling, review the model, tools, and where the agent runs. BitFun asks again if the instructions, model, tools, or configuration sources change. Each use starts a new task; follow-up is not supported in this version." .to_string(), ]; + lines.push(String::new()); + lines.extend(external_integration_policy_lines(snapshot)); if snapshot.discovery_pending { lines.push(String::new()); lines.push( diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index 1731fc92f7..95fdcc8c24 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -422,6 +422,11 @@ impl ChatMode { } let workspace = self.workspace_path_for_sync(chat_state); + let expected_preference_revision = self + .external_source_snapshot + .as_ref() + .map(|snapshot| snapshot.preference_revision) + .unwrap_or(0); let pending_status = match &action { ExternalToolReviewAction::Refresh => "Refreshing external tools", ExternalToolReviewAction::Decide { approved: true, .. } => "Enabling external tool", @@ -448,6 +453,7 @@ impl ChatMode { approval_key, decision_key, *approved, + expected_preference_revision, ) .await } @@ -455,12 +461,17 @@ impl ChatMode { conflict_key, candidate_id, } => { - set_external_tool_conflict_choice(Some(&workspace), conflict_key, candidate_id) - .await + set_external_tool_conflict_choice( + Some(&workspace), + conflict_key, + candidate_id, + expected_preference_revision, + ) + .await } ExternalToolReviewAction::Show => unreachable!(), } - .map_err(|error| error.to_string()); + .map_err(sanitize_external_source_operation_error); let _ = sender.send(ExternalToolMutationResult { action: task_action, result, @@ -519,10 +530,12 @@ impl ChatMode { } } Err(error) => { - tracing::warn!("External tool review action failed: {}", error); - chat_view.set_status(Some(format!( - "External tool choice was not applied: {error}. Run /builtin:tools refresh to review current results." - ))); + tracing::warn!( + error_code = error.code.as_str(), + correlation_id = error.correlation_id.as_deref().unwrap_or("none"), + "External tool review action failed" + ); + chat_view.set_status(Some(external_operation_error_status("tools", &error))); } } true @@ -655,7 +668,7 @@ impl ChatMode { } ExternalAgentReviewAction::Show => unreachable!(), } - .map_err(|error| error.to_string()); + .map_err(sanitize_external_source_operation_error); let _ = sender.send(ExternalAgentMutationResult { action: task_action, result, @@ -719,13 +732,12 @@ impl ChatMode { } } Err(error) => { - tracing::warn!("External agent review action failed: {}", error); - let reason = error - .split_once(':') - .map_or(error.as_str(), |(_, reason)| reason.trim()); - chat_view.set_status(Some(format!( - "External agent choice was not applied: {reason}. Run /builtin:agents refresh to review current results." - ))); + tracing::warn!( + error_code = error.code.as_str(), + correlation_id = error.correlation_id.as_deref().unwrap_or("none"), + "External agent review action failed" + ); + chat_view.set_status(Some(external_operation_error_status("agents", &error))); } } true diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 246ea78567..316ae52c18 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -3,21 +3,20 @@ mod tests { use tokio::sync::broadcast::error::TryRecvError; use super::{ - action_opens_extension_management, agent_event_stream_failure, - apply_agent_mode_feedback, apply_model_selection_feedback, builtin_command_reconfirmation, - command_route, + action_opens_extension_management, agent_event_stream_failure, apply_agent_mode_feedback, + apply_model_selection_feedback, builtin_command_reconfirmation, command_route, external_agent_attention, external_agent_diagnostic_lines, external_agent_pending_notice_key, external_agent_result_is_stale, external_agent_review_text, external_command_projections, + external_integration_policy_lines, external_operation_error_status, external_tool_mutation_result_label, external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, external_tool_run_location_label, mark_active_turn_failed, merge_external_agent_mutation_snapshot, mode_change_blocks_typed_submission, mode_change_completion_should_exit, native_command_conflict_key, parse_command_token, parse_external_agent_review_action, - parse_external_tool_review_action, CommandQualifier, CommandRoute, - ExternalAgentReviewAction, ExternalSourceConflictPreferences, ExternalToolReviewAction, - previous_session_mode_change_status, ModeSelectionApplyOutcome, - ModelSelectionApplyOutcome, + parse_external_tool_review_action, previous_session_mode_change_status, CommandQualifier, + CommandRoute, ExternalAgentReviewAction, ExternalSourceConflictPreferences, + ExternalToolReviewAction, ModeSelectionApplyOutcome, ModelSelectionApplyOutcome, }; use crate::actions::{action_conflict_behavior_version, ActionState, ResolvedKeymap}; use crate::chat_state::ChatState; @@ -25,7 +24,8 @@ mod tests { use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; use bitfun_core::external_sources::{ ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, - ExternalSourceDiagnosticSeverity, ExternalSubagentActivationState, + ExternalSourceDiagnosticSeverity, ExternalSourceOperationError, + ExternalSourceOperationErrorCode, ExternalSubagentActivationState, ExternalToolActivationState, }; use std::collections::{BTreeMap, BTreeSet}; @@ -196,6 +196,68 @@ mod tests { "sourceLocation": "/.opencode/tools/review.js" }] }], + "integrationPolicy": { + "schemaMajor": 1, + "status": "compatible", + "userDefaults": { "enabled": true }, + "globalEffective": { + "enabled": true, + "ecosystems": { + "opencode": { + "ecosystemId": "opencode", + "mode": "recommended", + "capabilities": { + "command": "auto", + "tool": "ask_before_use", + "subagent": "ask_before_use", + "mcp": "ask_before_use" + } + } + } + }, + "effective": { + "enabled": true, + "ecosystems": { + "opencode": { + "ecosystemId": "opencode", + "mode": "recommended", + "capabilities": { + "command": "auto", + "tool": "ask_before_use", + "subagent": "ask_before_use", + "mcp": "ask_before_use" + } + } + } + }, + "registeredEcosystems": [{ + "ecosystemId": "opencode", + "displayName": "OpenCode", + "adapterRevision": "1", + "capabilities": [ + { + "capabilityId": "command", + "recommendedAccess": "auto", + "safetyCeiling": "auto" + }, + { + "capabilityId": "tool", + "recommendedAccess": "ask_before_use", + "safetyCeiling": "ask_before_use" + }, + { + "capabilityId": "subagent", + "recommendedAccess": "ask_before_use", + "safetyCeiling": "ask_before_use" + }, + { + "capabilityId": "mcp", + "recommendedAccess": "ask_before_use", + "safetyCeiling": "ask_before_use" + } + ] + }] + }, "diagnostics": [{ "severity": "warning", "code": "opencode.tool.directory_read_failed", @@ -206,6 +268,53 @@ mod tests { .unwrap() } + #[test] + fn external_review_projects_effective_scope_and_capability_policy() { + let lines = external_integration_policy_lines(&external_tool_review_snapshot()); + let text = lines.join("\n"); + + assert!(text.contains("Access: enabled")); + assert!(text.contains("this project inherits global settings")); + assert!(text.contains("OpenCode: recommended")); + assert!(text.contains("command auto")); + assert!(text.contains("tool ask")); + assert!(text.contains("bitfun-cli config external --help")); + } + + #[test] + fn external_operation_errors_use_stable_tui_copy_without_raw_details() { + let stale = ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::StaleRevision, + "raw stale detail", + true, + ); + let policy = ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::PolicyLimited, + "raw policy detail", + false, + ); + let internal = ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + "database password must not be shown", + true, + ) + .with_correlation_id("external-source-ref-9"); + + let stale_status = external_operation_error_status("tools", &stale); + assert!(stale_status.contains("settings changed")); + assert!(stale_status.contains("refresh and try again")); + assert!(!stale_status.contains("raw stale detail")); + + let policy_status = external_operation_error_status("agents", &policy); + assert!(policy_status.contains("safety policy")); + assert!(policy_status.contains("review the current state")); + assert!(!policy_status.contains("raw policy detail")); + + let internal_status = external_operation_error_status("tools", &internal); + assert!(internal_status.contains("external-source-ref-9")); + assert!(!internal_status.contains("database password")); + } + #[test] fn external_tool_review_summary_discloses_execution_boundary_and_commands() { let summary = external_tool_review_text(Some(&external_tool_review_snapshot())); diff --git a/src/apps/cli/src/peer_host/commands/external_sources.rs b/src/apps/cli/src/peer_host/commands/external_sources.rs new file mode 100644 index 0000000000..6dd2c27aba --- /dev/null +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -0,0 +1,281 @@ +//! External compatibility HostInvoke handlers for CLI Peer Host. + +use std::path::PathBuf; + +use bitfun_core::external_sources::{ + choose_external_mcp_conflict, choose_external_subagent_conflict, external_source_snapshot, + set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, + set_external_source_enabled, set_external_subagent_activation, + set_external_tool_conflict_choice, set_external_tool_target_decision, + update_external_integration_policy, ExternalIntegrationPolicyMutation, + ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceOperationResult, + ExternalSourcePublicSnapshot, +}; +use serde_json::Value; + +use crate::peer_host::args::request_value; +use crate::peer_host::state::PeerHostState; + +fn required_bool(request: &Value, key: &str) -> ExternalSourceOperationResult { + optional_bool_field(request, key)?.ok_or_else(|| { + ExternalSourceOperationError::invalid_request(format!("Missing or invalid '{key}'")) + }) +} + +fn required_string(request: &Value, key: &str) -> ExternalSourceOperationResult { + optional_string_field(request, key)?.ok_or_else(|| { + ExternalSourceOperationError::invalid_request(format!("Missing or invalid '{key}'")) + }) +} + +fn optional_bool_field(request: &Value, key: &str) -> ExternalSourceOperationResult> { + match request.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(value)) => Ok(Some(*value)), + _ => Err(ExternalSourceOperationError::invalid_request(format!( + "'{key}' must be a boolean when provided" + ))), + } +} + +fn optional_string_field( + request: &Value, + key: &str, +) -> ExternalSourceOperationResult> { + match request.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) if !value.trim().is_empty() => Ok(Some(value.clone())), + _ => Err(ExternalSourceOperationError::invalid_request(format!( + "'{key}' must be a non-empty string when provided" + ))), + } +} + +fn required_u64(request: &Value, key: &str) -> ExternalSourceOperationResult { + request.get(key).and_then(Value::as_u64).ok_or_else(|| { + ExternalSourceOperationError::invalid_request(format!("Missing or invalid '{key}'")) + }) +} + +async fn workspace_root( + state: &PeerHostState, + request: &Value, +) -> ExternalSourceOperationResult> { + let Some(requested) = optional_string_field(request, "workspacePath")? else { + return Ok(None); + }; + let requested = PathBuf::from(requested); + if !requested.is_absolute() { + return Err(ExternalSourceOperationError::invalid_request( + "External sources require an absolute workspace path", + )); + } + let requested = requested.canonicalize().map_err(|_| { + ExternalSourceOperationError::invalid_request( + "Workspace path is not available on this Host", + ) + })?; + let current = state + .workspace_service + .get_current_workspace() + .await + .ok_or_else(|| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::HostUnavailable, + "No workspace is open on the CLI Host", + true, + ) + })?; + let current = current.root_path.canonicalize().map_err(|_| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::HostUnavailable, + "The CLI Host workspace is not available", + true, + ) + })?; + if current != requested { + return Err(ExternalSourceOperationError::invalid_request( + "External compatibility is limited to the current Host workspace", + )); + } + Ok(Some(requested)) +} + +fn public_snapshot( + snapshot: bitfun_core::external_sources::ExternalSourceCatalogSnapshot, +) -> ExternalSourceOperationResult { + serde_json::to_value(ExternalSourcePublicSnapshot::from(snapshot)).map_err(|_| { + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + "External source response could not be encoded", + false, + ) + }) +} + +pub(crate) async fn dispatch( + command: &str, + args: &Value, + state: &PeerHostState, +) -> Result { + dispatch_inner(command, args, state) + .await + .map_err(|error| error.encode()) +} + +async fn dispatch_inner( + command: &str, + args: &Value, + state: &PeerHostState, +) -> ExternalSourceOperationResult { + let request = request_value(args); + let workspace = workspace_root(state, request).await?; + let workspace = workspace.as_deref(); + let snapshot = match command { + "get_external_source_snapshot" => { + external_source_snapshot( + workspace, + optional_bool_field(request, "forceRefresh")?.unwrap_or(false), + ) + .await + } + "set_external_source_enabled_command" => { + set_external_source_enabled( + workspace, + &required_string(request, "sourceKey")?, + required_bool(request, "enabled")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } + "set_external_source_conflict_choice_command" => { + set_external_prompt_command_conflict_choice( + workspace, + &required_string(request, "conflictKey")?, + &required_string(request, "candidateId")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } + "set_external_tool_target_decision_command" => { + set_external_tool_target_decision( + workspace, + &required_string(request, "approvalKey")?, + &required_string(request, "decisionKey")?, + required_bool(request, "approved")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } + "set_external_tool_conflict_choice_command" => { + set_external_tool_conflict_choice( + workspace, + &required_string(request, "conflictKey")?, + &required_string(request, "candidateId")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } + "set_external_subagent_activation_command" => { + set_external_subagent_activation( + workspace, + &required_string(request, "candidateId")?, + required_bool(request, "approved")?, + required_u64(request, "expectedSubagentGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + &required_string(request, "decisionKey")?, + ) + .await + } + "choose_external_subagent_conflict_command" => { + choose_external_subagent_conflict( + workspace, + &required_string(request, "conflictKey")?, + &required_string(request, "candidateId")?, + optional_bool_field(request, "approveExternal")?.unwrap_or(false), + required_u64(request, "expectedSubagentGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } + "set_external_mcp_server_decision_command" => { + set_external_mcp_server_decision( + workspace, + &required_string(request, "candidateId")?, + &required_string(request, "decisionKey")?, + required_bool(request, "approved")?, + required_u64(request, "expectedMcpGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } + "choose_external_mcp_conflict_command" => { + choose_external_mcp_conflict( + workspace, + &required_string(request, "conflictKey")?, + &required_string(request, "candidateId")?, + optional_bool_field(request, "approveExternal")?.unwrap_or(false), + required_u64(request, "expectedMcpGeneration")?, + required_u64(request, "expectedPreferenceRevision")?, + ) + .await + } + "update_external_integration_policy_command" => { + let mutation = request + .get("mutation") + .cloned() + .ok_or_else(|| ExternalSourceOperationError::invalid_request("Missing mutation"))?; + let mutation: ExternalIntegrationPolicyMutation = serde_json::from_value(mutation) + .map_err(|_| { + ExternalSourceOperationError::invalid_request("Invalid policy mutation") + })?; + update_external_integration_policy(workspace, mutation).await + } + _ => { + return Err(ExternalSourceOperationError::host_capability_unavailable( + format!("External compatibility command '{command}' is not supported"), + )) + } + } + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)?; + + public_snapshot(snapshot) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn optional_host_fields_reject_wrong_types() { + let request = serde_json::json!({ + "workspacePath": false, + "forceRefresh": "false" + }); + assert_eq!( + optional_string_field(&request, "workspacePath") + .unwrap_err() + .code, + ExternalSourceOperationErrorCode::InvalidRequest + ); + assert_eq!( + optional_bool_field(&request, "forceRefresh") + .unwrap_err() + .code, + ExternalSourceOperationErrorCode::InvalidRequest + ); + } + + #[test] + fn peer_errors_use_the_shared_typed_envelope() { + let encoded = ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::StaleRevision, + "Refresh before retrying", + true, + ) + .encode(); + let value: Value = serde_json::from_str(&encoded).unwrap(); + assert_eq!(value["code"], "stale_revision"); + assert_eq!(value["retryable"], true); + } +} diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 3a7d3a0968..0f020655d7 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -2,6 +2,7 @@ mod config; mod dialog; +mod external_sources; mod filesystem; mod git; mod session; @@ -37,6 +38,18 @@ pub(crate) async fn dispatch( "set_config" => config::set_config(args).await, "get_agent_profile_config" => config::get_agent_profile_config(args).await, "get_agent_profile_configs" => config::get_agent_profile_configs().await, + "get_external_source_snapshot" + | "set_external_source_enabled_command" + | "set_external_source_conflict_choice_command" + | "set_external_tool_target_decision_command" + | "set_external_tool_conflict_choice_command" + | "set_external_subagent_activation_command" + | "choose_external_subagent_conflict_command" + | "set_external_mcp_server_decision_command" + | "choose_external_mcp_conflict_command" + | "update_external_integration_policy_command" => { + external_sources::dispatch(command, args, state).await + } // Filesystem "get_directory_children" | "list_files" => { diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index bc71380128..105879710b 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -1,9 +1,19 @@ use anyhow::{Context, Result}; +use std::collections::BTreeSet; use std::io::IsTerminal; use std::path::Path; use bitfun_agent_runtime::sdk::{AgentSessionRestoreRequest, SessionTranscriptRequest}; +use bitfun_core::external_sources::{ + external_source_snapshot, sanitize_external_source_operation_error, + update_external_integration_policy, EcosystemId, ExternalIntegrationAccess, + ExternalIntegrationCapabilityId, ExternalIntegrationMode, ExternalIntegrationPolicyMutation, + ExternalIntegrationPolicyOperation, ExternalIntegrationPolicyScope, + ExternalIntegrationPolicyStatus, ExternalSourceCatalogSnapshot, + ExternalSourceOperationErrorCode, EXTERNAL_CAPABILITY_COMMAND, EXTERNAL_CAPABILITY_MCP, + EXTERNAL_CAPABILITY_SUBAGENT, EXTERNAL_CAPABILITY_TOOL, +}; use crate::{ chat_state::{transcript_message_preview, transcript_role_label}, @@ -13,7 +23,8 @@ use crate::{ emit_preflight_json_error, ExecApprovalMode, ExecMode, ExecOutputFormat, ExecSessionOptions, }, ui::string_utils::truncate_str, - ConfigAction, SessionAction, + ConfigAction, ExternalAccessArg, ExternalCapabilityArg, ExternalConfigAction, + ExternalPolicyModeArg, ExternalPolicyScopeArg, SessionAction, }; pub(crate) struct ExecCommandArgs { @@ -358,7 +369,7 @@ async fn list_cli_sessions( .map_err(|error| anyhow::anyhow!(error.into_message())) } -pub(crate) fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> { +pub(crate) async fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> { match action { ConfigAction::Show => { println!("Current Configuration\n"); @@ -394,8 +405,348 @@ pub(crate) fn handle_config_action(action: ConfigAction, config: &CliConfig) -> default_config.save()?; println!("Reset to default configuration"); } + ConfigAction::External { action } => handle_external_config_action(action).await?, + } + + Ok(()) +} + +fn external_policy_scope(scope: ExternalPolicyScopeArg) -> ExternalIntegrationPolicyScope { + match scope { + ExternalPolicyScopeArg::Global => ExternalIntegrationPolicyScope::User, + ExternalPolicyScopeArg::Project => ExternalIntegrationPolicyScope::Workspace, + } +} + +fn external_policy_mode(mode: ExternalPolicyModeArg) -> ExternalIntegrationMode { + match mode { + ExternalPolicyModeArg::Recommended => ExternalIntegrationMode::Recommended, + ExternalPolicyModeArg::DiscoverOnly => ExternalIntegrationMode::DiscoverOnly, + ExternalPolicyModeArg::Off => ExternalIntegrationMode::Disabled, + } +} + +fn external_capability_id( + capability: ExternalCapabilityArg, +) -> Result { + let capability = match capability { + ExternalCapabilityArg::Command => EXTERNAL_CAPABILITY_COMMAND, + ExternalCapabilityArg::Tool => EXTERNAL_CAPABILITY_TOOL, + ExternalCapabilityArg::Agent => EXTERNAL_CAPABILITY_SUBAGENT, + ExternalCapabilityArg::Mcp => EXTERNAL_CAPABILITY_MCP, + }; + ExternalIntegrationCapabilityId::new(capability).map_err(anyhow::Error::msg) +} + +fn external_access(access: ExternalAccessArg) -> ExternalIntegrationAccess { + match access { + ExternalAccessArg::Off => ExternalIntegrationAccess::Disabled, + ExternalAccessArg::Discover => ExternalIntegrationAccess::DiscoverOnly, + ExternalAccessArg::Ask => ExternalIntegrationAccess::AskBeforeUse, + ExternalAccessArg::Auto => ExternalIntegrationAccess::Auto, + } +} + +fn print_external_policy_status(snapshot: &ExternalSourceCatalogSnapshot) { + let policy = &snapshot.integration_policy; + println!("External compatibility"); + if policy.status == ExternalIntegrationPolicyStatus::IncompatibleSchema { + println!( + "Status: safely off (policy schema {} is not supported by this version)", + policy.schema_major + ); + println!("Recovery: bitfun-cli config external reset-incompatible"); + println!("The original policy will be backed up before safe defaults are restored."); + return; + } + if !policy.status.is_compatible() { + println!( + "Status: safely off (policy status '{}' is not supported by this version)", + policy.status.as_str() + ); + println!("Recovery: upgrade BitFun or connect through a compatible workspace host."); + return; + } + + println!("Global defaults"); + println!( + " Status: {}", + if policy.global_effective.enabled { + "enabled" + } else { + "disabled" + } + ); + print_external_policy_ecosystems(policy, &policy.global_effective, " "); + + println!("Project overrides"); + if let Some(project) = &policy.workspace_override { + let has_override = project.enabled.is_some() + || project.ecosystems.values().any(|ecosystem| { + ecosystem.mode.is_some() || !ecosystem.capability_overrides.is_empty() + }); + println!( + " {}", + if has_override { + "Explicit project override" + } else { + "Inherited from global defaults" + } + ); + } else { + println!(" Inherited from global defaults"); + } + + println!("Effective for this project"); + println!( + " Status: {}", + if policy.effective.enabled { + "enabled" + } else { + "disabled" + } + ); + print_external_policy_ecosystems(policy, &policy.effective, " "); + let locations = snapshot + .sources + .iter() + .map(|source| source.record.location.as_str()) + .collect::>(); + println!("Detected source locations: {}", locations.len()); + println!("Preference revision: {}", snapshot.preference_revision); + println!(); + println!("Changes are applied by the workspace host. Project settings never fall back to files from another device."); +} + +fn external_cli_operation_error(error: String) -> anyhow::Error { + let error = sanitize_external_source_operation_error(error); + let reason = match error.code { + ExternalSourceOperationErrorCode::InvalidRequest => "The requested change is not valid.", + ExternalSourceOperationErrorCode::HostUnavailable => "The workspace host is not available.", + ExternalSourceOperationErrorCode::HostCapabilityUnavailable => { + "This workspace host is read-only for external integrations." + } + ExternalSourceOperationErrorCode::PolicyIncompatible => { + "Compatibility settings were written by a newer BitFun version." + } + ExternalSourceOperationErrorCode::PolicyLimited => { + "The current safety policy does not allow this change." + } + ExternalSourceOperationErrorCode::StaleRevision => { + "Compatibility settings changed; run the command again." + } + ExternalSourceOperationErrorCode::Conflict => { + "The available choices changed; inspect the current status and retry." + } + ExternalSourceOperationErrorCode::NotFound => "That external item is no longer available.", + ExternalSourceOperationErrorCode::Unavailable => { + "The external integration is temporarily unavailable." + } + ExternalSourceOperationErrorCode::Internal => { + "BitFun could not complete the external integration operation." + } + }; + let reference = error + .correlation_id + .as_deref() + .map(|id| format!(" Reference: {id}.")) + .unwrap_or_default(); + anyhow::anyhow!("{reason}{reference}") +} + +fn select_external_ecosystem( + status: &ExternalIntegrationPolicyStatus, + ecosystems: &[EcosystemId], + requested: Option<&str>, +) -> Result { + if !status.is_compatible() { + return Err(anyhow::anyhow!( + "External compatibility policy is unsupported and safely off; upgrade BitFun or reset an incompatible policy before changing it" + )); + } + if let Some(requested) = requested { + let ecosystem = ecosystems + .iter() + .find(|ecosystem| ecosystem.as_str() == requested) + .ok_or_else(|| anyhow::anyhow!("Unknown external ecosystem '{requested}'"))?; + return Ok(ecosystem.clone()); + } + match ecosystems { + [only] => Ok(only.clone()), + [] => Err(anyhow::anyhow!("No external ecosystems are registered")), + _ => Err(anyhow::anyhow!( + "More than one external ecosystem is registered; choose one with --ecosystem " + )), + } +} + +async fn resolve_external_ecosystem(requested: Option) -> Result { + let workspace = std::env::current_dir().context("Failed to resolve current workspace")?; + let snapshot = external_source_snapshot(Some(&workspace), false) + .await + .map_err(external_cli_operation_error)?; + let ecosystems = snapshot + .integration_policy + .registered_ecosystems + .iter() + .map(|descriptor| descriptor.ecosystem_id.clone()) + .collect::>(); + select_external_ecosystem( + &snapshot.integration_policy.status, + &ecosystems, + requested.as_deref(), + ) +} + +fn print_external_policy_ecosystems( + policy: &bitfun_core::external_sources::ExternalIntegrationPolicySnapshot, + effective: &bitfun_core::external_sources::EffectiveExternalIntegrationPolicy, + indent: &str, +) { + for descriptor in &policy.registered_ecosystems { + let Some(ecosystem) = effective.ecosystems.get(&descriptor.ecosystem_id) else { + println!("{indent}{}: unavailable", descriptor.display_name); + continue; + }; + let mode = match ecosystem.mode.as_str() { + "recommended" | "discover_only" | "disabled" | "custom" => ecosystem.mode.as_str(), + _ => "unsupported (safely off)", + }; + println!("{indent}{} mode: {mode}", descriptor.display_name); + for capability in &descriptor.capabilities { + let access = ecosystem + .capabilities + .get(&capability.capability_id) + .map(|access| match access.as_str() { + "disabled" | "discover_only" | "ask_before_use" | "auto" => access.as_str(), + _ => "unsupported (safely off)", + }) + .unwrap_or("unavailable"); + let suffix = if ecosystem + .policy_limited_capabilities + .contains(&capability.capability_id) + { + " (limited by safety policy)" + } else { + "" + }; + println!( + "{indent} {}: {access}{suffix}", + capability.capability_id.as_str() + ); + } } +} +async fn update_external_policy( + scope: ExternalPolicyScopeArg, + change: ExternalIntegrationPolicyOperation, +) -> Result<()> { + let workspace = std::env::current_dir().context("Failed to resolve current workspace")?; + let snapshot = external_source_snapshot(Some(&workspace), false) + .await + .map_err(external_cli_operation_error)?; + let reset_incompatible = matches!( + &change, + ExternalIntegrationPolicyOperation::ResetIncompatiblePolicy + ); + if !snapshot.integration_policy.status.is_compatible() + && !(reset_incompatible + && snapshot.integration_policy.status + == ExternalIntegrationPolicyStatus::IncompatibleSchema) + { + return Err(anyhow::anyhow!( + "External compatibility policy is unsupported and safely off; upgrade BitFun or reset an incompatible policy before changing it" + )); + } + let snapshot = update_external_integration_policy( + Some(&workspace), + ExternalIntegrationPolicyMutation { + expected_preference_revision: snapshot.preference_revision, + scope: external_policy_scope(scope), + change, + }, + ) + .await + .map_err(external_cli_operation_error)?; + println!("External compatibility settings saved.\n"); + print_external_policy_status(&snapshot); + Ok(()) +} + +async fn handle_external_config_action(action: ExternalConfigAction) -> Result<()> { + match action { + ExternalConfigAction::Status => { + let workspace = + std::env::current_dir().context("Failed to resolve current workspace")?; + let snapshot = external_source_snapshot(Some(&workspace), false) + .await + .map_err(external_cli_operation_error)?; + print_external_policy_status(&snapshot); + } + ExternalConfigAction::SetEnabled { enabled, scope } => { + update_external_policy( + scope, + ExternalIntegrationPolicyOperation::SetEnabled { enabled }, + ) + .await?; + } + ExternalConfigAction::SetMode { + mode, + ecosystem, + scope, + } => { + let ecosystem_id = resolve_external_ecosystem(ecosystem).await?; + update_external_policy( + scope, + ExternalIntegrationPolicyOperation::SetEcosystemMode { + ecosystem_id, + mode: external_policy_mode(mode), + }, + ) + .await?; + } + ExternalConfigAction::SetCapability { + capability, + access, + ecosystem, + scope, + } => { + let ecosystem_id = resolve_external_ecosystem(ecosystem).await?; + let capability_id = external_capability_id(capability)?; + let access = external_access(access); + if access == ExternalIntegrationAccess::Auto + && capability_id.as_str() != EXTERNAL_CAPABILITY_COMMAND + { + return Err(anyhow::anyhow!( + "Automatic use is available only for commands; tools, agents, and MCP servers require confirmation" + )); + } + update_external_policy( + scope, + ExternalIntegrationPolicyOperation::SetCapabilityAccess { + ecosystem_id, + capability_id, + access, + }, + ) + .await?; + } + ExternalConfigAction::ResetProject => { + update_external_policy( + ExternalPolicyScopeArg::Project, + ExternalIntegrationPolicyOperation::ResetWorkspace, + ) + .await?; + } + ExternalConfigAction::ResetIncompatible => { + update_external_policy( + ExternalPolicyScopeArg::Global, + ExternalIntegrationPolicyOperation::ResetIncompatiblePolicy, + ) + .await?; + } + } Ok(()) } @@ -469,3 +820,58 @@ pub(crate) async fn serve_acp_stdio() -> Result<()> { bitfun_acp::BitfunAcpRuntime::serve_stdio(agent_runtime, compatibility).await?; Ok(()) } + +#[cfg(test)] +mod external_ecosystem_selection_tests { + use super::*; + + fn ecosystem(id: &str) -> EcosystemId { + EcosystemId::new(id).unwrap() + } + + #[test] + fn ecosystem_selection_covers_zero_one_many_and_explicit_choices() { + let compatible = ExternalIntegrationPolicyStatus::Compatible; + assert!(select_external_ecosystem(&compatible, &[], None) + .unwrap_err() + .to_string() + .contains("No external ecosystems")); + + let only = vec![ecosystem("opencode")]; + assert_eq!( + select_external_ecosystem(&compatible, &only, None) + .unwrap() + .as_str(), + "opencode" + ); + assert!( + select_external_ecosystem(&compatible, &only, Some("missing")) + .unwrap_err() + .to_string() + .contains("Unknown external ecosystem") + ); + + let many = vec![ecosystem("opencode"), ecosystem("another")]; + assert!(select_external_ecosystem(&compatible, &many, None) + .unwrap_err() + .to_string() + .contains("--ecosystem")); + assert_eq!( + select_external_ecosystem(&compatible, &many, Some("another")) + .unwrap() + .as_str(), + "another" + ); + } + + #[test] + fn unknown_policy_status_is_always_safely_off() { + let error = select_external_ecosystem( + &ExternalIntegrationPolicyStatus::Unknown("future_status".to_string()), + &[ecosystem("opencode")], + Some("opencode"), + ) + .unwrap_err(); + assert!(error.to_string().contains("unsupported and safely off")); + } +} diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 5d303d453e..305ca092d4 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -5,13 +5,11 @@ use bitfun_core::external_sources::{ set_external_mcp_server_decision, set_external_prompt_command_conflict_choice, set_external_source_enabled, set_external_subagent_activation, set_external_tool_conflict_choice, set_external_tool_target_decision, - ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, - ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, - ExternalSubagentConflict, ExternalSubagentSummary, ExternalToolApprovalRequest, - ExternalToolCatalogEntry, ExternalToolConflict, PromptCommandAvailability, + update_external_integration_policy, ExternalIntegrationPolicyMutation, + ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceOperationResult, + ExternalSourcePublicSnapshot, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; -use bitfun_product_domains::external_sources::{PromptCommandConflict, SourceQualifiedCommandId}; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -29,6 +27,14 @@ pub struct SetExternalSourceEnabledRequest { pub workspace_path: Option, pub source_key: String, pub enabled: bool, + pub expected_preference_revision: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpdateExternalIntegrationPolicyRequest { + pub workspace_path: Option, + pub mutation: ExternalIntegrationPolicyMutation, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -37,6 +43,7 @@ pub struct SetExternalSourceConflictChoiceRequest { pub workspace_path: Option, pub conflict_key: String, pub candidate_id: String, + pub expected_preference_revision: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -46,6 +53,7 @@ pub struct SetExternalToolTargetDecisionRequest { pub approval_key: String, pub decision_key: String, pub approved: bool, + pub expected_preference_revision: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -54,6 +62,7 @@ pub struct SetExternalToolConflictChoiceRequest { pub workspace_path: Option, pub conflict_key: String, pub candidate_id: String, + pub expected_preference_revision: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -102,175 +111,121 @@ pub struct ChooseExternalMcpConflictRequest { pub expected_preference_revision: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalSourceSnapshotResponse { - pub generation: u64, - pub discovery_pending: bool, - pub sources: Vec, - pub commands: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub command_conflicts: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tools: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tool_approval_requests: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tool_conflicts: Vec, - #[serde(default)] - pub mcp_generation: u64, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub mcp_servers: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub mcp_approval_requests: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub mcp_conflicts: Vec, - #[serde(default)] - pub subagent_generation: u64, - #[serde(default)] - pub preference_revision: u64, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub subagents: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub subagent_conflicts: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub pending_subagent_approvals: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalPromptCommandSummary { - pub definition: ExternalPromptCommandDefinitionSummary, -} +pub type ExternalSourceSnapshotResponse = ExternalSourcePublicSnapshot; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ExternalPromptCommandDefinitionSummary { - pub id: SourceQualifiedCommandId, - pub name: String, - pub description: String, - pub availability: PromptCommandAvailability, - pub content_version: String, -} - -impl From for ExternalSourceSnapshotResponse { - fn from(snapshot: ExternalSourceCatalogSnapshot) -> Self { - Self { - generation: snapshot.generation, - discovery_pending: snapshot.discovery_pending, - sources: snapshot.sources, - commands: snapshot - .commands - .into_iter() - .map(|entry| ExternalPromptCommandSummary { - definition: ExternalPromptCommandDefinitionSummary { - id: entry.definition.id, - name: entry.definition.name, - description: entry.definition.description, - availability: entry.definition.availability, - content_version: entry.definition.content_version, - }, - }) - .collect(), - command_conflicts: snapshot.command_conflicts, - tools: snapshot.tools, - tool_approval_requests: snapshot.tool_approval_requests, - tool_conflicts: snapshot.tool_conflicts, - mcp_generation: snapshot.mcp_generation, - mcp_servers: snapshot.mcp_servers, - mcp_approval_requests: snapshot.mcp_approval_requests, - mcp_conflicts: snapshot.mcp_conflicts, - subagent_generation: snapshot.subagent_generation, - preference_revision: snapshot.preference_revision, - subagents: snapshot.subagents, - subagent_conflicts: snapshot.subagent_conflicts, - pending_subagent_approvals: snapshot.pending_subagent_approvals, - diagnostics: snapshot.diagnostics, - } - } -} - -async fn require_local_workspace(workspace_path: Option<&str>) -> Result, String> { +async fn require_local_workspace( + workspace_path: Option<&str>, +) -> ExternalSourceOperationResult> { let Some(workspace_path) = workspace_path else { return Ok(None); }; let path = Path::new(workspace_path); if !path.is_absolute() { - return Err("External AI application sources require an absolute workspace path".into()); + return Err(ExternalSourceOperationError::invalid_request( + "External AI application sources require an absolute workspace path", + )); } if is_remote_path(workspace_path).await { - return Err( - "unsupported_remote_workspace: External AI application sources are not available for remote workspaces yet".into(), - ); + return Err(ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::HostUnavailable, + "The remote workspace is not running the external compatibility service", + true, + )); } Ok(Some(path)) } +#[tauri::command] +pub async fn update_external_integration_policy_command( + request: UpdateExternalIntegrationPolicyRequest, +) -> ExternalSourceOperationResult { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + update_external_integration_policy(workspace, request.mutation) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) +} + #[tauri::command] pub async fn get_external_source_snapshot( request: ExternalSourceSnapshotRequest, -) -> Result { +) -> ExternalSourceOperationResult { let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; external_source_snapshot(workspace, request.force_refresh) .await .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } #[tauri::command] pub async fn set_external_source_enabled_command( request: SetExternalSourceEnabledRequest, -) -> Result { +) -> ExternalSourceOperationResult { let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; - set_external_source_enabled(workspace, &request.source_key, request.enabled) - .await - .map(Into::into) + set_external_source_enabled( + workspace, + &request.source_key, + request.enabled, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } #[tauri::command] pub async fn set_external_source_conflict_choice_command( request: SetExternalSourceConflictChoiceRequest, -) -> Result { +) -> ExternalSourceOperationResult { let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; set_external_prompt_command_conflict_choice( workspace, &request.conflict_key, &request.candidate_id, + request.expected_preference_revision, ) .await .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } #[tauri::command] pub async fn set_external_tool_target_decision_command( request: SetExternalToolTargetDecisionRequest, -) -> Result { +) -> ExternalSourceOperationResult { let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; set_external_tool_target_decision( workspace, &request.approval_key, &request.decision_key, request.approved, + request.expected_preference_revision, ) .await .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } #[tauri::command] pub async fn set_external_tool_conflict_choice_command( request: SetExternalToolConflictChoiceRequest, -) -> Result { +) -> ExternalSourceOperationResult { let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; - set_external_tool_conflict_choice(workspace, &request.conflict_key, &request.candidate_id) - .await - .map(Into::into) + set_external_tool_conflict_choice( + workspace, + &request.conflict_key, + &request.candidate_id, + request.expected_preference_revision, + ) + .await + .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } #[tauri::command] pub async fn set_external_subagent_activation_command( request: SetExternalSubagentActivationRequest, -) -> Result { +) -> ExternalSourceOperationResult { let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; set_external_subagent_activation( workspace, @@ -282,12 +237,13 @@ pub async fn set_external_subagent_activation_command( ) .await .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } #[tauri::command] pub async fn choose_external_subagent_conflict_command( request: ChooseExternalSubagentConflictRequest, -) -> Result { +) -> ExternalSourceOperationResult { let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; choose_external_subagent_conflict( workspace, @@ -299,12 +255,13 @@ pub async fn choose_external_subagent_conflict_command( ) .await .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } #[tauri::command] pub async fn set_external_mcp_server_decision_command( request: SetExternalMcpServerDecisionRequest, -) -> Result { +) -> ExternalSourceOperationResult { let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; set_external_mcp_server_decision( workspace, @@ -316,12 +273,13 @@ pub async fn set_external_mcp_server_decision_command( ) .await .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } #[tauri::command] pub async fn choose_external_mcp_conflict_command( request: ChooseExternalMcpConflictRequest, -) -> Result { +) -> ExternalSourceOperationResult { let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; choose_external_mcp_conflict( workspace, @@ -333,11 +291,13 @@ pub async fn choose_external_mcp_conflict_command( ) .await .map(Into::into) + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error) } #[cfg(test)] mod tests { use super::*; + use bitfun_core::external_sources::ExternalSourceCatalogSnapshot; #[test] fn desktop_snapshot_never_serializes_prompt_templates() { diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index aca86a953f..0ed31c79f2 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1574,6 +1574,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "update_custom_agent", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "update_external_integration_policy_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "update_mcp_remote_auth", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/api/skill_api.rs b/src/apps/desktop/src/api/skill_api.rs index 51e17b01e7..849402da9d 100644 --- a/src/apps/desktop/src/api/skill_api.rs +++ b/src/apps/desktop/src/api/skill_api.rs @@ -47,6 +47,28 @@ const MARKET_DESC_MAX_LEN: usize = 220; static MARKET_DESCRIPTION_CACHE: OnceLock>> = OnceLock::new(); +fn can_delete_owned_skill(source_id: &str, source_slot: &str, is_builtin: bool) -> bool { + if is_builtin { + return false; + } + + let source_id = source_id.trim().to_ascii_lowercase(); + if !source_id.is_empty() { + return matches!(source_id.as_str(), "bitfun" | "bitfun-system"); + } + + let source_slot = source_slot.trim().to_ascii_lowercase(); + source_slot.starts_with("bitfun") +} + +fn ensure_skill_can_be_deleted(skill: &SkillInfo) -> Result<(), String> { + if can_delete_owned_skill(&skill.source_id, &skill.source_slot, skill.is_builtin) { + Ok(()) + } else { + Err("Only BitFun-owned, non-built-in Skills can be deleted from BitFun".to_string()) + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SkillValidationResult { pub valid: bool, @@ -907,6 +929,7 @@ pub async fn delete_skill( .find_skill_by_key_for_remote_workspace(&remote_workspace_fs, &remote_root, &skill_key) .await .ok_or_else(|| format!("Skill '{}' not found", skill_key))?; + ensure_skill_can_be_deleted(&skill_info)?; match skill_info.level { SkillLocation::Project => { @@ -944,6 +967,7 @@ pub async fn delete_skill( .find_skill_by_key_for_workspace(&skill_key, workspace_root.as_deref()) .await .ok_or_else(|| format!("Skill '{}' not found", skill_key))?; + ensure_skill_can_be_deleted(&skill_info)?; let skill_path = std::path::PathBuf::from(&skill_info.path); @@ -965,6 +989,32 @@ pub async fn delete_skill( Ok(format!("Skill '{}' deleted successfully", skill_info.name)) } +#[cfg(test)] +mod skill_delete_policy_tests { + use super::can_delete_owned_skill; + + #[test] + fn only_bitfun_owned_non_builtin_skills_are_deletable() { + assert!(can_delete_owned_skill("bitfun", "bitfun", false)); + assert!(can_delete_owned_skill("", "bitfun", false)); + assert!(can_delete_owned_skill( + "bitfun-system", + "bitfun-system", + false + )); + assert!(!can_delete_owned_skill( + "bitfun-system", + "bitfun-system", + true + )); + assert!(!can_delete_owned_skill("opencode", "home.opencode", false)); + assert!(!can_delete_owned_skill("codex", "home.codex", false)); + assert!(!can_delete_owned_skill("future-ecosystem", "future", false)); + assert!(!can_delete_owned_skill("", "future", false)); + assert!(!can_delete_owned_skill("", "", false)); + } +} + #[tauri::command] pub async fn list_skill_market( _state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index a41c61504b..3354c0edef 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -930,6 +930,7 @@ pub async fn run() { api::editor_ai_api::editor_ai_stream, api::editor_ai_api::editor_ai_cancel, get_external_source_snapshot, + update_external_integration_policy_command, set_external_source_enabled_command, set_external_source_conflict_choice_command, set_external_tool_target_decision_command, diff --git a/src/apps/server/Cargo.toml b/src/apps/server/Cargo.toml index bed10367c5..e8a83ad459 100644 --- a/src/apps/server/Cargo.toml +++ b/src/apps/server/Cargo.toml @@ -10,6 +10,8 @@ name = "bitfun-server" path = "src/main.rs" [dependencies] +bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } + # Web framework axum = { workspace = true } tower-http = { workspace = true } @@ -19,6 +21,7 @@ tokio = { workspace = true, features = ["full"] } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } +clap = { workspace = true } base64 = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/src/apps/server/src/main.rs b/src/apps/server/src/main.rs index 6e76cde292..c6603f0cb5 100644 --- a/src/apps/server/src/main.rs +++ b/src/apps/server/src/main.rs @@ -5,16 +5,40 @@ use anyhow::Result; /// - RESTful API /// - WebSocket real-time communication /// - Static file serving (frontend) -use axum::{routing::get, Json, Router}; +use axum::{ + http::{HeaderValue, Method, Uri}, + routing::get, + Json, Router, +}; +use clap::Parser; use serde::Serialize; -use std::net::SocketAddr; +use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc}; use tower_http::cors::CorsLayer; mod routes; /// Application state #[derive(Clone)] -pub struct AppState {} +pub struct AppState { + external_workspace_root: Option, + allowed_browser_origins: Arc>, +} + +const DEFAULT_ALLOWED_BROWSER_ORIGINS: [&str; 2] = + ["http://localhost:1422", "http://127.0.0.1:1422"]; + +#[derive(Debug, Parser)] +#[command(name = "bitfun-server")] +struct ServerArgs { + /// Project workspace owned by this Server Host. + #[arg(long, value_name = "PATH")] + workspace: Option, + + /// Browser origin allowed to connect to this Server Host. Repeat to allow more than one. + /// When omitted, only BitFun's local Web development origins are allowed. + #[arg(long = "allowed-origin", value_name = "ORIGIN")] + allowed_origins: Vec, +} /// Health check response #[derive(Serialize)] @@ -41,23 +65,114 @@ async fn main() -> Result<()> { tracing::info!("BitFun Server v{}", env!("CARGO_PKG_VERSION")); - let app_state = AppState {}; + let args = ServerArgs::parse(); + let external_workspace_root = args + .workspace + .map(|path| { + if !path.is_absolute() { + return Err(anyhow::anyhow!("--workspace must be an absolute path")); + } + path.canonicalize() + .map_err(|error| anyhow::anyhow!("Could not open Server workspace: {error}")) + }) + .transpose()?; + let configured_origins = if args.allowed_origins.is_empty() { + DEFAULT_ALLOWED_BROWSER_ORIGINS + .iter() + .map(|origin| (*origin).to_string()) + .collect() + } else { + args.allowed_origins + }; + let allowed_browser_origins = configured_origins + .iter() + .map(|origin| normalize_browser_origin(origin)) + .collect::>>()?; + let cors_origins = allowed_browser_origins + .iter() + .map(|origin| { + HeaderValue::from_str(origin) + .map_err(|_| anyhow::anyhow!("--allowed-origin contains an invalid header value")) + }) + .collect::>>()?; + let app_state = AppState { + external_workspace_root, + allowed_browser_origins: Arc::new(allowed_browser_origins), + }; let app = Router::new() .route("/health", get(health_check)) .route("/api/v1/health", get(health_check)) .route("/api/v1/info", get(routes::api::api_info)) .route("/ws", get(routes::websocket::websocket_handler)) - .layer(CorsLayer::permissive()) + .layer( + CorsLayer::new() + .allow_methods([Method::GET]) + .allow_origin(cors_origins), + ) .with_state(app_state); let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); tracing::info!("Server started: http://{}", addr); tracing::info!("WebSocket endpoint: ws://{}/ws", addr); tracing::info!("Health check: http://{}/health", addr); + tracing::info!( + allowed_origin_count = configured_origins.len(), + "Browser origin policy configured" + ); let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve(listener, app).await?; Ok(()) } + +pub(crate) fn normalize_browser_origin(value: &str) -> Result { + let trimmed = value.trim(); + let uri = trimmed + .parse::() + .map_err(|_| anyhow::anyhow!("--allowed-origin must be an HTTP or HTTPS origin"))?; + let scheme = uri + .scheme_str() + .filter(|scheme| { + scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") + }) + .ok_or_else(|| anyhow::anyhow!("--allowed-origin must use http or https"))?; + let authority = uri + .authority() + .ok_or_else(|| anyhow::anyhow!("--allowed-origin must include a host"))?; + if uri + .path_and_query() + .is_some_and(|path_and_query| path_and_query.as_str() != "/") + { + return Err(anyhow::anyhow!( + "--allowed-origin must not include a path, query, or fragment" + )); + } + Ok(format!("{scheme}://{authority}").to_ascii_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browser_origins_are_normalized_for_exact_matching() { + assert_eq!( + normalize_browser_origin(" HTTPS://Example.TEST:8443/ ").unwrap(), + "https://example.test:8443" + ); + } + + #[test] + fn browser_origins_reject_non_origins() { + for invalid in [ + "file:///tmp/index.html", + "https://example.test/app", + "https://example.test?mode=web", + "example.test", + ] { + assert!(normalize_browser_origin(invalid).is_err(), "{invalid}"); + } + } +} diff --git a/src/apps/server/src/routes/external_sources.rs b/src/apps/server/src/routes/external_sources.rs new file mode 100644 index 0000000000..3449ee789f --- /dev/null +++ b/src/apps/server/src/routes/external_sources.rs @@ -0,0 +1,190 @@ +use bitfun_core::external_sources::{ + external_source_read_only_snapshot, ExternalSourceOperationError, ExternalSourceOperationResult, +}; +use std::path::PathBuf; + +use crate::AppState; + +pub(crate) fn supports(method: &str) -> bool { + matches!( + method, + "get_external_source_snapshot" + | "set_external_source_enabled_command" + | "set_external_source_conflict_choice_command" + | "set_external_tool_target_decision_command" + | "set_external_tool_conflict_choice_command" + | "set_external_subagent_activation_command" + | "choose_external_subagent_conflict_command" + | "set_external_mcp_server_decision_command" + | "choose_external_mcp_conflict_command" + | "update_external_integration_policy_command" + ) +} + +pub(crate) async fn dispatch( + method: &str, + params: serde_json::Value, + state: &AppState, +) -> ExternalSourceOperationResult { + if method != "get_external_source_snapshot" { + return Err(ExternalSourceOperationError::host_capability_unavailable( + if supports(method) { + "This Server Host exposes external integrations as read-only. Use an authenticated Desktop or Peer Host to change them." + } else { + "Unknown external source operation" + }, + )); + } + let request = params + .get("request") + .ok_or_else(|| ExternalSourceOperationError::invalid_request("missing request"))?; + let workspace = external_workspace_root(state, request)?; + let workspace = workspace.as_deref(); + let snapshot = match method { + "get_external_source_snapshot" => { + let force_refresh = optional_bool_field(request, "forceRefresh")?; + external_source_read_only_snapshot(workspace, force_refresh).await + } + _ => unreachable!("write and unknown methods are rejected before request parsing"), + } + .map_err(bitfun_core::external_sources::sanitize_external_source_operation_error)?; + + serde_json::to_value(snapshot).map_err(|_| { + ExternalSourceOperationError::new( + bitfun_core::external_sources::ExternalSourceOperationErrorCode::Internal, + "External source response could not be encoded", + false, + ) + }) +} + +fn external_workspace_root( + state: &AppState, + request: &serde_json::Value, +) -> ExternalSourceOperationResult> { + let workspace = match request.get("workspacePath") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(path)) if !path.trim().is_empty() => { + Some(PathBuf::from(path)) + } + _ => { + return Err(ExternalSourceOperationError::invalid_request( + "workspacePath must be a non-empty absolute path when provided", + )) + } + }; + if workspace.as_ref().is_some_and(|path| !path.is_absolute()) { + return Err(ExternalSourceOperationError::invalid_request( + "External sources require an absolute workspace path", + )); + } + let Some(requested) = workspace else { + return Ok(None); + }; + let owned = state.external_workspace_root.as_ref().ok_or_else(|| { + ExternalSourceOperationError::new( + bitfun_core::external_sources::ExternalSourceOperationErrorCode::HostUnavailable, + "The Server Host has no project workspace", + false, + ) + })?; + let requested = requested.canonicalize().map_err(|_| { + ExternalSourceOperationError::invalid_request( + "Workspace path is not available on this Host", + ) + })?; + if &requested != owned { + return Err(ExternalSourceOperationError::invalid_request( + "External compatibility is limited to the Server Host workspace", + )); + } + Ok(Some(requested)) +} + +fn optional_bool_field( + request: &serde_json::Value, + key: &str, +) -> ExternalSourceOperationResult { + match request.get(key) { + None | Some(serde_json::Value::Null) => Ok(false), + Some(serde_json::Value::Bool(value)) => Ok(*value), + _ => Err(ExternalSourceOperationError::invalid_request(format!( + "'{key}' must be a boolean when provided" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_state(external_workspace_root: Option) -> AppState { + AppState { + external_workspace_root, + allowed_browser_origins: Default::default(), + } + } + + #[test] + fn only_external_source_methods_are_claimed() { + assert!(supports("get_external_source_snapshot")); + assert!(supports("update_external_integration_policy_command")); + assert!(!supports("open_workspace")); + } + + #[test] + fn workspace_paths_must_be_absolute() { + let state = app_state(None); + let request = serde_json::json!({ "workspacePath": "relative/project" }); + let error = external_workspace_root(&state, &request).unwrap_err(); + assert_eq!(error.code.as_str(), "invalid_request"); + } + + #[test] + fn project_paths_require_an_owned_server_workspace() { + let state = app_state(None); + let workspace = std::env::current_dir().expect("current directory is available"); + let request = serde_json::json!({ "workspacePath": workspace }); + let error = external_workspace_root(&state, &request).unwrap_err(); + assert_eq!(error.code.as_str(), "host_unavailable"); + } + + #[test] + fn project_paths_must_match_the_owned_server_workspace() { + let workspace = std::env::current_dir() + .expect("current directory is available") + .canonicalize() + .expect("current directory can be canonicalized"); + let state = app_state(Some(workspace.clone())); + let request = serde_json::json!({ "workspacePath": workspace }); + assert_eq!( + external_workspace_root(&state, &request).unwrap(), + Some(workspace) + ); + } + + #[test] + fn malformed_optional_values_are_rejected() { + let request = serde_json::json!({ "forceRefresh": "false" }); + let error = optional_bool_field(&request, "forceRefresh").unwrap_err(); + assert_eq!(error.code.as_str(), "invalid_request"); + + let state = app_state(None); + let request = serde_json::json!({ "workspacePath": false }); + let error = external_workspace_root(&state, &request).unwrap_err(); + assert_eq!(error.code.as_str(), "invalid_request"); + } + + #[tokio::test] + async fn writes_are_rejected_before_request_or_workspace_parsing() { + let state = app_state(None); + let error = dispatch( + "set_external_source_enabled_command", + serde_json::json!({ "malformed": true }), + &state, + ) + .await + .unwrap_err(); + assert_eq!(error.code.as_str(), "host_capability_unavailable"); + } +} diff --git a/src/apps/server/src/routes/mod.rs b/src/apps/server/src/routes/mod.rs index 831e762e80..3e05d76d11 100644 --- a/src/apps/server/src/routes/mod.rs +++ b/src/apps/server/src/routes/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod api; +pub(crate) mod external_sources; /// Routes module /// /// Contains all HTTP and WebSocket routes diff --git a/src/apps/server/src/routes/websocket.rs b/src/apps/server/src/routes/websocket.rs index d99de7a36a..75fa0c534f 100644 --- a/src/apps/server/src/routes/websocket.rs +++ b/src/apps/server/src/routes/websocket.rs @@ -9,13 +9,16 @@ use axum::{ ws::{Message, WebSocket, WebSocketUpgrade}, State, }, - response::Response, + http::{header::ORIGIN, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, }; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use crate::AppState; +const MAX_WS_TEXT_BYTES: usize = 256 * 1024; + /// WebSocket message protocol (JSON RPC 2.0 style) #[derive(Debug, Deserialize, Serialize)] #[serde(tag = "type")] @@ -56,9 +59,27 @@ struct ErrorInfo { pub(crate) async fn websocket_handler( ws: WebSocketUpgrade, State(state): State, + headers: HeaderMap, ) -> Response { + if !browser_origin_allowed(&headers, &state) { + tracing::warn!("Rejected WebSocket upgrade from untrusted browser origin"); + return StatusCode::FORBIDDEN.into_response(); + } tracing::info!("New WebSocket connection"); - ws.on_upgrade(|socket| handle_socket(socket, state)) + ws.max_message_size(MAX_WS_TEXT_BYTES) + .max_frame_size(MAX_WS_TEXT_BYTES) + .on_upgrade(|socket| handle_socket(socket, state)) +} + +fn browser_origin_allowed(headers: &HeaderMap, state: &AppState) -> bool { + let Some(origin) = headers.get(ORIGIN) else { + return true; + }; + let Ok(origin) = origin.to_str() else { + return false; + }; + crate::normalize_browser_origin(origin) + .is_ok_and(|origin| state.allowed_browser_origins.contains(&origin)) } /// Handle a single WebSocket connection @@ -83,13 +104,29 @@ async fn handle_socket(socket: WebSocket, state: AppState) { while let Some(msg) = receiver.next().await { match msg { Ok(Message::Text(text)) => { - tracing::debug!("Received text message: {}", text); - if let Err(e) = handle_text_message(&mut sender, &text, &state).await { - tracing::error!("Failed to handle message: {:?}", e); + if text.len() > MAX_WS_TEXT_BYTES { + tracing::warn!( + message_bytes = text.len(), + "Rejected oversized WebSocket message" + ); + break; + } + if handle_text_message(&mut sender, &text, &state) + .await + .is_err() + { + tracing::warn!( + error_category = "message_processing", + "Failed to handle WebSocket message" + ); } } Ok(Message::Binary(data)) => { - tracing::debug!("Received binary message: {} bytes", data.len()); + tracing::warn!( + message_bytes = data.len(), + "Rejected unsupported binary WebSocket message" + ); + break; } Ok(Message::Ping(data)) => { tracing::trace!("Received Ping"); @@ -102,8 +139,8 @@ async fn handle_socket(socket: WebSocket, state: AppState) { tracing::info!("Client closed connection"); break; } - Err(e) => { - tracing::error!("WebSocket error: {:?}", e); + Err(_) => { + tracing::warn!(error_category = "transport", "WebSocket connection failed"); break; } } @@ -122,7 +159,11 @@ async fn handle_text_message( match ws_msg { WsMessage::Request { id, method, params } => { - tracing::info!("Handling request: method={}, id={}", method, id); + tracing::info!( + method = safe_protocol_token(&method), + id_kind = "string", + "Handling WebSocket request" + ); let result = handle_command(&method, params, state).await; @@ -132,13 +173,13 @@ async fn handle_text_message( result: Some(data), error: None, }, - Err(e) => WsMessage::Response { + Err(error) => WsMessage::Response { id, result: None, error: Some(ErrorInfo { - code: -1, - message: e.to_string(), - data: None, + code: json_rpc_error_code(error.code), + message: error.detail.clone(), + data: serde_json::to_value(error).ok(), }), }, }; @@ -147,7 +188,10 @@ async fn handle_text_message( sender.send(Message::Text(json.into())).await?; } WsMessage::Event { event, .. } => { - tracing::debug!("Received event: {}", event); + tracing::debug!( + event = safe_protocol_token(&event), + "Received WebSocket event" + ); } WsMessage::Response { .. } => { tracing::warn!("Received response message (client should not send responses)"); @@ -160,17 +204,111 @@ async fn handle_text_message( /// Handle specific commands async fn handle_command( method: &str, - _params: serde_json::Value, - _state: &AppState, -) -> Result { + params: serde_json::Value, + state: &AppState, +) -> bitfun_core::external_sources::ExternalSourceOperationResult { + if super::external_sources::supports(method) { + return super::external_sources::dispatch(method, params, state).await; + } match method { "ping" => Ok(serde_json::json!({ "pong": true, "timestamp": chrono::Utc::now().timestamp(), })), _ => { - tracing::warn!("Unknown command: {}", method); - Err(anyhow::anyhow!("Unknown command: {}", method)) + tracing::warn!( + method = safe_protocol_token(method), + "Unknown Server Host command" + ); + Err(bitfun_core::external_sources::ExternalSourceOperationError::host_capability_unavailable( + "Unknown Server Host operation", + )) } } } + +fn safe_protocol_token(value: &str) -> &str { + if !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + { + value + } else { + "" + } +} + +fn json_rpc_error_code( + code: bitfun_core::external_sources::ExternalSourceOperationErrorCode, +) -> i32 { + use bitfun_core::external_sources::ExternalSourceOperationErrorCode; + match code { + ExternalSourceOperationErrorCode::InvalidRequest => -32602, + ExternalSourceOperationErrorCode::HostCapabilityUnavailable => -32601, + ExternalSourceOperationErrorCode::StaleRevision + | ExternalSourceOperationErrorCode::Conflict => -32009, + ExternalSourceOperationErrorCode::HostUnavailable + | ExternalSourceOperationErrorCode::Unavailable => -32003, + ExternalSourceOperationErrorCode::PolicyIncompatible + | ExternalSourceOperationErrorCode::PolicyLimited => -32010, + ExternalSourceOperationErrorCode::NotFound => -32004, + ExternalSourceOperationErrorCode::Internal => -32603, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state_with_allowed_origins(origins: &[&str]) -> AppState { + AppState { + external_workspace_root: None, + allowed_browser_origins: std::sync::Arc::new( + origins.iter().map(|origin| (*origin).to_string()).collect(), + ), + } + } + + #[test] + fn browser_origin_requires_an_exact_allowlist_match() { + let state = state_with_allowed_origins(&["http://localhost:1422"]); + let mut allowed_headers = HeaderMap::new(); + allowed_headers.insert(ORIGIN, "http://localhost:1422".parse().unwrap()); + assert!(browser_origin_allowed(&allowed_headers, &state)); + + let mut unknown_headers = HeaderMap::new(); + unknown_headers.insert(ORIGIN, "https://example.test".parse().unwrap()); + assert!(!browser_origin_allowed(&unknown_headers, &state)); + assert!(browser_origin_allowed(&HeaderMap::new(), &state)); + } + + #[test] + fn typed_errors_keep_stable_json_rpc_categories() { + use bitfun_core::external_sources::ExternalSourceOperationErrorCode; + + assert_eq!( + json_rpc_error_code(ExternalSourceOperationErrorCode::InvalidRequest), + -32602 + ); + assert_eq!( + json_rpc_error_code(ExternalSourceOperationErrorCode::HostCapabilityUnavailable), + -32601 + ); + assert_eq!( + json_rpc_error_code(ExternalSourceOperationErrorCode::PolicyLimited), + -32010 + ); + } + + #[test] + fn client_protocol_tokens_are_bounded_before_logging() { + assert_eq!( + safe_protocol_token("get_external_source_snapshot"), + "get_external_source_snapshot" + ); + assert_eq!(safe_protocol_token("method\nsecret"), ""); + assert_eq!(safe_protocol_token(&"x".repeat(65)), ""); + } +} diff --git a/src/crates/assembly/core/src/external_mcp.rs b/src/crates/assembly/core/src/external_mcp.rs index 8feb2a440c..58cfca1c0d 100644 --- a/src/crates/assembly/core/src/external_mcp.rs +++ b/src/crates/assembly/core/src/external_mcp.rs @@ -5,14 +5,14 @@ use crate::service::mcp::{ use async_trait::async_trait; use bitfun_external_sources::ExternalMcpCoordinatorSnapshot; use bitfun_product_domains::external_sources::{ - external_mcp_approval_key, external_mcp_conflict_key, ExternalMcpActivationState, + external_mcp_approval_key, external_mcp_conflict_key, EcosystemId, ExternalMcpActivationState, ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, ExternalMcpConflictCandidate, ExternalMcpServerDefinition, ExternalMcpStaticStatus, ExternalSourceDiagnostic, PreparedExternalMcpServer, PreparedExternalMcpTransport, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::time::Duration; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -23,6 +23,7 @@ pub(super) struct ExternalMcpDecision { } pub(super) struct ExternalMcpDecisions<'a> { + pub active_ecosystems: &'a BTreeSet, pub server_decisions: &'a BTreeMap, pub conflict_choices: &'a BTreeMap, } @@ -304,6 +305,16 @@ pub(super) fn reconcile_external_mcp_catalog( ) }) .collect::>(); + let source_ecosystems = snapshot + .sources + .iter() + .map(|source| { + ( + source.record.key.clone(), + source.record.ecosystem_id.clone(), + ) + }) + .collect::>(); let mut state = ExternalMcpProductState::default(); for (_, mut group) in groups { @@ -313,17 +324,25 @@ pub(super) fn reconcile_external_mcp_catalog( group .external .sort_by(|left, right| left.candidate_id().cmp(&right.candidate_id())); - let participant_count = group.native.len() + group.external.len(); - let server_name = group - .native - .first() - .map(|candidate| candidate.name.as_str()) - .or_else(|| group.external.first().map(|definition| definition.name.as_str())) - .unwrap_or_default(); + let active_external = group + .external + .iter() + .copied() + .filter(|definition| { + source_ecosystems + .get(&definition.id.source) + .is_some_and(|ecosystem| decisions.active_ecosystems.contains(ecosystem)) + }) + .collect::>(); + let active_group = CandidateGroup { + native: group.native.clone(), + external: active_external, + }; + let participant_count = active_group.native.len() + active_group.external.len(); let pending_conflict = build_conflict( execution_domain_id, workspace_key, - &group, + &active_group, &source_names, decisions.conflict_choices, ); @@ -341,8 +360,7 @@ pub(super) fn reconcile_external_mcp_catalog( || decisions.conflict_choices.keys().any(|key| { key.rsplit_once(':') .is_some_and(|(lineage, _)| lineage == conflict_lineage) - }) - { + }) { Some(pending_conflict) } else { None @@ -351,7 +369,7 @@ pub(super) fn reconcile_external_mcp_catalog( .as_ref() .and_then(|conflict| conflict.selected_candidate_id.as_deref()); let selected_external = selected_candidate_id.is_some_and(|selected| { - group + active_group .external .iter() .any(|definition| definition.candidate_id() == selected) @@ -373,12 +391,15 @@ pub(super) fn reconcile_external_mcp_catalog( &definition.id, &definition.behavior_version, ); - let decision = current_or_previous_mcp_decision( - decisions.server_decisions, - &approval_key, - ); + let decision = + current_or_previous_mcp_decision(decisions.server_decisions, &approval_key); let static_unavailable = static_unavailable_reason(definition); - let activation_state = if let Some(reason) = static_unavailable { + let ecosystem_active = source_ecosystems + .get(&definition.id.source) + .is_some_and(|ecosystem| decisions.active_ecosystems.contains(ecosystem)); + let activation_state = if !ecosystem_active { + ExternalMcpActivationState::SourceDisabled + } else if let Some(reason) = static_unavailable { if !definition.source_enabled || matches!( definition.static_status, diff --git a/src/crates/assembly/core/src/external_mcp_tests.rs b/src/crates/assembly/core/src/external_mcp_tests.rs index 09f60ad2a7..640277f962 100644 --- a/src/crates/assembly/core/src/external_mcp_tests.rs +++ b/src/crates/assembly/core/src/external_mcp_tests.rs @@ -11,7 +11,14 @@ use bitfun_product_domains::external_sources::{ PreparedExternalMcpServer, PreparedExternalMcpTransport, SecretValue, SourceKey, SourceQualifiedMcpServerId, }; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; + +fn active_ecosystems() -> &'static BTreeSet { + static ECOSYSTEMS: std::sync::OnceLock> = std::sync::OnceLock::new(); + ECOSYSTEMS.get_or_init(|| { + BTreeSet::from([EcosystemId::new("opencode").expect("valid test ecosystem")]) + }) +} #[test] fn unavailable_external_mcp_can_be_disabled_but_not_silently_reapproved() { @@ -73,6 +80,7 @@ fn decisions<'a>( conflict_choices: &'a BTreeMap, ) -> ExternalMcpDecisions<'a> { ExternalMcpDecisions { + active_ecosystems: active_ecosystems(), server_decisions, conflict_choices, } diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 4a9b16fc3f..5c6f3b098b 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -3,15 +3,23 @@ //! Concrete ecosystem providers are selected only in this assembly module. The //! catalog and product surfaces remain provider- and ecosystem-neutral. +pub use bitfun_product_domains::external_integration_policy::{ + EffectiveExternalIntegrationPolicy, ExternalIntegrationAccess, ExternalIntegrationMode, + ExternalIntegrationPolicyMutation, ExternalIntegrationPolicyOperation, + ExternalIntegrationPolicyScope, ExternalIntegrationPolicySnapshot, + ExternalIntegrationPolicyStatus, +}; pub use bitfun_product_domains::external_sources::{ - prompt_command_conflict_key, ExpandedPromptCommand, ExternalMcpActivationState, - ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, - ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceCatalogEntry, - ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, - ExternalSourceLifecycleState, ExternalToolActivationState, ExternalToolApprovalRequest, - ExternalToolCapability, ExternalToolCatalogEntry, ExternalToolConflict, - ExternalToolRuntimeKind, PromptCommandAvailability, PromptCommandCatalogEntry, - PromptCommandDefinition, SourceKey, + prompt_command_conflict_key, EcosystemId, ExpandedPromptCommand, + ExternalIntegrationCapabilityId, ExternalMcpActivationState, ExternalMcpApprovalRequest, + ExternalMcpCatalogEntry, ExternalMcpConflict, ExternalMcpTransportKind, + ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, + ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, ExternalSourceHostCapabilities, + ExternalSourceLifecycleState, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceOperationResult, ExternalSourcePublicSnapshot, ExternalToolActivationState, + ExternalToolApprovalRequest, ExternalToolCapability, ExternalToolCatalogEntry, + ExternalToolConflict, ExternalToolConflictCandidateKind, ExternalToolRuntimeKind, + PromptCommandAvailability, PromptCommandCatalogEntry, PromptCommandDefinition, SourceKey, }; pub use bitfun_product_domains::external_subagents::{ ExternalSubagentActivationState, ExternalSubagentCompatibilityState, ExternalSubagentConflict, @@ -24,14 +32,15 @@ use crate::external_mcp::{ ExternalMcpRuntimeStatus, NativeMcpCandidate, }; use crate::external_subagents::{ - reconcile_external_subagents, ExternalSubagentDecisions, ExternalSubagentProductState, - DISABLED_SUBAGENT_CONFLICT_CHOICE, + project_external_subagents_read_only, reconcile_external_subagents, ExternalSubagentDecisions, + ExternalSubagentProductState, DISABLED_SUBAGENT_CONFLICT_CHOICE, }; use crate::external_tools::{ begin_external_tool_workspace_recovery, external_tool_workspace_requires_recovery, - merge_tool_state, reconcile_external_tools, release_external_tool_workspace, - reset_external_tool_workspace_recovery_budget, workspace_route_key, ExternalToolDecisions, - ExternalToolProductState, TOOL_CONFLICT_RESELECTION_REQUIRED, UNRESOLVED_TOOL_CONFLICT_CHOICE, + merge_tool_state, project_external_tools_read_only, reconcile_external_tools, + release_external_tool_workspace, reset_external_tool_workspace_recovery_budget, + workspace_route_key, ExternalToolDecisions, ExternalToolProductState, + TOOL_CONFLICT_RESELECTION_REQUIRED, UNRESOLVED_TOOL_CONFLICT_CHOICE, }; use crate::service::config::{subscribe_config_updates, ConfigUpdateEvent}; use bitfun_external_sources::{ @@ -43,6 +52,12 @@ use bitfun_external_sources::{ use bitfun_opencode_adapter::{ OpenCodeCommandProvider, OpenCodeMcpProvider, OpenCodeSubagentProvider, OpenCodeToolProvider, }; +use bitfun_product_domains::external_integration_policy::{ + external_integration_policy_snapshot, incompatible_external_integration_policy_snapshot, + ExternalIntegrationCapabilityDescriptor, ExternalIntegrationEcosystemDescriptor, + ExternalIntegrationPolicyDocument, ExternalIntegrationPolicySettings, + EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR, +}; use bitfun_product_domains::external_sources::{ ExecutionDomainId, ExternalMcpSourceProvider, ExternalSourceContext, ExternalSourceScope, ExternalToolSourceProvider, PromptCommandSourceProvider, @@ -65,10 +80,172 @@ use tokio::sync::broadcast; const PROVIDER_DISCOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); const EXTERNAL_SOURCE_PREFERENCES_FILE: &str = "external-sources.json"; const SUBAGENT_CONFLICT_RESELECTION_REQUIRED: &str = "__bitfun_reselection_required__"; +const OPENCODE_ECOSYSTEM_ID: &str = "opencode"; +pub const EXTERNAL_CAPABILITY_COMMAND: &str = "command"; +pub const EXTERNAL_CAPABILITY_TOOL: &str = "tool"; +pub const EXTERNAL_CAPABILITY_SUBAGENT: &str = "subagent"; +pub const EXTERNAL_CAPABILITY_MCP: &str = "mcp"; +const EXTERNAL_ADAPTER_CONTRACT_MAJOR: u32 = 1; + +fn external_capability_descriptor( + capability_id: &str, + recommended_access: ExternalIntegrationAccess, + safety_ceiling: ExternalIntegrationAccess, +) -> ExternalIntegrationCapabilityDescriptor { + ExternalIntegrationCapabilityDescriptor { + capability_id: ExternalIntegrationCapabilityId::new(capability_id) + .expect("built-in external integration capability id is valid"), + recommended_access, + safety_ceiling, + } +} + +/// Internal SDK-ready registration seam. Adapters only contribute discovery +/// providers and metadata; execution remains owned by BitFun policy/runtime. +#[derive(Clone)] +struct ExternalEcosystemRegistration { + descriptor: ExternalIntegrationEcosystemDescriptor, + contract_major: u32, + upstream_format_revision: &'static str, + command_provider: Option>, + tool_provider: Option>, + subagent_provider: Option>, + mcp_provider: Option>, +} + +impl ExternalEcosystemRegistration { + fn validate(&self) -> Result<(), String> { + if self.contract_major != EXTERNAL_ADAPTER_CONTRACT_MAJOR { + return Err(format!( + "adapter contract major {} is not supported", + self.contract_major + )); + } + let ecosystem_id = &self.descriptor.ecosystem_id; + let capabilities = self + .descriptor + .capabilities + .iter() + .map(|capability| capability.capability_id.as_str()) + .collect::>(); + let providers = [ + ( + EXTERNAL_CAPABILITY_COMMAND, + self.command_provider + .as_ref() + .map(|provider| provider.identity().ecosystem_id), + ), + ( + EXTERNAL_CAPABILITY_TOOL, + self.tool_provider + .as_ref() + .map(|provider| provider.identity().ecosystem_id), + ), + ( + EXTERNAL_CAPABILITY_SUBAGENT, + self.subagent_provider + .as_ref() + .map(|provider| provider.identity().ecosystem_id), + ), + ( + EXTERNAL_CAPABILITY_MCP, + self.mcp_provider + .as_ref() + .map(|provider| provider.identity().ecosystem_id), + ), + ]; + for (capability_id, provider_ecosystem) in providers { + if capabilities.contains(capability_id) != provider_ecosystem.is_some() { + return Err(format!( + "capability '{capability_id}' and provider registration do not match" + )); + } + if provider_ecosystem + .as_ref() + .is_some_and(|provider_ecosystem| provider_ecosystem != ecosystem_id) + { + return Err(format!( + "capability '{capability_id}' provider belongs to a different ecosystem" + )); + } + } + Ok(()) + } +} + +fn default_external_integration_registry() -> Vec { + vec![ExternalEcosystemRegistration { + descriptor: ExternalIntegrationEcosystemDescriptor { + ecosystem_id: EcosystemId::new(OPENCODE_ECOSYSTEM_ID) + .expect("OpenCode ecosystem id is valid"), + display_name: "OpenCode".to_string(), + adapter_revision: "1".to_string(), + capabilities: vec![ + external_capability_descriptor( + EXTERNAL_CAPABILITY_COMMAND, + ExternalIntegrationAccess::Auto, + ExternalIntegrationAccess::Auto, + ), + external_capability_descriptor( + EXTERNAL_CAPABILITY_TOOL, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + external_capability_descriptor( + EXTERNAL_CAPABILITY_SUBAGENT, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + external_capability_descriptor( + EXTERNAL_CAPABILITY_MCP, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + ], + }, + contract_major: EXTERNAL_ADAPTER_CONTRACT_MAJOR, + upstream_format_revision: "opencode-config-v1", + command_provider: Some(Arc::new(OpenCodeCommandProvider::default())), + tool_provider: Some(Arc::new(OpenCodeToolProvider::default())), + subagent_provider: Some(Arc::new(OpenCodeSubagentProvider::default())), + mcp_provider: Some(Arc::new(OpenCodeMcpProvider::default())), + }] +} + +fn default_external_integration_ecosystems() -> Vec { + default_external_integration_registry() + .into_iter() + .filter(|registration| { + let compatible = registration.validate(); + if let Err(error) = &compatible { + log::warn!( + "External ecosystem adapter skipped ecosystem={} contract_major={} host_contract_major={} upstream_format={} reason={}", + safe_external_log_token(registration.descriptor.ecosystem_id.as_str()), + registration.contract_major, + EXTERNAL_ADAPTER_CONTRACT_MAJOR, + safe_external_log_token(registration.upstream_format_revision), + safe_external_log_token(error), + ); + } + compatible.is_ok() + }) + .map(|registration| registration.descriptor) + .collect() +} +/// Kept stable so existing approval fingerprints remain valid. Product hosts +/// resolve this identity once; capability owners never hard-code it. +const LEGACY_LOCAL_EXECUTION_DOMAIN_ID: &str = "local-user"; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] struct ExternalSourcesConfig { + #[serde(default)] + integration_policy: StoredExternalIntegrationPolicy, + /// Bounded recovery history for a policy document written by an + /// incompatible host. This remains persistence-only and is never projected + /// through public Host APIs. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + integration_policy_backups: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] suppressed_source_keys: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -97,6 +274,93 @@ struct ExternalSourcesConfig { mcp_server_decisions: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] mcp_conflict_choices: BTreeMap, + /// Preserves fields written by a newer preferences schema. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + extensions: BTreeMap, +} + +/// Persistence-only version gate. Unknown major versions remain opaque until +/// the user explicitly backs them up and resets; this prevents current structs +/// from partially decoding a future policy shape before compatibility is known. +#[derive(Debug, Clone, PartialEq, Eq)] +enum StoredExternalIntegrationPolicy { + Known(ExternalIntegrationPolicyDocument), + Unknown { + schema_major: u32, + raw: serde_json::Value, + }, +} + +impl StoredExternalIntegrationPolicy { + fn schema_major(&self) -> u32 { + match self { + Self::Known(document) => document.schema_major, + Self::Unknown { schema_major, .. } => *schema_major, + } + } + + fn known(&self) -> Option<&ExternalIntegrationPolicyDocument> { + match self { + Self::Known(document) => Some(document), + Self::Unknown { .. } => None, + } + } + + fn known_mut(&mut self) -> Option<&mut ExternalIntegrationPolicyDocument> { + match self { + Self::Known(document) => Some(document), + Self::Unknown { .. } => None, + } + } + + fn raw_value(&self) -> serde_json::Value { + match self { + Self::Known(document) => serde_json::to_value(document).unwrap_or_else(|_| { + serde_json::json!({ + "schemaMajor": EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR, + "userDefaults": { "enabled": false } + }) + }), + Self::Unknown { raw, .. } => raw.clone(), + } + } +} + +impl Default for StoredExternalIntegrationPolicy { + fn default() -> Self { + Self::Known(ExternalIntegrationPolicyDocument::default()) + } +} + +impl Serialize for StoredExternalIntegrationPolicy { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.raw_value().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for StoredExternalIntegrationPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + + let raw = serde_json::Value::deserialize(deserializer)?; + let schema_major = raw + .get("schemaMajor") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR); + if schema_major != EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR { + return Ok(Self::Unknown { schema_major, raw }); + } + serde_json::from_value(raw) + .map(Self::Known) + .map_err(D::Error::custom) + } } #[derive(Debug, Clone)] @@ -174,8 +438,198 @@ fn config_update_refreshes_external_model_bindings(event: &ConfigUpdateEvent) -> matches!(event, ConfigUpdateEvent::ModelConfigurationUpdated) } +fn host_execution_domain_id() -> Result { + ExecutionDomainId::new(LEGACY_LOCAL_EXECUTION_DOMAIN_ID).map_err(|error| error.to_string()) +} + +fn workspace_policy_key(workspace_root: Option<&Path>) -> Option { + let route = workspace_route_key(workspace_root); + workspace_policy_key_from_route(&route) +} + +fn workspace_policy_key_from_route(route: &str) -> Option { + if route == "" { + return None; + } + let normalized = route.replace('\\', "/"); + #[cfg(windows)] + let normalized = normalized.to_ascii_lowercase(); + let mut hasher = Sha256::new(); + hasher.update(normalized.as_bytes()); + Some(format!( + "workspace:{}", + hex::encode(&hasher.finalize()[..16]) + )) +} + +fn integration_policy_snapshot( + preferences: &ExternalSourcesConfig, + workspace_root: Option<&Path>, +) -> Result { + let ecosystems = default_external_integration_ecosystems(); + match preferences.integration_policy.known() { + Some(document) => external_integration_policy_snapshot( + document, + workspace_policy_key(workspace_root).as_deref(), + ecosystems, + ), + None => incompatible_external_integration_policy_snapshot( + preferences.integration_policy.schema_major(), + ecosystems, + ), + } + .map_err(|error| format!("policy_unavailable: {error}")) +} + +fn integration_access( + policy: &ExternalIntegrationPolicySnapshot, + ecosystem_id: &str, + capability_id: &str, +) -> ExternalIntegrationAccess { + let Some(ecosystem) = policy + .effective + .ecosystems + .iter() + .find_map(|(id, policy)| (id.as_str() == ecosystem_id).then_some(policy)) + else { + return ExternalIntegrationAccess::Disabled; + }; + ecosystem + .capabilities + .iter() + .find_map(|(id, access)| (id.as_str() == capability_id).then_some(access.clone())) + .unwrap_or(ExternalIntegrationAccess::Disabled) +} + +fn integration_capability_is_discoverable( + policy: &ExternalIntegrationPolicySnapshot, + ecosystem_id: &str, + capability_id: &str, +) -> bool { + !matches!( + integration_access(policy, ecosystem_id, capability_id), + ExternalIntegrationAccess::Disabled | ExternalIntegrationAccess::Unknown(_) + ) +} + +fn integration_capability_is_active( + policy: &ExternalIntegrationPolicySnapshot, + ecosystem_id: &str, + capability_id: &str, +) -> bool { + matches!( + integration_access(policy, ecosystem_id, capability_id), + ExternalIntegrationAccess::Auto | ExternalIntegrationAccess::AskBeforeUse + ) +} + +fn ecosystems_with_discoverable_capability( + policy: &ExternalIntegrationPolicySnapshot, + capability_id: &str, +) -> BTreeSet { + policy + .registered_ecosystems + .iter() + .filter(|descriptor| { + integration_capability_is_discoverable( + policy, + descriptor.ecosystem_id.as_str(), + capability_id, + ) + }) + .map(|descriptor| descriptor.ecosystem_id.clone()) + .collect() +} + +fn ecosystems_with_active_capability( + policy: &ExternalIntegrationPolicySnapshot, + capability_id: &str, +) -> BTreeSet { + policy + .registered_ecosystems + .iter() + .filter(|descriptor| { + integration_capability_is_active( + policy, + descriptor.ecosystem_id.as_str(), + capability_id, + ) + }) + .map(|descriptor| descriptor.ecosystem_id.clone()) + .collect() +} + +fn source_ecosystem_id( + snapshot: &ExternalSourceCatalogSnapshot, + source_key: &SourceKey, +) -> Result { + snapshot + .sources + .iter() + .find(|source| source.record.key == *source_key) + .map(|source| source.record.ecosystem_id.clone()) + .ok_or_else(|| { + encoded_operation_error( + ExternalSourceOperationErrorCode::NotFound, + format!( + "External source '{}' is no longer available", + source_key.stable_key() + ), + false, + ) + }) +} + +fn ensure_source_capability_active( + snapshot: &ExternalSourceCatalogSnapshot, + source_key: &SourceKey, + capability_id: &str, +) -> Result<(), String> { + let ecosystem_id = source_ecosystem_id(snapshot, source_key)?; + integration_capability_is_active( + &snapshot.integration_policy, + ecosystem_id.as_str(), + capability_id, + ) + .then_some(()) + .ok_or_else(|| encoded_operation_error( + ExternalSourceOperationErrorCode::PolicyLimited, + format!( + "External capability '{capability_id}' is not enabled for ecosystem '{}' in this workspace", + ecosystem_id.as_str() + ), + false, + )) +} + +fn ensure_source_set_capability_active( + snapshot: &ExternalSourceCatalogSnapshot, + source_keys: &[SourceKey], + capability_id: &str, +) -> Result<(), String> { + if source_keys.is_empty() { + return Err(encoded_operation_error( + ExternalSourceOperationErrorCode::NotFound, + "External source provenance is missing", + false, + )); + } + for source_key in source_keys { + ensure_source_capability_active(snapshot, source_key, capability_id)?; + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExternalSourceServiceProfile { + LocalExecution, + ReadOnlyProjection, +} + struct WorkspaceExternalSourceService { + profile: ExternalSourceServiceProfile, workspace_root: Option, + execution_domain_id: ExecutionDomainId, coordinator: Arc>, tool_coordinator: Arc>, subagent_coordinator: Arc>, @@ -213,24 +667,49 @@ struct WorkspaceExternalSourceService { } impl WorkspaceExternalSourceService { - async fn create(workspace_root: Option) -> Result, String> { + async fn create( + workspace_root: Option, + profile: ExternalSourceServiceProfile, + ) -> Result, String> { + let execution_domain_id = host_execution_domain_id()?; let context = ExternalSourceContext { workspace_root: workspace_root.clone(), - execution_domain_id: ExecutionDomainId::new("local-user") - .map_err(|error| error.to_string())?, + execution_domain_id: execution_domain_id.clone(), }; - let providers: Vec> = - vec![Arc::new(OpenCodeCommandProvider::default())]; + let registrations = default_external_integration_registry() + .into_iter() + .filter_map(|registration| match registration.validate() { + Ok(()) => Some(registration), + Err(error) => { + log::warn!( + "External ecosystem registration rejected ecosystem={} reason={}", + safe_external_log_token(registration.descriptor.ecosystem_id.as_str()), + safe_external_log_token(&error), + ); + None + } + }) + .collect::>(); + let providers: Vec> = registrations + .iter() + .filter_map(|registration| registration.command_provider.as_ref().map(Arc::clone)) + .collect(); let mut coordinator = ExternalSourceCoordinator::new(context.clone(), providers)?; - let tool_providers: Vec> = - vec![Arc::new(OpenCodeToolProvider::default())]; + let tool_providers: Vec> = registrations + .iter() + .filter_map(|registration| registration.tool_provider.as_ref().map(Arc::clone)) + .collect(); let mut tool_coordinator = ExternalToolCoordinator::new(context.clone(), tool_providers)?; - let subagent_providers: Vec> = - vec![Arc::new(OpenCodeSubagentProvider::default())]; + let subagent_providers: Vec> = registrations + .iter() + .filter_map(|registration| registration.subagent_provider.as_ref().map(Arc::clone)) + .collect(); let mut subagent_coordinator = ExternalSubagentCoordinator::new(context.clone(), subagent_providers)?; - let mcp_providers: Vec> = - vec![Arc::new(OpenCodeMcpProvider::default())]; + let mcp_providers: Vec> = registrations + .iter() + .filter_map(|registration| registration.mcp_provider.as_ref().map(Arc::clone)) + .collect(); let mut mcp_coordinator = ExternalMcpCoordinator::new(context, mcp_providers)?; let preferences = read_external_sources_config().await?; let suppressed_sources = preferences @@ -254,9 +733,13 @@ impl WorkspaceExternalSourceService { ); initial_snapshot.subagent_generation = subagent_coordinator.snapshot().generation; initial_snapshot.preference_revision = preferences.preference_revision; + initial_snapshot.integration_policy = + integration_policy_snapshot(&preferences, workspace_root.as_deref())?; let (updates, _) = broadcast::channel(32); let service = Arc::new(Self { + profile, workspace_root, + execution_domain_id, coordinator: Arc::new(StdMutex::new(coordinator)), tool_coordinator: Arc::new(StdMutex::new(tool_coordinator)), subagent_coordinator: Arc::new(StdMutex::new(subagent_coordinator)), @@ -285,7 +768,9 @@ impl WorkspaceExternalSourceService { tool_decision_gate_acquired: tokio::sync::Notify::new(), }); service.start_watching().await; - service.start_model_config_watching(); + if profile == ExternalSourceServiceProfile::LocalExecution { + service.start_model_config_watching(); + } Ok(service) } @@ -318,27 +803,79 @@ impl WorkspaceExternalSourceService { // source active. sync_service_preferences(self).await?; let _refresh_guard = self.refresh_gate.lock().await; - if matches!(recovery_policy, WorkerRecoveryPolicy::ResetAndAttempt) { + let preferences = read_external_sources_config().await?; + let policy = integration_policy_snapshot(&preferences, self.workspace_root.as_deref())?; + if self.profile == ExternalSourceServiceProfile::LocalExecution + && matches!(recovery_policy, WorkerRecoveryPolicy::ResetAndAttempt) + { reset_external_tool_workspace_recovery_budget(self.workspace_root.as_deref()).await; } - let recovery_targets = if matches!( - recovery_policy, - WorkerRecoveryPolicy::PendingOnce | WorkerRecoveryPolicy::ResetAndAttempt - ) { + let recovery_targets = if self.profile == ExternalSourceServiceProfile::LocalExecution + && matches!( + recovery_policy, + WorkerRecoveryPolicy::PendingOnce | WorkerRecoveryPolicy::ResetAndAttempt + ) { begin_external_tool_workspace_recovery(self.workspace_root.as_deref()).await } else { BTreeSet::new() }; - let requests = lock_coordinator(&self.coordinator).discovery_requests(); + let mut requests = Vec::new(); + let mut disabled_command_results = Vec::new(); + for request in lock_coordinator(&self.coordinator).discovery_requests() { + if integration_capability_is_discoverable( + &policy, + request.ecosystem_id().as_str(), + EXTERNAL_CAPABILITY_COMMAND, + ) { + requests.push(request); + } else { + disabled_command_results.push(request.disabled()); + } + } let scheduled = self.prepare_discovery_tasks(requests).await; - let tool_requests = lock_tool_coordinator(&self.tool_coordinator).discovery_requests(); + let mut tool_requests = Vec::new(); + let mut disabled_tool_results = Vec::new(); + for request in lock_tool_coordinator(&self.tool_coordinator).discovery_requests() { + if integration_capability_is_discoverable( + &policy, + request.ecosystem_id().as_str(), + EXTERNAL_CAPABILITY_TOOL, + ) { + tool_requests.push(request); + } else { + disabled_tool_results.push(request.disabled()); + } + } let tool_scheduled = self.prepare_tool_discovery_tasks(tool_requests).await; - let subagent_requests = - lock_subagent_coordinator(&self.subagent_coordinator).discovery_requests(); + let mut subagent_requests = Vec::new(); + let mut disabled_subagent_results = Vec::new(); + for request in lock_subagent_coordinator(&self.subagent_coordinator).discovery_requests() { + if integration_capability_is_discoverable( + &policy, + request.ecosystem_id().as_str(), + EXTERNAL_CAPABILITY_SUBAGENT, + ) { + subagent_requests.push(request); + } else { + disabled_subagent_results.push(request.disabled()); + } + } let subagent_scheduled = self .prepare_subagent_discovery_tasks(subagent_requests) .await; - let mcp_requests = lock_mcp_coordinator(&self.mcp_coordinator).discovery_requests(); + let mut mcp_requests = Vec::new(); + let mut disabled_mcp_results = Vec::new(); + for request in lock_mcp_coordinator(&self.mcp_coordinator).discovery_requests() { + if integration_capability_is_discoverable( + &policy, + request.ecosystem_id().as_str(), + EXTERNAL_CAPABILITY_MCP, + ) { + mcp_requests.push(request); + } else { + disabled_mcp_results.push(request.disabled()); + } + } let mcp_scheduled = self.prepare_mcp_discovery_tasks(mcp_requests).await; let (polled, tool_polled, subagent_polled, mcp_polled) = tokio::join!( poll_discovery_tasks(scheduled, PROVIDER_DISCOVERY_TIMEOUT), @@ -346,17 +883,21 @@ impl WorkspaceExternalSourceService { poll_subagent_discovery_tasks(subagent_scheduled, PROVIDER_DISCOVERY_TIMEOUT), poll_mcp_discovery_tasks(mcp_scheduled, PROVIDER_DISCOVERY_TIMEOUT), ); - let results = self.finish_discovery_poll(polled).await; - let tool_results = self.finish_tool_discovery_poll(tool_polled).await; - let subagent_results = self.finish_subagent_discovery_poll(subagent_polled).await; - let mcp_results = self.finish_mcp_discovery_poll(mcp_polled).await; + let mut results = self.finish_discovery_poll(polled).await; + results.append(&mut disabled_command_results); + let mut tool_results = self.finish_tool_discovery_poll(tool_polled).await; + tool_results.append(&mut disabled_tool_results); + let mut subagent_results = self.finish_subagent_discovery_poll(subagent_polled).await; + subagent_results.append(&mut disabled_subagent_results); + let mut mcp_results = self.finish_mcp_discovery_poll(mcp_polled).await; + mcp_results.append(&mut disabled_mcp_results); let command_snapshot = lock_coordinator(&self.coordinator).apply_discovery_results(results); lock_tool_coordinator(&self.tool_coordinator).apply_discovery_results(tool_results); let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator) .apply_discovery_results(subagent_results); lock_mcp_coordinator(&self.mcp_coordinator).apply_discovery_results(mcp_results); self.schedule_subagent_last_valid_expiry(&subagent_snapshot); - self.ensure_watch_roots().await; + self.ensure_watch_roots(&policy).await; let snapshot = self .rebuild_product_snapshot_with_worker_recovery(command_snapshot, &recovery_targets) .await; @@ -410,11 +951,36 @@ impl WorkspaceExternalSourceService { let _rebuild_guard = self.product_rebuild_gate.lock().await; let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); let mut preferences = read_external_sources_config().await?; + let mut policy = integration_policy_snapshot(&preferences, self.workspace_root.as_deref())?; + if self.profile == ExternalSourceServiceProfile::ReadOnlyProjection { + return self + .rebuild_read_only_projection(command_snapshot, preferences, policy) + .await; + } + let command_discoverable = + !ecosystems_with_discoverable_capability(&policy, EXTERNAL_CAPABILITY_COMMAND) + .is_empty(); + let command_active_ecosystems = + ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_COMMAND); + let tool_discoverable = + !ecosystems_with_discoverable_capability(&policy, EXTERNAL_CAPABILITY_TOOL).is_empty(); + let tool_active_ecosystems = + ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_TOOL); + let subagent_discoverable = + !ecosystems_with_discoverable_capability(&policy, EXTERNAL_CAPABILITY_SUBAGENT) + .is_empty(); + let subagent_active_ecosystems = + ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_SUBAGENT); + let mcp_discoverable = + !ecosystems_with_discoverable_capability(&policy, EXTERNAL_CAPABILITY_MCP).is_empty(); + let mcp_active_ecosystems = + ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_MCP); let mut state = reconcile_external_tools( self.workspace_root.as_deref(), - "local-user", + self.execution_domain_id.as_str(), &self.tool_coordinator, ExternalToolDecisions { + active_ecosystems: &tool_active_ecosystems, approved_targets: &preferences.approved_tool_targets, declined_decisions_by_approval: &preferences.declined_tool_decisions, conflict_choices: &preferences.tool_conflict_choices, @@ -437,24 +1003,31 @@ impl WorkspaceExternalSourceService { let mut snapshot = merge_tool_state(command_snapshot, &tool_snapshot, state); let mcp_snapshot = lock_mcp_coordinator(&self.mcp_coordinator).snapshot(); let mcp_workspace_key = workspace_route_key(self.workspace_root.as_deref()); - let mut mcp_state = match load_native_mcp_candidates().await { + let native_mcp_candidates = if !mcp_active_ecosystems.is_empty() { + load_native_mcp_candidates().await + } else { + Ok(Vec::new()) + }; + let mut mcp_state = match native_mcp_candidates { Ok(native_candidates) => reconcile_external_mcp_catalog( - "local-user", + self.execution_domain_id.as_str(), &mcp_workspace_key, &mcp_snapshot, &native_candidates, ExternalMcpDecisions { + active_ecosystems: &mcp_active_ecosystems, server_decisions: &preferences.mcp_server_decisions, conflict_choices: &preferences.mcp_conflict_choices, }, ), Err(error) => { let mut state = reconcile_external_mcp_catalog( - "local-user", + self.execution_domain_id.as_str(), &mcp_workspace_key, &mcp_snapshot, &[], ExternalMcpDecisions { + active_ecosystems: &mcp_active_ecosystems, server_decisions: &preferences.mcp_server_decisions, conflict_choices: &preferences.mcp_conflict_choices, }, @@ -474,9 +1047,10 @@ impl WorkspaceExternalSourceService { let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator).snapshot(); let mut subagent_state = reconcile_external_subagents( self.workspace_root.as_deref(), - "local-user", + self.execution_domain_id.as_str(), &subagent_snapshot, ExternalSubagentDecisions { + active_ecosystems: &subagent_active_ecosystems, approved_envelopes: &preferences.approved_subagent_envelopes, declined_decisions: &preferences.declined_subagent_decisions, conflict_choices: &preferences.subagent_conflict_choices, @@ -484,47 +1058,50 @@ impl WorkspaceExternalSourceService { }, ) .await; - match persist_observed_subagent_conflicts( - &subagent_state.observed_conflict_lineage_current_keys, - ) - .await { - Ok((_history_changed, authoritative)) => { - let decisions_changed = authoritative.preference_revision - != preferences.preference_revision - || authoritative.approved_subagent_envelopes - != preferences.approved_subagent_envelopes - || authoritative.declined_subagent_decisions - != preferences.declined_subagent_decisions - || authoritative.subagent_conflict_choices - != preferences.subagent_conflict_choices - || authoritative.subagent_conflict_lineage_current_keys - != preferences.subagent_conflict_lineage_current_keys; - preferences = authoritative; - if decisions_changed { - subagent_state = reconcile_external_subagents( - self.workspace_root.as_deref(), - "local-user", - &subagent_snapshot, - ExternalSubagentDecisions { - approved_envelopes: &preferences.approved_subagent_envelopes, - declined_decisions: &preferences.declined_subagent_decisions, - conflict_choices: &preferences.subagent_conflict_choices, - conflict_lineage_current_keys: &preferences - .subagent_conflict_lineage_current_keys, - }, - ) - .await; + match persist_observed_subagent_conflicts( + &subagent_state.observed_conflict_lineage_current_keys, + ) + .await + { + Ok((_history_changed, authoritative)) => { + let decisions_changed = authoritative.preference_revision + != preferences.preference_revision + || authoritative.approved_subagent_envelopes + != preferences.approved_subagent_envelopes + || authoritative.declined_subagent_decisions + != preferences.declined_subagent_decisions + || authoritative.subagent_conflict_choices + != preferences.subagent_conflict_choices + || authoritative.subagent_conflict_lineage_current_keys + != preferences.subagent_conflict_lineage_current_keys; + preferences = authoritative; + if decisions_changed { + subagent_state = reconcile_external_subagents( + self.workspace_root.as_deref(), + self.execution_domain_id.as_str(), + &subagent_snapshot, + ExternalSubagentDecisions { + active_ecosystems: &subagent_active_ecosystems, + approved_envelopes: &preferences.approved_subagent_envelopes, + declined_decisions: &preferences.declined_subagent_decisions, + conflict_choices: &preferences.subagent_conflict_choices, + conflict_lineage_current_keys: &preferences + .subagent_conflict_lineage_current_keys, + }, + ) + .await; + } } - } - Err(error) => { - snapshot.diagnostics.push(ExternalSourceDiagnostic::warning( + Err(error) => { + snapshot.diagnostics.push(ExternalSourceDiagnostic::warning( "external_subagent.conflict_history_write_failed", format!( "Could not persist external subagent conflict history; routes remain unavailable: {error}" ), None, ).with_asset_kind(ExternalSourceAssetKind::Subagent)); + } } } merge_subagent_state( @@ -540,6 +1117,185 @@ impl WorkspaceExternalSourceService { subagent_state.routes, ); } + let source_ecosystems = snapshot + .sources + .iter() + .map(|source| { + ( + source.record.key.clone(), + source.record.ecosystem_id.clone(), + ) + }) + .collect::>(); + let restricted = PromptCommandAvailability::Restricted { + reason: "External command execution is disabled by integration policy".to_string(), + required_capabilities: vec![EXTERNAL_CAPABILITY_COMMAND.to_string()], + }; + for command in &mut snapshot.commands { + if !source_ecosystems + .get(&command.definition.id.source) + .is_some_and(|ecosystem| command_active_ecosystems.contains(ecosystem)) + { + command.definition.availability = restricted.clone(); + } + } + for conflict in &mut snapshot.command_conflicts { + for candidate in &mut conflict.candidates { + if !command_active_ecosystems.contains(&candidate.ecosystem_id) { + candidate.availability = restricted.clone(); + if conflict.selected_candidate_id.as_deref() + == Some(candidate.candidate_id.as_str()) + { + conflict.selected_candidate_id = None; + } + } + } + } + if !tool_discoverable { + snapshot.tools.clear(); + snapshot.tool_approval_requests.clear(); + snapshot.tool_conflicts.clear(); + } + if !subagent_discoverable { + snapshot.subagents.clear(); + snapshot.subagent_conflicts.clear(); + snapshot.pending_subagent_approvals.clear(); + } + if !mcp_discoverable { + snapshot.mcp_servers.clear(); + snapshot.mcp_approval_requests.clear(); + snapshot.mcp_conflicts.clear(); + } + if !command_discoverable + && !tool_discoverable + && !subagent_discoverable + && !mcp_discoverable + { + snapshot.sources.clear(); + snapshot.diagnostics.clear(); + } + policy = integration_policy_snapshot(&preferences, self.workspace_root.as_deref())?; + snapshot.integration_policy = policy; + sanitize_external_snapshot_locations(&mut snapshot, self.workspace_root.as_deref()); + let mut current = lock_snapshot(&self.snapshot); + let mcp_changed = snapshot.mcp_servers != current.mcp_servers + || snapshot.mcp_conflicts != current.mcp_conflicts + || snapshot.mcp_approval_requests != current.mcp_approval_requests; + snapshot.mcp_generation = if mcp_changed { + snapshot + .mcp_generation + .max(current.mcp_generation.saturating_add(1)) + } else { + snapshot.mcp_generation.max(current.mcp_generation) + }; + let subagent_changed = snapshot.subagents != current.subagents + || snapshot.subagent_conflicts != current.subagent_conflicts + || snapshot.pending_subagent_approvals != current.pending_subagent_approvals + || snapshot.preference_revision != current.preference_revision; + snapshot.subagent_generation = if subagent_changed { + snapshot + .subagent_generation + .max(current.subagent_generation.saturating_add(1)) + } else { + snapshot + .subagent_generation + .max(current.subagent_generation) + }; + snapshot.generation = snapshot + .generation + .max(current.generation.saturating_add(1)); + *current = snapshot.clone(); + Ok(snapshot) + } + + async fn rebuild_read_only_projection( + &self, + command_snapshot: ExternalSourceCatalogSnapshot, + preferences: ExternalSourcesConfig, + policy: ExternalIntegrationPolicySnapshot, + ) -> Result { + let tool_active_ecosystems = + ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_TOOL); + let subagent_active_ecosystems = + ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_SUBAGENT); + let mcp_active_ecosystems = + ecosystems_with_active_capability(&policy, EXTERNAL_CAPABILITY_MCP); + + let tool_snapshot = lock_tool_coordinator(&self.tool_coordinator).snapshot(); + let tool_state = project_external_tools_read_only( + self.execution_domain_id.as_str(), + &tool_snapshot, + ExternalToolDecisions { + active_ecosystems: &tool_active_ecosystems, + approved_targets: &preferences.approved_tool_targets, + declined_decisions_by_approval: &preferences.declined_tool_decisions, + conflict_choices: &preferences.tool_conflict_choices, + }, + ); + let mut snapshot = merge_tool_state(command_snapshot, &tool_snapshot, tool_state); + + let mcp_snapshot = lock_mcp_coordinator(&self.mcp_coordinator).snapshot(); + let mcp_workspace_key = workspace_route_key(self.workspace_root.as_deref()); + let mut mcp_state = reconcile_external_mcp_catalog( + self.execution_domain_id.as_str(), + &mcp_workspace_key, + &mcp_snapshot, + &[], + ExternalMcpDecisions { + active_ecosystems: &mcp_active_ecosystems, + server_decisions: &preferences.mcp_server_decisions, + conflict_choices: &preferences.mcp_conflict_choices, + }, + ); + mcp_state.active.clear(); + mcp_state.suppressed_native_server_ids.clear(); + for entry in &mut mcp_state.entries { + entry.runtime_id = None; + if matches!( + entry.activation_state, + ExternalMcpActivationState::Active | ExternalMcpActivationState::Starting + ) { + entry.activation_state = ExternalMcpActivationState::RuntimeUnavailable { + reason: "This Host exposes discovery only; use Desktop or an authenticated Peer Host to run external MCP servers".to_string(), + }; + } + } + merge_mcp_state(&mut snapshot, &mcp_snapshot, mcp_state); + + let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator).snapshot(); + let subagent_state = project_external_subagents_read_only( + self.workspace_root.as_deref(), + self.execution_domain_id.as_str(), + &subagent_snapshot, + ExternalSubagentDecisions { + active_ecosystems: &subagent_active_ecosystems, + approved_envelopes: &preferences.approved_subagent_envelopes, + declined_decisions: &preferences.declined_subagent_decisions, + conflict_choices: &preferences.subagent_conflict_choices, + conflict_lineage_current_keys: &preferences.subagent_conflict_lineage_current_keys, + }, + ); + merge_subagent_state( + &mut snapshot, + &subagent_snapshot, + &subagent_state, + preferences.preference_revision, + ); + + let restricted = PromptCommandAvailability::Restricted { + reason: "This Host exposes discovery only; run external commands from Desktop or an authenticated Peer Host".to_string(), + required_capabilities: vec![EXTERNAL_CAPABILITY_COMMAND.to_string()], + }; + for command in &mut snapshot.commands { + command.definition.availability = restricted.clone(); + } + for conflict in &mut snapshot.command_conflicts { + conflict.selected_candidate_id = None; + for candidate in &mut conflict.candidates { + candidate.availability = restricted.clone(); + } + } + snapshot.integration_policy = policy; sanitize_external_snapshot_locations(&mut snapshot, self.workspace_root.as_deref()); let mut current = lock_snapshot(&self.snapshot); let mcp_changed = snapshot.mcp_servers != current.mcp_servers @@ -1021,8 +1777,26 @@ impl WorkspaceExternalSourceService { { return; } + let Ok(preferences) = read_external_sources_config().await else { + return; + }; + let Ok(policy) = integration_policy_snapshot(&preferences, self.workspace_root.as_deref()) + else { + return; + }; + let ecosystem_id = lock_coordinator(&self.coordinator).ecosystem_for_provider(&provider_id); + if ecosystem_id.is_none_or(|ecosystem_id| { + !integration_capability_is_discoverable( + &policy, + ecosystem_id.as_str(), + EXTERNAL_CAPABILITY_COMMAND, + ) + }) { + self.ensure_watch_roots(&policy).await; + return; + } let command_snapshot = lock_coordinator(&self.coordinator).apply_discovery_result(result); - self.ensure_watch_roots().await; + self.ensure_watch_roots(&policy).await; if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { let _ = self.updates.send(snapshot); } @@ -1043,8 +1817,27 @@ impl WorkspaceExternalSourceService { { return; } + let Ok(preferences) = read_external_sources_config().await else { + return; + }; + let Ok(policy) = integration_policy_snapshot(&preferences, self.workspace_root.as_deref()) + else { + return; + }; + let ecosystem_id = + lock_tool_coordinator(&self.tool_coordinator).ecosystem_for_provider(&provider_id); + if ecosystem_id.is_none_or(|ecosystem_id| { + !integration_capability_is_discoverable( + &policy, + ecosystem_id.as_str(), + EXTERNAL_CAPABILITY_TOOL, + ) + }) { + self.ensure_watch_roots(&policy).await; + return; + } lock_tool_coordinator(&self.tool_coordinator).apply_discovery_result(result); - self.ensure_watch_roots().await; + self.ensure_watch_roots(&policy).await; let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { let _ = self.updates.send(snapshot); @@ -1066,10 +1859,29 @@ impl WorkspaceExternalSourceService { { return; } + let Ok(preferences) = read_external_sources_config().await else { + return; + }; + let Ok(policy) = integration_policy_snapshot(&preferences, self.workspace_root.as_deref()) + else { + return; + }; + let ecosystem_id = lock_subagent_coordinator(&self.subagent_coordinator) + .ecosystem_for_provider(&provider_id); + if ecosystem_id.is_none_or(|ecosystem_id| { + !integration_capability_is_discoverable( + &policy, + ecosystem_id.as_str(), + EXTERNAL_CAPABILITY_SUBAGENT, + ) + }) { + self.ensure_watch_roots(&policy).await; + return; + } let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator).apply_discovery_result(result); self.schedule_subagent_last_valid_expiry(&subagent_snapshot); - self.ensure_watch_roots().await; + self.ensure_watch_roots(&policy).await; let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { let _ = self.updates.send(snapshot); @@ -1091,8 +1903,27 @@ impl WorkspaceExternalSourceService { { return; } + let Ok(preferences) = read_external_sources_config().await else { + return; + }; + let Ok(policy) = integration_policy_snapshot(&preferences, self.workspace_root.as_deref()) + else { + return; + }; + let ecosystem_id = + lock_mcp_coordinator(&self.mcp_coordinator).ecosystem_for_provider(&provider_id); + if ecosystem_id.is_none_or(|ecosystem_id| { + !integration_capability_is_discoverable( + &policy, + ecosystem_id.as_str(), + EXTERNAL_CAPABILITY_MCP, + ) + }) { + self.ensure_watch_roots(&policy).await; + return; + } lock_mcp_coordinator(&self.mcp_coordinator).apply_discovery_result(result); - self.ensure_watch_roots().await; + self.ensure_watch_roots(&policy).await; let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { let _ = self.updates.send(snapshot); @@ -1143,7 +1974,11 @@ impl WorkspaceExternalSourceService { return; }; if let Err(error) = service.ensure_initial_refresh().await { - log::warn!("Initial external source refresh failed: {}", error); + log::warn!( + "Initial external source refresh failed scope={} error_category={}", + external_log_scope(service.workspace_root.as_deref()), + external_log_error_category(&error), + ); } service .background_refresh_scheduled @@ -1188,38 +2023,43 @@ impl WorkspaceExternalSourceService { continue; } let key = service.workspace_root.clone(); - if let Some(entry) = workspace_services().get(&key) { + let services = workspace_services_for_profile(service.profile); + if let Some(entry) = services.get(&key) { let should_remove = entry .value() .upgrade() .is_some_and(|cached| Arc::ptr_eq(&cached, &service)); drop(entry); if should_remove { - let runtime_ids = - std::mem::take(&mut *service.active_mcp_runtime_ids.lock().await); - let workspace_key = workspace_route_key(key.as_deref()); - let _ = service - .mcp_runtime - .replace_workspace_route( - &workspace_key, - BTreeSet::new(), - BTreeSet::new(), - ) - .await; - for runtime_id in runtime_ids { - if let Err(error) = service.mcp_runtime.retire(&runtime_id).await { - log::warn!( - "Could not retire idle external MCP runtime: id={} error={}", - runtime_id, - error - ); + if service.profile == ExternalSourceServiceProfile::LocalExecution { + let runtime_ids = + std::mem::take(&mut *service.active_mcp_runtime_ids.lock().await); + let workspace_key = workspace_route_key(key.as_deref()); + let _ = service + .mcp_runtime + .replace_workspace_route( + &workspace_key, + BTreeSet::new(), + BTreeSet::new(), + ) + .await; + for runtime_id in runtime_ids { + if let Err(error) = service.mcp_runtime.retire(&runtime_id).await { + log::warn!( + "Could not retire idle external MCP runtime runtime_id={} error_category={}", + safe_external_log_token(&runtime_id), + external_log_error_category(&error.to_string()), + ); + } } } - workspace_services().remove(&key); - release_external_tool_workspace(key.as_deref()).await; - if let Some(workspace_root) = key.as_deref() { - crate::agentic::agents::get_agent_registry() - .release_external_subagent_workspace(workspace_root); + services.remove(&key); + if service.profile == ExternalSourceServiceProfile::LocalExecution { + release_external_tool_workspace(key.as_deref()).await; + if let Some(workspace_root) = key.as_deref() { + crate::agentic::agents::get_agent_registry() + .release_external_subagent_workspace(workspace_root); + } } } } @@ -1236,7 +2076,14 @@ impl WorkspaceExternalSourceService { self: &Arc, stable_key: &str, enabled: bool, + expected_preference_revision: u64, ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + if self.snapshot().preference_revision != expected_preference_revision { + return Err(stale_operation_error( + "External source preferences changed; refresh before retrying", + )); + } let (previous_commands, command_known) = { let mut coordinator = lock_coordinator(&self.coordinator); let previous = coordinator.suppressed_sources().clone(); @@ -1262,21 +2109,27 @@ impl WorkspaceExternalSourceService { (previous, known) }; if !command_known && !tool_known && !subagent_known && !mcp_known { - return Err(format!("unknown external source: {stable_key}")); + return Err(missing_candidate_error(format!( + "External source '{stable_key}' is no longer available" + ))); } - let authoritative = match persist_source_enabled_change(stable_key, enabled).await { - Ok(authoritative) => authoritative, - Err(error) => { - lock_coordinator(&self.coordinator).replace_suppressed_sources(previous_commands); - lock_tool_coordinator(&self.tool_coordinator) - .replace_suppressed_sources(previous_tools); - lock_subagent_coordinator(&self.subagent_coordinator) - .replace_suppressed_sources(previous_subagents); - lock_mcp_coordinator(&self.mcp_coordinator) - .replace_suppressed_sources(previous_mcps); - return Err(error); - } - }; + let authoritative = + match persist_source_enabled_change(stable_key, enabled, expected_preference_revision) + .await + { + Ok(authoritative) => authoritative, + Err(error) => { + lock_coordinator(&self.coordinator) + .replace_suppressed_sources(previous_commands); + lock_tool_coordinator(&self.tool_coordinator) + .replace_suppressed_sources(previous_tools); + lock_subagent_coordinator(&self.subagent_coordinator) + .replace_suppressed_sources(previous_subagents); + lock_mcp_coordinator(&self.mcp_coordinator) + .replace_suppressed_sources(previous_mcps); + return Err(error); + } + }; lock_coordinator(&self.coordinator).replace_suppressed_sources(authoritative.clone()); lock_tool_coordinator(&self.tool_coordinator) .replace_suppressed_sources(authoritative.clone()); @@ -1288,11 +2141,53 @@ impl WorkspaceExternalSourceService { self.refresh_preserving_worker_recovery().await } + async fn update_integration_policy( + self: &Arc, + mutation: ExternalIntegrationPolicyMutation, + ) -> Result { + let preferences = + persist_integration_policy_mutation(self.workspace_root.as_deref(), mutation).await?; + propagate_integration_policy_preferences(&preferences, self); + self.refresh_preserving_worker_recovery().await + } + async fn set_conflict_choice( &self, conflict_key: &str, candidate_id: &str, + expected_preference_revision: u64, ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let product_snapshot = self.snapshot(); + if product_snapshot.preference_revision != expected_preference_revision { + return Err(stale_operation_error( + "External command preferences changed; refresh before retrying", + )); + } + let selected_candidate = product_snapshot + .command_conflicts + .iter() + .find(|conflict| conflict.conflict_key == conflict_key) + .and_then(|conflict| { + conflict + .candidates + .iter() + .find(|candidate| candidate.candidate_id == candidate_id) + }) + .ok_or_else(|| { + missing_candidate_error(format!( + "External source conflict '{conflict_key}' is no longer available" + )) + })?; + if !integration_capability_is_active( + &product_snapshot.integration_policy, + selected_candidate.ecosystem_id.as_str(), + EXTERNAL_CAPABILITY_COMMAND, + ) { + return Err(policy_limited_error( + "The selected external command ecosystem is not enabled for this workspace", + )); + } let (previous_choices, previous_lineage_keys, previous_conflicted_ids, participants) = { let mut coordinator = lock_coordinator(&self.coordinator); let participants = coordinator @@ -1307,7 +2202,11 @@ impl WorkspaceExternalSourceService { .map(|candidate| candidate.candidate_id) .collect::>() }) - .ok_or_else(|| format!("unknown external source conflict: {conflict_key}"))?; + .ok_or_else(|| { + missing_candidate_error(format!( + "External source conflict '{conflict_key}' is no longer available" + )) + })?; let previous_choices = coordinator.conflict_choices().clone(); let previous_lineage_keys = coordinator.conflict_lineage_current_keys().clone(); let previous_conflicted_ids = coordinator.conflicted_candidate_ids().clone(); @@ -1327,17 +2226,23 @@ impl WorkspaceExternalSourceService { coordinator.conflicted_candidate_ids().clone(), ) }; - let authoritative = - match persist_conflict_choice(conflict_key, candidate_id, participants).await { - Ok(authoritative) => authoritative, - Err(error) => { - let mut coordinator = lock_coordinator(&self.coordinator); - coordinator.replace_conflict_choices(previous_choices); - coordinator.replace_conflict_lineage_current_keys(previous_lineage_keys); - coordinator.replace_conflicted_candidate_ids(previous_conflicted_ids); - return Err(error); - } - }; + let authoritative = match persist_conflict_choice( + conflict_key, + candidate_id, + participants, + expected_preference_revision, + ) + .await + { + Ok(authoritative) => authoritative, + Err(error) => { + let mut coordinator = lock_coordinator(&self.coordinator); + coordinator.replace_conflict_choices(previous_choices); + coordinator.replace_conflict_lineage_current_keys(previous_lineage_keys); + coordinator.replace_conflicted_candidate_ids(previous_conflicted_ids); + return Err(error); + } + }; if authoritative.conflict_choices != updated_choices || authoritative.conflict_lineage_current_keys != updated_lineage_keys || authoritative.conflicted_candidate_ids != updated_conflicted_ids @@ -1354,6 +2259,7 @@ impl WorkspaceExternalSourceService { approval_key: &str, decision_key: &str, approved: bool, + expected_preference_revision: u64, ) -> Result { // Keep preview validation, preference persistence and the resulting // product rebuild in the same ordering domain as watcher refreshes. @@ -1365,18 +2271,41 @@ impl WorkspaceExternalSourceService { #[cfg(test)] self.tool_decision_gate_acquired.notify_one(); let snapshot = self.snapshot(); - let known = snapshot.tool_approval_requests.iter().any(|request| { - request.approval_key == approval_key && request.decision_key == decision_key - }) || snapshot - .tools + if snapshot.preference_revision != expected_preference_revision { + return Err(stale_operation_error( + "External tool preferences changed; refresh before retrying", + )); + } + let source_key = snapshot + .tool_approval_requests .iter() - .any(|tool| tool.approval_key == approval_key && tool.decision_key == decision_key); - if !known { - return Err("external tool decision is stale or unknown".to_string()); + .find(|request| { + request.approval_key == approval_key && request.decision_key == decision_key + }) + .map(|request| request.target_id.source.clone()) + .or_else(|| { + snapshot + .tools + .iter() + .find(|tool| { + tool.approval_key == approval_key && tool.decision_key == decision_key + }) + .map(|tool| tool.definition.id.target.source.clone()) + }) + .ok_or_else(|| { + missing_candidate_error("External tool decision is stale or no longer available") + })?; + if approved { + ensure_source_capability_active(&snapshot, &source_key, EXTERNAL_CAPABILITY_TOOL)?; } validate_conflict_preference(approval_key, decision_key)?; - let preferences = - persist_tool_target_decision(approval_key, decision_key, approved).await?; + let preferences = persist_tool_target_decision( + approval_key, + decision_key, + approved, + expected_preference_revision, + ) + .await?; propagate_tool_preferences(&preferences); let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); self.rebuild_product_snapshot(command_snapshot).await @@ -1386,19 +2315,40 @@ impl WorkspaceExternalSourceService { &self, conflict_key: &str, candidate_id: &str, + expected_preference_revision: u64, ) -> Result { - let known = self.snapshot().tool_conflicts.iter().any(|conflict| { - conflict.conflict_key == conflict_key - && conflict + let _refresh_guard = self.refresh_gate.lock().await; + let snapshot = self.snapshot(); + if snapshot.preference_revision != expected_preference_revision { + return Err(stale_operation_error( + "External tool preferences changed; refresh before retrying", + )); + } + let candidate = snapshot + .tool_conflicts + .iter() + .find(|conflict| conflict.conflict_key == conflict_key) + .and_then(|conflict| { + conflict .candidates .iter() - .any(|candidate| candidate.candidate_id == candidate_id) - }); - if !known { - return Err("external tool conflict choice is stale or unknown".to_string()); + .find(|candidate| candidate.candidate_id == candidate_id) + }) + .ok_or_else(|| { + missing_candidate_error( + "External tool conflict choice is stale or no longer available", + ) + })?; + if matches!(candidate.kind, ExternalToolConflictCandidateKind::External) { + let source_key = candidate.source.as_ref().ok_or_else(|| { + missing_candidate_error("External tool conflict source is missing") + })?; + ensure_source_capability_active(&snapshot, source_key, EXTERNAL_CAPABILITY_TOOL)?; } validate_conflict_preference(conflict_key, candidate_id)?; - let preferences = persist_tool_conflict_choice(conflict_key, candidate_id).await?; + let preferences = + persist_tool_conflict_choice(conflict_key, candidate_id, expected_preference_revision) + .await?; propagate_tool_preferences(&preferences); let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); self.rebuild_product_snapshot(command_snapshot).await @@ -1417,31 +2367,34 @@ impl WorkspaceExternalSourceService { if snapshot.mcp_generation != expected_mcp_generation || snapshot.preference_revision != expected_preference_revision { - return Err( - "stale_action: external MCP catalog changed; refresh before retrying".to_string(), - ); + return Err(stale_operation_error( + "External MCP catalog changed; refresh before retrying", + )); } let entry = snapshot .mcp_servers .iter() .find(|entry| entry.candidate_id == candidate_id && entry.decision_key == decision_key) .ok_or_else(|| { - "candidate_unavailable: external MCP candidate is no longer available".to_string() + missing_candidate_error("External MCP candidate is no longer available") })?; + if approved { + ensure_source_capability_active( + &snapshot, + &entry.definition.id.source, + EXTERNAL_CAPABILITY_MCP, + )?; + } if !external_mcp_decision_allowed(&entry.activation_state, approved) { - return Err( - "unsupported_definition: external MCP candidate cannot be changed in its current state" - .to_string(), - ); + return Err(unavailable_operation_error( + "External MCP candidate cannot be changed in its current state", + )); } validate_mcp_decision_value(candidate_id, "candidate id")?; validate_mcp_decision_value(decision_key, "decision key")?; - let preferences = persist_mcp_server_decision( - decision_key, - approved, - expected_preference_revision, - ) - .await?; + let preferences = + persist_mcp_server_decision(decision_key, approved, expected_preference_revision) + .await?; propagate_mcp_preferences(&preferences); let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); self.rebuild_product_snapshot(command_snapshot).await @@ -1460,38 +2413,40 @@ impl WorkspaceExternalSourceService { if snapshot.mcp_generation != expected_mcp_generation || snapshot.preference_revision != expected_preference_revision { - return Err( - "stale_action: external MCP catalog changed; refresh before retrying".to_string(), - ); + return Err(stale_operation_error( + "External MCP catalog changed; refresh before retrying", + )); } let conflict = snapshot .mcp_conflicts .iter() .find(|conflict| conflict.conflict_key == conflict_key) .ok_or_else(|| { - "conflict_choice_required: external MCP conflict is stale or unknown".to_string() + conflict_operation_error("External MCP conflict is stale or no longer available") })?; let candidate = conflict .candidates .iter() .find(|candidate| candidate.candidate_id == candidate_id && candidate.available) .ok_or_else(|| { - "candidate_unavailable: external MCP conflict candidate is unavailable".to_string() + missing_candidate_error("External MCP conflict candidate is unavailable") })?; let external_decision = if candidate.external { + let source_key = candidate.source.as_ref().ok_or_else(|| { + missing_candidate_error("External MCP conflict source is missing") + })?; + ensure_source_capability_active(&snapshot, source_key, EXTERNAL_CAPABILITY_MCP)?; if !approve_external { - return Err( - "approval_required: selecting an external MCP server must atomically approve its current behavior" - .to_string(), - ); + return Err(policy_limited_error( + "Selecting an external MCP server requires approval of its current behavior", + )); } let entry = snapshot .mcp_servers .iter() .find(|entry| entry.candidate_id == candidate_id) .ok_or_else(|| { - "candidate_unavailable: external MCP candidate is no longer available" - .to_string() + missing_candidate_error("External MCP candidate is no longer available") })?; Some(entry.decision_key.as_str()) } else { @@ -1524,10 +2479,9 @@ impl WorkspaceExternalSourceService { if snapshot.subagent_generation != expected_subagent_generation || snapshot.preference_revision != expected_preference_revision { - return Err( - "stale_action: external subagent catalog changed; refresh before retrying" - .to_string(), - ); + return Err(stale_operation_error( + "External subagent catalog changed; refresh before retrying", + )); } let summary = snapshot .subagents @@ -1536,19 +2490,24 @@ impl WorkspaceExternalSourceService { summary.candidate_id == candidate_id && summary.decision_key == decision_key }) .ok_or_else(|| { - "candidate_unavailable: external subagent candidate is no longer available" - .to_string() + missing_candidate_error("External subagent candidate is no longer available") })?; + if approved { + ensure_source_set_capability_active( + &snapshot, + &summary.source_keys, + EXTERNAL_CAPABILITY_SUBAGENT, + )?; + } if matches!( summary.activation_state, ExternalSubagentActivationState::Blocked | ExternalSubagentActivationState::Unavailable | ExternalSubagentActivationState::Conflict ) { - return Err( - "unsupported_definition: external subagent cannot be activated in its current state" - .to_string(), - ); + return Err(unavailable_operation_error( + "External subagent cannot be activated in its current state", + )); } validate_subagent_decision_value(candidate_id, "candidate id")?; validate_subagent_decision_value(decision_key, "decision key")?; @@ -1573,18 +2532,18 @@ impl WorkspaceExternalSourceService { if snapshot.subagent_generation != expected_subagent_generation || snapshot.preference_revision != expected_preference_revision { - return Err( - "stale_action: external subagent catalog changed; refresh before retrying" - .to_string(), - ); + return Err(stale_operation_error( + "External subagent catalog changed; refresh before retrying", + )); } let conflict = snapshot .subagent_conflicts .iter() .find(|conflict| conflict.conflict_key == conflict_key) .ok_or_else(|| { - "conflict_choice_required: external subagent conflict is stale or unknown" - .to_string() + conflict_operation_error( + "External subagent conflict is stale or no longer available", + ) })?; let external = if candidate_id == DISABLED_SUBAGENT_CONFLICT_CHOICE { false @@ -1595,7 +2554,7 @@ impl WorkspaceExternalSourceService { .find(|candidate| candidate.candidate_id == candidate_id) .map(|candidate| candidate.external) .ok_or_else(|| { - "candidate_unavailable: conflict candidate is no longer available".to_string() + missing_candidate_error("Conflict candidate is no longer available") })? }; let approval_key = if external { @@ -1604,14 +2563,17 @@ impl WorkspaceExternalSourceService { .iter() .find(|summary| summary.candidate_id == candidate_id) .ok_or_else(|| { - "candidate_unavailable: external subagent candidate is no longer available" - .to_string() + missing_candidate_error("External subagent candidate is no longer available") })?; + ensure_source_set_capability_active( + &snapshot, + &summary.source_keys, + EXTERNAL_CAPABILITY_SUBAGENT, + )?; if !approve_external { - return Err( - "approval_required: selecting an external subagent must atomically approve its current capability envelope" - .to_string(), - ); + return Err(policy_limited_error( + "Selecting an external subagent requires approval of its current capability envelope", + )); } Some(summary.decision_key.clone()) } else { @@ -1640,7 +2602,16 @@ impl WorkspaceExternalSourceService { ) -> Result { // Explicit invocation refreshes first, so a stable deletion cannot be // bypassed by an old menu projection. - self.refresh_preserving_worker_recovery().await?; + let snapshot = self.refresh_preserving_worker_recovery().await?; + let source_key = snapshot + .commands + .iter() + .find(|entry| entry.definition.name.eq_ignore_ascii_case(name)) + .map(|entry| entry.definition.id.source.clone()) + .ok_or_else(|| { + missing_candidate_error(format!("External prompt command '{name}' was not found")) + })?; + ensure_source_capability_active(&snapshot, &source_key, EXTERNAL_CAPABILITY_COMMAND)?; let coordinator = Arc::clone(&self.coordinator); let name = name.to_string(); let arguments = arguments.to_string(); @@ -1661,11 +2632,12 @@ impl WorkspaceExternalSourceService { } async fn start_watching(self: &Arc) { - let watch_roots = self.watch_roots(); + let policy = self.snapshot().integration_policy; + let watch_roots = self.watch_roots(&policy); if watch_roots.is_empty() { return; } - self.ensure_watch_roots().await; + self.ensure_watch_roots(&policy).await; let mut receiver = self.watcher.subscribe(); let weak: Weak = Arc::downgrade(self); tokio::spawn(async move { @@ -1684,7 +2656,8 @@ impl WorkspaceExternalSourceService { let Some(service) = weak.upgrade() else { break; }; - let watch_roots = service.watch_roots(); + let policy = service.snapshot().integration_policy; + let watch_roots = service.watch_roots(&policy); let relevant = events.iter().any(|event| { let path = Path::new(&event.path); watch_roots.iter().any(|root| path.starts_with(&root.path)) @@ -1694,13 +2667,9 @@ impl WorkspaceExternalSourceService { } if let Err(error) = service.refresh().await { log::warn!( - "External source background refresh failed for '{}': {}", - service - .workspace_root - .as_deref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "user-global".to_string()), - error + "External source background refresh failed scope={} error_category={}", + external_log_scope(service.workspace_root.as_deref()), + external_log_error_category(&error), ); } } @@ -1731,23 +2700,42 @@ impl WorkspaceExternalSourceService { let _ = service.updates.send(snapshot); } Err(error) => log::warn!( - "External source model-binding refresh failed for '{}': {}", - service - .workspace_root - .as_deref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "user-global".to_string()), - error + "External source model-binding refresh failed scope={} error_category={}", + external_log_scope(service.workspace_root.as_deref()), + external_log_error_category(&error), ), } } }); } - async fn ensure_watch_roots(&self) { - let watch_roots = self.watch_roots(); + async fn ensure_watch_roots(&self, policy: &ExternalIntegrationPolicySnapshot) { + let watch_roots = self.watch_roots(policy); let watcher = Arc::clone(&self.watcher); let mut states = self.watch_states.lock().await; + let desired = watch_roots + .iter() + .map(|root| (root.path.clone(), root.recursive)) + .collect::>(); + let obsolete = states + .keys() + .filter(|key| !desired.contains(*key)) + .cloned() + .collect::>(); + for key in obsolete { + if states.get(&key).copied().unwrap_or(false) { + let path = key.0.to_string_lossy().to_string(); + if let Err(error) = watcher.unwatch_path(&path).await { + log::warn!( + "Failed to stop watching external source root scope={} recursive={} error_category={}", + external_log_scope(self.workspace_root.as_deref()), + key.1, + external_log_error_category(&error.to_string()), + ); + } + } + states.remove(&key); + } for root in watch_roots { let key = (root.path.clone(), root.recursive); let exists = root.path.exists(); @@ -1770,21 +2758,46 @@ impl WorkspaceExternalSourceService { } Err(error) => { states.insert(key, false); - log::warn!("Failed to watch external source root '{}': {}", path, error); + log::warn!( + "Failed to watch external source root scope={} recursive={} error_category={}", + external_log_scope(self.workspace_root.as_deref()), + root.recursive, + external_log_error_category(&error.to_string()), + ); } } } } - fn watch_roots(&self) -> Vec { + fn watch_roots( + &self, + policy: &ExternalIntegrationPolicySnapshot, + ) -> Vec { let mut roots = BTreeMap::new(); - for root in lock_coordinator(&self.coordinator) - .watch_roots() - .into_iter() - .chain(lock_tool_coordinator(&self.tool_coordinator).watch_roots()) - .chain(lock_subagent_coordinator(&self.subagent_coordinator).watch_roots()) - .chain(lock_mcp_coordinator(&self.mcp_coordinator).watch_roots()) - { + let mut provider_roots = Vec::new(); + let command_ecosystems = + ecosystems_with_discoverable_capability(policy, EXTERNAL_CAPABILITY_COMMAND); + provider_roots.extend( + lock_coordinator(&self.coordinator).watch_roots_for_ecosystems(&command_ecosystems), + ); + let tool_ecosystems = + ecosystems_with_discoverable_capability(policy, EXTERNAL_CAPABILITY_TOOL); + provider_roots.extend( + lock_tool_coordinator(&self.tool_coordinator) + .watch_roots_for_ecosystems(&tool_ecosystems), + ); + let subagent_ecosystems = + ecosystems_with_discoverable_capability(policy, EXTERNAL_CAPABILITY_SUBAGENT); + provider_roots.extend( + lock_subagent_coordinator(&self.subagent_coordinator) + .watch_roots_for_ecosystems(&subagent_ecosystems), + ); + let mcp_ecosystems = + ecosystems_with_discoverable_capability(policy, EXTERNAL_CAPABILITY_MCP); + provider_roots.extend( + lock_mcp_coordinator(&self.mcp_coordinator).watch_roots_for_ecosystems(&mcp_ecosystems), + ); + for root in provider_roots { roots .entry(root.path) .and_modify(|recursive| *recursive |= root.recursive) @@ -2108,6 +3121,9 @@ fn lock_snapshot( static WORKSPACE_SERVICES: OnceLock< DashMap, Weak>, > = OnceLock::new(); +static READ_ONLY_WORKSPACE_SERVICES: OnceLock< + DashMap, Weak>, +> = OnceLock::new(); static TOOL_REGISTRY_CHANGE_EPOCH: AtomicU64 = AtomicU64::new(0); static TOOL_REGISTRY_REBUILD_SCHEDULED: AtomicBool = AtomicBool::new(false); @@ -2115,6 +3131,20 @@ fn workspace_services() -> &'static DashMap, Weak &'static DashMap, Weak> { + READ_ONLY_WORKSPACE_SERVICES.get_or_init(DashMap::new) +} + +fn workspace_services_for_profile( + profile: ExternalSourceServiceProfile, +) -> &'static DashMap, Weak> { + match profile { + ExternalSourceServiceProfile::LocalExecution => workspace_services(), + ExternalSourceServiceProfile::ReadOnlyProjection => read_only_workspace_services(), + } +} + fn workspace_service_gate() -> &'static tokio::sync::Mutex<()> { static GATE: OnceLock> = OnceLock::new(); GATE.get_or_init(|| tokio::sync::Mutex::new(())) @@ -2495,13 +3525,31 @@ fn merge_mcp_state( async fn service_for( workspace_root: Option<&Path>, +) -> Result, String> { + service_for_profile(workspace_root, ExternalSourceServiceProfile::LocalExecution).await +} + +async fn read_only_service_for( + workspace_root: Option<&Path>, +) -> Result, String> { + service_for_profile( + workspace_root, + ExternalSourceServiceProfile::ReadOnlyProjection, + ) + .await +} + +async fn service_for_profile( + workspace_root: Option<&Path>, + profile: ExternalSourceServiceProfile, ) -> Result, String> { let workspace_root = normalize_workspace_root(workspace_root)?; // Serialize cache acquisition with idle retirement. Without this lease // gate, a caller could upgrade the weak entry after the retirement count // check and have its newly acquired routes removed underneath it. let _service_gate = workspace_service_gate().lock().await; - if let Some(service) = workspace_services() + let services = workspace_services_for_profile(profile); + if let Some(service) = services .get(&workspace_root) .and_then(|service| service.value().upgrade()) { @@ -2509,8 +3557,8 @@ async fn service_for( sync_service_preferences(&service).await?; return Ok(service); } - let created = WorkspaceExternalSourceService::create(workspace_root.clone()).await?; - let service = match workspace_services().entry(workspace_root) { + let created = WorkspaceExternalSourceService::create(workspace_root.clone(), profile).await?; + let service = match services.entry(workspace_root) { Entry::Occupied(mut entry) => match entry.get().upgrade() { Some(existing) => existing, None => { @@ -2541,23 +3589,40 @@ async fn read_external_sources_config() -> Result } pub(crate) async fn external_tool_invocation_is_authorized( + ecosystem_id: &str, approval_key: &str, source_key: &str, + workspace_route: &str, ) -> Result { let preferences = read_external_sources_config().await?; Ok(external_tool_invocation_is_authorized_by( &preferences, + ecosystem_id, approval_key, source_key, + workspace_route, )) } fn external_tool_invocation_is_authorized_by( preferences: &ExternalSourcesConfig, + ecosystem_id: &str, approval_key: &str, source_preference_key: &str, + workspace_route: &str, ) -> bool { - preferences.approved_tool_targets.contains(approval_key) + let policy = preferences.integration_policy.known().map(|document| { + external_integration_policy_snapshot( + document, + workspace_policy_key_from_route(workspace_route).as_deref(), + default_external_integration_ecosystems(), + ) + }); + policy.is_some_and(|policy| { + policy.is_ok_and(|policy| { + integration_capability_is_active(&policy, ecosystem_id, EXTERNAL_CAPABILITY_TOOL) + }) + }) && preferences.approved_tool_targets.contains(approval_key) && !preferences .suppressed_source_keys .iter() @@ -2587,12 +3652,16 @@ async fn persist_observed_tool_conflicts(conflicts: &[ExternalToolConflict]) -> let conflicts = conflicts.to_vec(); ExternalSourcePreferenceStore::global()? .update(move |config| { + let previous = config.tool_conflict_choices.clone(); for conflict in conflicts { reconcile_observed_tool_conflict( &mut config.tool_conflict_choices, &conflict.conflict_key, ); } + if config.tool_conflict_choices != previous { + config.preference_revision = config.preference_revision.saturating_add(1); + } }) .await .map(|_| ()) @@ -2715,10 +3784,14 @@ fn reconcile_observed_tool_conflict(choices: &mut BTreeMap, conf async fn persist_source_enabled_change( stable_key: &str, enabled: bool, + expected_preference_revision: u64, ) -> Result, String> { let stable_key = stable_key.to_string(); ExternalSourcePreferenceStore::global()? .update(move |config| { + if config.preference_revision != expected_preference_revision { + return None; + } let mut sources = config .suppressed_source_keys .iter() @@ -2729,22 +3802,39 @@ async fn persist_source_enabled_change( } else { sources.insert(stable_key); } - config.suppressed_source_keys = sources.iter().cloned().collect(); - sources + let next = sources.iter().cloned().collect::>(); + if config.suppressed_source_keys != next { + config.suppressed_source_keys = next; + config.preference_revision = config.preference_revision.saturating_add(1); + } + Some(sources) }) .await - .map(|(sources, _)| sources) + .and_then(|(sources, _)| { + sources.ok_or_else(|| { + stale_operation_error( + "External source preferences changed; refresh before retrying", + ) + }) + }) } async fn persist_conflict_choice( conflict_key: &str, candidate_id: &str, participants: Vec, + expected_preference_revision: u64, ) -> Result { let conflict_key = conflict_key.to_string(); let candidate_id = candidate_id.to_string(); ExternalSourcePreferenceStore::global()? .update(move |config| { + if config.preference_revision != expected_preference_revision { + return false; + } + let previous_choices = config.conflict_choices.clone(); + let previous_lineage = config.conflict_lineage_current_keys.clone(); + let previous_candidates = config.conflicted_candidate_ids.clone(); ExternalSourceCoordinator::reconcile_conflict_preferences( &mut config.conflict_choices, &mut config.conflict_lineage_current_keys, @@ -2753,24 +3843,53 @@ async fn persist_conflict_choice( &candidate_id, &participants, ); + if config.conflict_choices != previous_choices + || config.conflict_lineage_current_keys != previous_lineage + || config.conflicted_candidate_ids != previous_candidates + { + config.preference_revision = config.preference_revision.saturating_add(1); + } + true }) .await - .map(|(_, config)| config) + .and_then(|(applied, config)| { + applied.then_some(config).ok_or_else(|| { + stale_operation_error( + "External command preferences changed; refresh before retrying", + ) + }) + }) } async fn persist_tool_target_decision( approval_key: &str, decision_key: &str, approved: bool, + expected_preference_revision: u64, ) -> Result { let approval_key = approval_key.to_string(); let decision_key = decision_key.to_string(); ExternalSourcePreferenceStore::global()? .update(move |config| { + if config.preference_revision != expected_preference_revision { + return false; + } + let previous_approved = config.approved_tool_targets.clone(); + let previous_declined = config.declined_tool_decisions.clone(); reconcile_tool_target_decision(config, approval_key, decision_key, approved); + if config.approved_tool_targets != previous_approved + || config.declined_tool_decisions != previous_declined + { + config.preference_revision = config.preference_revision.saturating_add(1); + } + true }) .await - .map(|(_, config)| config) + .and_then(|(applied, config)| { + applied.then_some(config).ok_or_else(|| { + stale_operation_error("External tool preferences changed; refresh before retrying") + }) + }) } fn reconcile_tool_target_decision( @@ -2793,19 +3912,32 @@ fn reconcile_tool_target_decision( async fn persist_tool_conflict_choice( conflict_key: &str, candidate_id: &str, + expected_preference_revision: u64, ) -> Result { let conflict_key = conflict_key.to_string(); let candidate_id = candidate_id.to_string(); ExternalSourcePreferenceStore::global()? .update(move |config| { + if config.preference_revision != expected_preference_revision { + return false; + } + let previous = config.tool_conflict_choices.clone(); reconcile_versioned_tool_conflict_choice( &mut config.tool_conflict_choices, conflict_key, candidate_id, ); + if config.tool_conflict_choices != previous { + config.preference_revision = config.preference_revision.saturating_add(1); + } + true }) .await - .map(|(_, config)| config) + .and_then(|(applied, config)| { + applied.then_some(config).ok_or_else(|| { + stale_operation_error("External tool preferences changed; refresh before retrying") + }) + }) } async fn persist_subagent_activation( @@ -2836,8 +3968,9 @@ async fn persist_subagent_activation( .await .and_then(|(applied, config)| { applied.then_some(config).ok_or_else(|| { - "stale_action: external subagent preferences changed; refresh before retrying" - .to_string() + stale_operation_error( + "External subagent preferences changed; refresh before retrying", + ) }) }) } @@ -2889,8 +4022,9 @@ async fn persist_subagent_conflict_choice_with_store( .await .and_then(|(applied, config)| { applied.then_some(config).ok_or_else(|| { - "stale_action: external subagent preferences changed; refresh before retrying" - .to_string() + stale_operation_error( + "External subagent preferences changed; refresh before retrying", + ) }) }) } @@ -2918,8 +4052,7 @@ async fn persist_mcp_server_decision( .await .and_then(|(applied, config)| { applied.then_some(config).ok_or_else(|| { - "stale_action: external MCP preferences changed; refresh before retrying" - .to_string() + stale_operation_error("External MCP preferences changed; refresh before retrying") }) }) } @@ -2957,10 +4090,252 @@ async fn persist_mcp_conflict_choice( .await .and_then(|(applied, config)| { applied.then_some(config).ok_or_else(|| { - "stale_action: external MCP preferences changed; refresh before retrying" - .to_string() + stale_operation_error("External MCP preferences changed; refresh before retrying") + }) + }) +} + +fn validate_integration_policy_operation( + scope: ExternalIntegrationPolicyScope, + operation: &ExternalIntegrationPolicyOperation, +) -> Result<(), String> { + let descriptors = default_external_integration_ecosystems(); + let validate_ecosystem = + |ecosystem_id: &bitfun_product_domains::external_sources::EcosystemId| { + descriptors + .iter() + .find(|descriptor| descriptor.ecosystem_id == *ecosystem_id) + .ok_or_else(|| { + invalid_operation_error(format!( + "External ecosystem '{}' is not registered", + ecosystem_id + )) + }) + }; + match operation { + ExternalIntegrationPolicyOperation::SetEnabled { .. } => Ok(()), + ExternalIntegrationPolicyOperation::SetEcosystemMode { ecosystem_id, mode } => { + validate_ecosystem(ecosystem_id)?; + mode.is_known().then_some(()).ok_or_else(|| { + invalid_operation_error("External integration mode is not supported") }) + } + ExternalIntegrationPolicyOperation::SetCapabilityAccess { + ecosystem_id, + capability_id, + access, + } => { + let descriptor = validate_ecosystem(ecosystem_id)?; + if !descriptor + .capabilities + .iter() + .any(|capability| capability.capability_id == *capability_id) + || !access.is_known() + { + return Err(invalid_operation_error(format!( + "External capability '{}' is not registered", + capability_id + ))); + } + Ok(()) + } + ExternalIntegrationPolicyOperation::ResetWorkspace + if scope == ExternalIntegrationPolicyScope::Workspace => + { + Ok(()) + } + ExternalIntegrationPolicyOperation::ResetWorkspace => Err(invalid_operation_error( + "reset_workspace requires workspace policy scope", + )), + ExternalIntegrationPolicyOperation::ResetIncompatiblePolicy + if scope == ExternalIntegrationPolicyScope::User => + { + Ok(()) + } + ExternalIntegrationPolicyOperation::ResetIncompatiblePolicy => Err( + invalid_operation_error("reset_incompatible_policy requires user policy scope"), + ), + _ => Err(invalid_operation_error("Policy operation is not supported")), + } +} + +fn apply_user_policy_operation( + settings: &mut ExternalIntegrationPolicySettings, + operation: &ExternalIntegrationPolicyOperation, +) -> Result { + match operation { + ExternalIntegrationPolicyOperation::SetEnabled { enabled } => { + let changed = settings.enabled != *enabled; + settings.enabled = *enabled; + Ok(changed) + } + ExternalIntegrationPolicyOperation::SetEcosystemMode { ecosystem_id, mode } => { + let policy = settings.ecosystems.entry(ecosystem_id.clone()).or_default(); + let changed = policy.mode != *mode; + policy.mode = mode.clone(); + Ok(changed) + } + ExternalIntegrationPolicyOperation::SetCapabilityAccess { + ecosystem_id, + capability_id, + access, + } => { + let policy = settings.ecosystems.entry(ecosystem_id.clone()).or_default(); + let changed = policy.mode != ExternalIntegrationMode::Custom + || policy.capability_overrides.get(capability_id) != Some(access); + policy.mode = ExternalIntegrationMode::Custom; + policy + .capability_overrides + .insert(capability_id.clone(), access.clone()); + Ok(changed) + } + ExternalIntegrationPolicyOperation::ResetWorkspace => Err(invalid_operation_error( + "reset_workspace cannot update user defaults", + )), + _ => Err(invalid_operation_error("Policy operation is not supported")), + } +} + +fn apply_workspace_policy_operation( + document: &mut ExternalIntegrationPolicyDocument, + workspace_key: &str, + operation: &ExternalIntegrationPolicyOperation, +) -> Result { + if matches!( + operation, + ExternalIntegrationPolicyOperation::ResetWorkspace + ) { + return Ok(document.workspace_overrides.remove(workspace_key).is_some()); + } + let policy = document + .workspace_overrides + .entry(workspace_key.to_string()) + .or_default(); + match operation { + ExternalIntegrationPolicyOperation::SetEnabled { enabled } => { + let changed = policy.enabled != Some(*enabled); + policy.enabled = Some(*enabled); + Ok(changed) + } + ExternalIntegrationPolicyOperation::SetEcosystemMode { ecosystem_id, mode } => { + let ecosystem = policy.ecosystems.entry(ecosystem_id.clone()).or_default(); + let changed = ecosystem.mode.as_ref() != Some(mode); + ecosystem.mode = Some(mode.clone()); + Ok(changed) + } + ExternalIntegrationPolicyOperation::SetCapabilityAccess { + ecosystem_id, + capability_id, + access, + } => { + let ecosystem = policy.ecosystems.entry(ecosystem_id.clone()).or_default(); + let changed = ecosystem.mode != Some(ExternalIntegrationMode::Custom) + || ecosystem.capability_overrides.get(capability_id) != Some(access); + ecosystem.mode = Some(ExternalIntegrationMode::Custom); + ecosystem + .capability_overrides + .insert(capability_id.clone(), access.clone()); + Ok(changed) + } + ExternalIntegrationPolicyOperation::ResetWorkspace => Ok(false), + _ => Err(invalid_operation_error("Policy operation is not supported")), + } +} + +async fn persist_integration_policy_mutation( + workspace_root: Option<&Path>, + mutation: ExternalIntegrationPolicyMutation, +) -> Result { + validate_integration_policy_operation(mutation.scope, &mutation.change)?; + let workspace_key = match mutation.scope { + ExternalIntegrationPolicyScope::User => None, + ExternalIntegrationPolicyScope::Workspace => { + Some(workspace_policy_key(workspace_root).ok_or_else(|| { + invalid_operation_error("Workspace policy scope requires a workspace") + })?) + } + _ => { + return Err(invalid_operation_error("Policy scope is not supported")); + } + }; + ExternalSourcePreferenceStore::global()? + .update(move |config| { + apply_integration_policy_mutation_to_config(config, workspace_key.as_deref(), &mutation) + .map(|_| ()) }) + .await + .and_then(|(result, config)| result.map(|()| config)) +} + +fn apply_integration_policy_mutation_to_config( + config: &mut ExternalSourcesConfig, + workspace_key: Option<&str>, + mutation: &ExternalIntegrationPolicyMutation, +) -> Result { + if config.preference_revision != mutation.expected_preference_revision { + return Err(stale_operation_error( + "External integration policy changed; refresh before retrying", + )); + } + let incompatible = config.integration_policy.known().is_none(); + if incompatible { + if mutation.scope == ExternalIntegrationPolicyScope::User + && matches!( + &mutation.change, + ExternalIntegrationPolicyOperation::ResetIncompatiblePolicy + ) + { + config + .integration_policy_backups + .push(config.integration_policy.raw_value()); + const MAX_POLICY_BACKUPS: usize = 3; + if config.integration_policy_backups.len() > MAX_POLICY_BACKUPS { + let remove_count = config.integration_policy_backups.len() - MAX_POLICY_BACKUPS; + config.integration_policy_backups.drain(0..remove_count); + } + let mut reset_policy = StoredExternalIntegrationPolicy::default(); + reset_policy + .known_mut() + .expect("the host-owned default policy schema must be compatible") + .user_defaults + .enabled = false; + config.integration_policy = reset_policy; + config.preference_revision = config.preference_revision.saturating_add(1); + return Ok(true); + } + return Err(incompatible_policy_error(format!( + "External integration policy schema {} is not supported; back up and reset it before making changes", + config.integration_policy.schema_major() + ))); + } + if matches!( + &mutation.change, + ExternalIntegrationPolicyOperation::ResetIncompatiblePolicy + ) { + return Err(invalid_operation_error( + "External integration policy is already compatible", + )); + } + let document = config.integration_policy.known_mut().ok_or_else(|| { + incompatible_policy_error("External integration policy requires a backup and reset") + })?; + let changed = match mutation.scope { + ExternalIntegrationPolicyScope::User => { + apply_user_policy_operation(&mut document.user_defaults, &mutation.change)? + } + ExternalIntegrationPolicyScope::Workspace => apply_workspace_policy_operation( + document, + workspace_key.ok_or_else(|| { + invalid_operation_error("Workspace policy scope requires a workspace") + })?, + &mutation.change, + )?, + _ => return Err(invalid_operation_error("Policy scope is not supported")), + }; + if changed { + config.preference_revision = config.preference_revision.saturating_add(1); + } + Ok(changed) } fn reconcile_versioned_mcp_conflict_choice( @@ -3034,7 +4409,9 @@ fn propagate_suppressed_sources( tokio::spawn(async move { if let Err(error) = service.refresh_preserving_worker_recovery().await { log::warn!( - "Could not refresh external sources after source preference change: {error}" + "Could not refresh external sources after source preference change scope={} error_category={}", + external_log_scope(service.workspace_root.as_deref()), + external_log_error_category(&error), ); } }); @@ -3106,6 +4483,29 @@ fn propagate_mcp_preferences(_preferences: &ExternalSourcesConfig) { } } +fn propagate_integration_policy_preferences( + _preferences: &ExternalSourcesConfig, + current: &Arc, +) { + for service in workspace_services().iter() { + let Some(service) = service.value().upgrade() else { + continue; + }; + if Arc::ptr_eq(&service, current) { + continue; + } + tokio::spawn(async move { + if let Err(error) = service.refresh_preserving_worker_recovery().await { + log::warn!( + "Could not apply external integration policy update scope={} error_category={}", + external_log_scope(service.workspace_root.as_deref()), + external_log_error_category(&error), + ); + } + }); + } +} + pub(crate) fn notify_external_tool_registry_changed() { TOOL_REGISTRY_CHANGE_EPOCH.fetch_add(1, Ordering::AcqRel); if TOOL_REGISTRY_REBUILD_SCHEDULED.swap(true, Ordering::AcqRel) { @@ -3144,6 +4544,7 @@ pub(crate) fn notify_external_tool_registry_changed() { async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> Result<(), String> { let preferences = read_external_sources_config().await?; + let policy = integration_policy_snapshot(&preferences, service.workspace_root.as_deref())?; let suppressed_sources = preferences .suppressed_source_keys .iter() @@ -3203,41 +4604,50 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R }; let subagent_preferences_changed = service.snapshot().preference_revision != preferences.preference_revision; + let policy_changed = service.snapshot().integration_policy != policy; if command_changed || tool_changed || subagent_changed || mcp_changed || subagent_preferences_changed + || policy_changed { let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); let snapshot = service.rebuild_product_snapshot(command_snapshot).await?; let _ = service.updates.send(snapshot); } + service.ensure_watch_roots(&policy).await; Ok(()) } fn validate_conflict_preference(conflict_key: &str, candidate_id: &str) -> Result<(), String> { if conflict_key.is_empty() || conflict_key.len() > 512 { - return Err("external source conflict key is invalid".to_string()); + return Err(invalid_operation_error( + "External source conflict key is invalid", + )); } if candidate_id.is_empty() || candidate_id.len() > 512 { - return Err("external source conflict candidate is invalid".to_string()); + return Err(invalid_operation_error( + "External source conflict candidate is invalid", + )); } Ok(()) } fn validate_subagent_decision_value(value: &str, label: &str) -> Result<(), String> { if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) { - return Err(format!( - "invalid_request: external subagent {label} is invalid" - )); + return Err(invalid_operation_error(format!( + "External subagent {label} is invalid" + ))); } Ok(()) } fn validate_mcp_decision_value(value: &str, label: &str) -> Result<(), String> { if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) { - return Err(format!("invalid_request: external MCP {label} is invalid")); + return Err(invalid_operation_error(format!( + "External MCP {label} is invalid" + ))); } Ok(()) } @@ -3284,11 +4694,13 @@ pub async fn remember_external_source_conflict_choice( conflict_key: &str, candidate_id: &str, participants: Vec, + expected_preference_revision: u64, ) -> Result< ( BTreeMap, BTreeMap, BTreeSet, + u64, ), String, > { @@ -3301,14 +4713,23 @@ pub async fn remember_external_source_conflict_choice( .iter() .any(|candidate| validate_conflict_preference(conflict_key, candidate).is_err()) { - return Err("external source conflict participants are invalid".to_string()); + return Err(invalid_operation_error( + "External source conflict participants are invalid", + )); } - let preferences = persist_conflict_choice(conflict_key, candidate_id, participants).await?; + let preferences = persist_conflict_choice( + conflict_key, + candidate_id, + participants, + expected_preference_revision, + ) + .await?; propagate_conflict_preferences(&preferences); Ok(( preferences.conflict_choices, preferences.conflict_lineage_current_keys, preferences.conflicted_candidate_ids, + preferences.preference_revision, )) } @@ -3316,11 +4737,12 @@ pub async fn set_external_prompt_command_conflict_choice( workspace_root: Option<&Path>, conflict_key: &str, candidate_id: &str, + expected_preference_revision: u64, ) -> Result { validate_conflict_preference(conflict_key, candidate_id)?; service_for(workspace_root) .await? - .set_conflict_choice(conflict_key, candidate_id) + .set_conflict_choice(conflict_key, candidate_id, expected_preference_revision) .await } @@ -3329,10 +4751,16 @@ pub async fn set_external_tool_target_decision( approval_key: &str, decision_key: &str, approved: bool, + expected_preference_revision: u64, ) -> Result { service_for(workspace_root) .await? - .set_tool_target_decision(approval_key, decision_key, approved) + .set_tool_target_decision( + approval_key, + decision_key, + approved, + expected_preference_revision, + ) .await } @@ -3340,10 +4768,11 @@ pub async fn set_external_tool_conflict_choice( workspace_root: Option<&Path>, conflict_key: &str, candidate_id: &str, + expected_preference_revision: u64, ) -> Result { service_for(workspace_root) .await? - .set_tool_conflict_choice(conflict_key, candidate_id) + .set_tool_conflict_choice(conflict_key, candidate_id, expected_preference_revision) .await } @@ -3440,6 +4869,209 @@ pub async fn external_source_snapshot( } } +/// Returns a static, sanitized projection for Hosts that may inspect external +/// configuration but must never load external code or alter runtime routes. +pub async fn external_source_read_only_snapshot( + workspace_root: Option<&Path>, + force_refresh: bool, +) -> Result { + let service = read_only_service_for(workspace_root).await?; + let snapshot = if force_refresh { + service.refresh().await? + } else { + service.ensure_background_refresh(); + service.snapshot() + }; + let mut public = ExternalSourcePublicSnapshot::from(snapshot); + public.host_capabilities = ExternalSourceHostCapabilities::read_only_projection(); + Ok(public) +} + +pub async fn update_external_integration_policy( + workspace_root: Option<&Path>, + mutation: ExternalIntegrationPolicyMutation, +) -> Result { + let expected_revision = mutation.expected_preference_revision; + let (scope, operation, ecosystem, capability) = integration_policy_log_context(&mutation); + let result = match service_for(workspace_root).await { + Ok(service) => service.update_integration_policy(mutation).await, + Err(error) => Err(error), + }; + match &result { + Ok(snapshot) => log::info!( + "External integration policy mutation outcome=success scope={} operation={} ecosystem={} capability={} revision={} changed={}", + scope, + operation, + ecosystem, + capability, + snapshot.preference_revision, + snapshot.preference_revision != expected_revision, + ), + Err(error) => log::warn!( + "External integration policy mutation outcome=failure scope={} operation={} ecosystem={} capability={} expected_revision={} error_code={}", + scope, + operation, + ecosystem, + capability, + expected_revision, + external_integration_error_code(error), + ), + } + result +} + +fn integration_policy_log_context( + mutation: &ExternalIntegrationPolicyMutation, +) -> (&'static str, &'static str, String, String) { + let scope = match mutation.scope { + ExternalIntegrationPolicyScope::User => "user", + ExternalIntegrationPolicyScope::Workspace => "workspace", + _ => "unknown", + }; + let (operation, ecosystem, capability) = match &mutation.change { + ExternalIntegrationPolicyOperation::SetEnabled { .. } => { + ("set_enabled", "all".to_string(), "all".to_string()) + } + ExternalIntegrationPolicyOperation::SetEcosystemMode { ecosystem_id, .. } => ( + "set_ecosystem_mode", + safe_external_log_token(ecosystem_id.as_str()), + "all".to_string(), + ), + ExternalIntegrationPolicyOperation::SetCapabilityAccess { + ecosystem_id, + capability_id, + .. + } => ( + "set_capability_access", + safe_external_log_token(ecosystem_id.as_str()), + safe_external_log_token(capability_id.as_str()), + ), + ExternalIntegrationPolicyOperation::ResetWorkspace => { + ("reset_workspace", "all".to_string(), "all".to_string()) + } + ExternalIntegrationPolicyOperation::ResetIncompatiblePolicy => ( + "reset_incompatible_policy", + "all".to_string(), + "all".to_string(), + ), + _ => ("unknown", "unknown".to_string(), "unknown".to_string()), + }; + (scope, operation, ecosystem, capability) +} + +fn safe_external_log_token(value: &str) -> String { + value + .chars() + .take(64) + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + character + } else { + '_' + } + }) + .collect() +} + +fn external_log_scope(workspace_root: Option<&Path>) -> &'static str { + if workspace_root.is_some() { + "workspace" + } else { + "user-global" + } +} + +fn external_log_error_category(error: &str) -> String { + ExternalSourceOperationError::decode(error) + .map(|typed| typed.code.as_str().to_string()) + .unwrap_or_else(|| "internal".to_string()) +} + +/// Converts legacy internal failures at the product boundary without deriving +/// control flow from prose. Callers may pass an exactly encoded shared error; +/// every other failure becomes a sanitized internal error with a correlation +/// id, while the local log retains only a bounded category token. +pub fn sanitize_external_source_operation_error(error: String) -> ExternalSourceOperationError { + if let Some(typed) = ExternalSourceOperationError::decode(&error) { + return typed; + } + static NEXT_CORRELATION_ID: AtomicU64 = AtomicU64::new(1); + let correlation_id = format!( + "external-source-{}-{}", + epoch_seconds(), + NEXT_CORRELATION_ID.fetch_add(1, Ordering::Relaxed) + ); + log::error!( + "External source operation failed correlation_id={} category={}", + correlation_id, + external_log_error_category(&error), + ); + ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::Internal, + "External source operation failed. Retry, then use the reference id if the problem continues.", + true, + ) + .with_correlation_id(correlation_id) +} + +fn encoded_operation_error( + code: ExternalSourceOperationErrorCode, + detail: impl Into, + retryable: bool, +) -> String { + ExternalSourceOperationError::new(code, detail, retryable).encode() +} + +fn stale_operation_error(detail: impl Into) -> String { + encoded_operation_error( + ExternalSourceOperationErrorCode::StaleRevision, + detail, + true, + ) +} + +fn missing_candidate_error(detail: impl Into) -> String { + encoded_operation_error(ExternalSourceOperationErrorCode::NotFound, detail, false) +} + +fn policy_limited_error(detail: impl Into) -> String { + encoded_operation_error( + ExternalSourceOperationErrorCode::PolicyLimited, + detail, + false, + ) +} + +fn conflict_operation_error(detail: impl Into) -> String { + encoded_operation_error(ExternalSourceOperationErrorCode::Conflict, detail, true) +} + +fn unavailable_operation_error(detail: impl Into) -> String { + encoded_operation_error(ExternalSourceOperationErrorCode::Unavailable, detail, true) +} + +fn invalid_operation_error(detail: impl Into) -> String { + encoded_operation_error( + ExternalSourceOperationErrorCode::InvalidRequest, + detail, + false, + ) +} + +fn incompatible_policy_error(detail: impl Into) -> String { + encoded_operation_error( + ExternalSourceOperationErrorCode::PolicyIncompatible, + detail, + false, + ) +} + +fn external_integration_error_code(error: &str) -> String { + ExternalSourceOperationError::decode(error) + .map(|error| error.code.as_str().to_string()) + .unwrap_or_else(|| "internal".to_string()) +} + /// Keep the external-source runtime aligned with an actively assembled product /// tool catalog. A newly created service performs one synchronous refresh so an /// idle-retired workspace can restore approved routes before the catalog is @@ -3449,17 +5081,29 @@ pub(crate) async fn ensure_external_source_workspace_runtime(workspace_root: Opt let service = match service_for(workspace_root).await { Ok(service) => service, Err(error) => { - log::warn!("Could not retain external source workspace runtime: {error}"); + log::warn!( + "Could not retain external source workspace runtime scope={} error_category={}", + external_log_scope(workspace_root), + external_log_error_category(&error), + ); return; } }; if let Err(error) = service.ensure_initial_refresh().await { - log::warn!("Could not initialize external source workspace runtime: {error}"); + log::warn!( + "Could not initialize external source workspace runtime scope={} error_category={}", + external_log_scope(workspace_root), + external_log_error_category(&error), + ); return; } if external_tool_workspace_requires_recovery(workspace_root).await { if let Err(error) = service.refresh_worker_loss_once().await { - log::warn!("Could not recover external source tool runtime: {error}"); + log::warn!( + "Could not recover external source tool runtime scope={} error_category={}", + external_log_scope(workspace_root), + external_log_error_category(&error), + ); } } } @@ -3468,10 +5112,11 @@ pub async fn set_external_source_enabled( workspace_root: Option<&Path>, source_key: &str, enabled: bool, + expected_preference_revision: u64, ) -> Result { service_for(workspace_root) .await? - .set_source_enabled(source_key, enabled) + .set_source_enabled(source_key, enabled, expected_preference_revision) .await } @@ -3522,9 +5167,9 @@ impl ExternalSourceSubscription { mod tests { use super::*; use bitfun_product_domains::external_sources::{ - EcosystemId, ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, - ExternalSourceScope, PromptCommandAvailability, PromptCommandDefinition, - PromptCommandProviderIdentity, PromptCommandProviderSnapshot, SourceQualifiedCommandId, + EcosystemId, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, + PromptCommandAvailability, PromptCommandDefinition, PromptCommandProviderIdentity, + PromptCommandProviderSnapshot, SourceQualifiedCommandId, }; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -3540,6 +5185,34 @@ mod tests { )); } + #[test] + fn integration_error_metrics_decode_typed_codes_without_parsing_prose() { + let stale = stale_operation_error("preferences changed"); + assert_eq!(external_integration_error_code(&stale), "stale_revision"); + assert_eq!( + external_integration_error_code("legacy internal failure: private detail"), + "internal" + ); + } + + #[test] + fn background_log_categories_never_include_error_details_or_paths() { + let stale = stale_operation_error("private workspace path changed"); + assert_eq!(external_log_error_category(&stale), "stale_revision"); + + for raw in [ + r"directory_read_failed: C:\Users\alice\.config\opencode", + "Failed to watch path /home/alice/.config/opencode: permission denied", + ] { + let category = external_log_error_category(raw); + assert_eq!(category, "internal"); + assert!(!category.contains("alice")); + assert!(!category.contains("opencode")); + } + assert_eq!(external_log_scope(Some(Path::new("C:/repo"))), "workspace"); + assert_eq!(external_log_scope(None), "user-global"); + } + #[test] fn final_catalog_redacts_known_absolute_paths_from_diagnostics() { let source_key = SourceKey::new("future.tools", "project").unwrap(); @@ -3558,7 +5231,7 @@ mod tests { scope: ExternalSourceScope::Project, location: raw_root.to_string(), execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), - health: ExternalSourceHealth::Partial, + health: bitfun_product_domains::external_sources::ExternalSourceHealth::Partial, content_version: "source-v1".to_string(), diagnostics: vec![ExternalSourceDiagnostic::warning( "future.tool.directory_read_failed", @@ -3583,6 +5256,7 @@ mod tests { subagents: Vec::new(), subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), diagnostics: vec![ExternalSourceDiagnostic::warning( "future.tool.file_read_failed", format!("Failed to read '{raw_file}'"), @@ -3640,7 +5314,7 @@ mod tests { scope: ExternalSourceScope::UserGlobal, location: format!("/{}", self.command_name), execution_domain_id: context.execution_domain_id.clone(), - health: ExternalSourceHealth::Available, + health: bitfun_product_domains::external_sources::ExternalSourceHealth::Available, content_version: "source-v1".to_string(), diagnostics: Vec::new(), }; @@ -3709,13 +5383,18 @@ mod tests { let subagent_coordinator = ExternalSubagentCoordinator::new(context.clone(), Vec::new()).unwrap(); let mcp_coordinator = ExternalMcpCoordinator::new(context, Vec::new()).unwrap(); - let snapshot = merge_tool_state( + let mut snapshot = merge_tool_state( coordinator.snapshot(), &tool_coordinator.snapshot(), ExternalToolProductState::default(), ); + snapshot.integration_policy = + integration_policy_snapshot(&ExternalSourcesConfig::default(), None) + .expect("built-in integration policy is valid"); Arc::new(WorkspaceExternalSourceService { + profile: ExternalSourceServiceProfile::LocalExecution, workspace_root: None, + execution_domain_id: ExecutionDomainId::new(LEGACY_LOCAL_EXECUTION_DOMAIN_ID).unwrap(), coordinator: Arc::new(StdMutex::new(coordinator)), tool_coordinator: Arc::new(StdMutex::new(tool_coordinator)), subagent_coordinator: Arc::new(StdMutex::new(subagent_coordinator)), @@ -3743,6 +5422,77 @@ mod tests { }) } + #[derive(Default)] + struct CountingExternalMcpRuntime { + calls: AtomicUsize, + } + + #[async_trait::async_trait] + impl ExternalMcpRuntimePort for CountingExternalMcpRuntime { + async fn install( + &self, + _candidate: &crate::external_mcp::ActiveExternalMcpCandidate, + _prepared: bitfun_product_domains::external_sources::PreparedExternalMcpServer, + _workspace_key: &str, + ) -> Result<(), String> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn retire(&self, _runtime_id: &str) -> Result<(), String> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn status(&self, _runtime_id: &str) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(ExternalMcpRuntimeStatus::Active) + } + + async fn replace_workspace_route( + &self, + _workspace_key: &str, + _active_external_server_ids: BTreeSet, + _suppressed_native_server_ids: BTreeSet, + ) -> Result<(), String> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[tokio::test] + async fn read_only_projection_never_calls_the_mcp_runtime() { + let runtime = Arc::new(CountingExternalMcpRuntime::default()); + let mut service = test_service(Vec::new()); + let service_inner = Arc::get_mut(&mut service).expect("test owns the service"); + service_inner.profile = ExternalSourceServiceProfile::ReadOnlyProjection; + service_inner.mcp_runtime = runtime.clone(); + + let preferences = ExternalSourcesConfig::default(); + let policy = integration_policy_snapshot(&preferences, None).unwrap(); + let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + let snapshot = service + .rebuild_read_only_projection(command_snapshot, preferences, policy) + .await + .unwrap(); + + assert_eq!(runtime.calls.load(Ordering::SeqCst), 0); + assert!(snapshot + .mcp_servers + .iter() + .all(|entry| entry.runtime_id.is_none())); + assert!(snapshot + .tools + .iter() + .all(|entry| { !matches!(entry.activation, ExternalToolActivationState::Active) })); + assert!(snapshot.subagents.iter().all(|entry| { + !matches!( + entry.activation_state, + ExternalSubagentActivationState::Active + ) + })); + } + #[tokio::test] async fn preference_store_merges_updates_from_independent_instances() { let temp = tempfile::tempdir().unwrap(); @@ -3788,6 +5538,317 @@ mod tests { ); } + #[test] + fn opencode_registry_owns_low_friction_defaults_and_safety_ceilings() { + let policy = integration_policy_snapshot(&ExternalSourcesConfig::default(), None) + .expect("built-in policy is valid"); + let descriptor = policy + .registered_ecosystems + .iter() + .find(|descriptor| descriptor.ecosystem_id.as_str() == OPENCODE_ECOSYSTEM_ID) + .expect("OpenCode is registered by product assembly"); + + for (capability_id, recommended, ceiling) in [ + ( + EXTERNAL_CAPABILITY_COMMAND, + ExternalIntegrationAccess::Auto, + ExternalIntegrationAccess::Auto, + ), + ( + EXTERNAL_CAPABILITY_TOOL, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + ( + EXTERNAL_CAPABILITY_SUBAGENT, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + ( + EXTERNAL_CAPABILITY_MCP, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + ] { + let capability = descriptor + .capabilities + .iter() + .find(|capability| capability.capability_id.as_str() == capability_id) + .expect("built-in capability is registered"); + assert_eq!(capability.recommended_access, recommended); + assert_eq!(capability.safety_ceiling, ceiling); + assert_eq!( + integration_access(&policy, OPENCODE_ECOSYSTEM_ID, capability_id), + recommended + ); + } + } + + #[test] + fn active_capability_sets_are_scoped_per_ecosystem_for_every_asset_kind() { + let mut policy = integration_policy_snapshot(&ExternalSourcesConfig::default(), None) + .expect("built-in policy is valid"); + let template_descriptor = policy.registered_ecosystems[0].clone(); + let template_effective = policy + .effective + .ecosystems + .get(&template_descriptor.ecosystem_id) + .cloned() + .expect("built-in effective ecosystem exists"); + let discover_id = EcosystemId::new("discover.ecosystem").unwrap(); + let active_id = EcosystemId::new("active.ecosystem").unwrap(); + let mut discover_descriptor = template_descriptor.clone(); + discover_descriptor.ecosystem_id = discover_id.clone(); + discover_descriptor.display_name = "Discover ecosystem".to_string(); + let mut active_descriptor = template_descriptor; + active_descriptor.ecosystem_id = active_id.clone(); + active_descriptor.display_name = "Active ecosystem".to_string(); + policy.registered_ecosystems = vec![discover_descriptor, active_descriptor]; + + let mut discover_effective = template_effective.clone(); + discover_effective.ecosystem_id = discover_id.clone(); + for access in discover_effective.capabilities.values_mut() { + *access = ExternalIntegrationAccess::DiscoverOnly; + } + let mut active_effective = template_effective; + active_effective.ecosystem_id = active_id.clone(); + policy.effective.ecosystems = BTreeMap::from([ + (discover_id.clone(), discover_effective), + (active_id.clone(), active_effective), + ]); + + for capability in [ + EXTERNAL_CAPABILITY_COMMAND, + EXTERNAL_CAPABILITY_TOOL, + EXTERNAL_CAPABILITY_SUBAGENT, + EXTERNAL_CAPABILITY_MCP, + ] { + assert_eq!( + ecosystems_with_discoverable_capability(&policy, capability), + BTreeSet::from([discover_id.clone(), active_id.clone()]) + ); + assert_eq!( + ecosystems_with_active_capability(&policy, capability), + BTreeSet::from([active_id.clone()]) + ); + } + } + + #[test] + fn ecosystem_registration_rejects_provider_and_capability_mismatches() { + let mut incompatible_contract = default_external_integration_registry() + .into_iter() + .next() + .expect("built-in registration exists"); + incompatible_contract.contract_major = EXTERNAL_ADAPTER_CONTRACT_MAJOR + 1; + assert!(incompatible_contract + .validate() + .unwrap_err() + .contains("contract major")); + + let mut wrong_ecosystem = default_external_integration_registry() + .into_iter() + .next() + .expect("built-in registration exists"); + wrong_ecosystem.command_provider = Some(delayed_provider( + "different.ecosystem", + std::time::Duration::ZERO, + Arc::new(AtomicUsize::new(0)), + )); + assert!(wrong_ecosystem + .validate() + .unwrap_err() + .contains("different ecosystem")); + + let mut missing_provider = default_external_integration_registry() + .into_iter() + .next() + .expect("built-in registration exists"); + missing_provider.command_provider = None; + assert!(missing_provider + .validate() + .unwrap_err() + .contains("provider registration do not match")); + } + + #[test] + fn integration_policy_mutations_share_revision_and_keep_workspace_paths_private() { + let temp = tempfile::tempdir().unwrap(); + let workspace_key = workspace_policy_key(Some(temp.path())).expect("workspace has a key"); + assert!(workspace_key.starts_with("workspace:")); + assert!(!workspace_key.contains(&temp.path().to_string_lossy().to_string())); + + let ecosystem_id = + bitfun_product_domains::external_sources::EcosystemId::new(OPENCODE_ECOSYSTEM_ID) + .unwrap(); + let mut config = ExternalSourcesConfig::default(); + let user_mutation = ExternalIntegrationPolicyMutation { + expected_preference_revision: 0, + scope: ExternalIntegrationPolicyScope::User, + change: ExternalIntegrationPolicyOperation::SetEcosystemMode { + ecosystem_id: ecosystem_id.clone(), + mode: ExternalIntegrationMode::DiscoverOnly, + }, + }; + assert!( + apply_integration_policy_mutation_to_config(&mut config, None, &user_mutation,) + .unwrap() + ); + assert_eq!(config.preference_revision, 1); + + let stale = apply_integration_policy_mutation_to_config(&mut config, None, &user_mutation) + .expect_err("old revisions cannot overwrite a newer policy"); + assert_eq!( + ExternalSourceOperationError::decode(&stale) + .expect("stale policy revisions use the typed error contract") + .code, + ExternalSourceOperationErrorCode::StaleRevision + ); + + let workspace_mutation = ExternalIntegrationPolicyMutation { + expected_preference_revision: 1, + scope: ExternalIntegrationPolicyScope::Workspace, + change: ExternalIntegrationPolicyOperation::SetEcosystemMode { + ecosystem_id: ecosystem_id.clone(), + mode: ExternalIntegrationMode::Disabled, + }, + }; + assert!(apply_integration_policy_mutation_to_config( + &mut config, + Some(&workspace_key), + &workspace_mutation, + ) + .unwrap()); + assert_eq!(config.preference_revision, 2); + assert_eq!( + config + .integration_policy + .known() + .expect("the built-in policy schema is known") + .workspace_overrides[&workspace_key] + .ecosystems[&ecosystem_id] + .mode, + Some(ExternalIntegrationMode::Disabled) + ); + } + + #[test] + fn preference_document_preserves_future_minor_fields() { + let raw = serde_json::json!({ + "integrationPolicy": { + "schemaMajor": 1, + "userDefaults": { + "enabled": true, + "futureSetting": "keep" + }, + "futurePolicyField": { "revision": 2 } + }, + "preferenceRevision": 4, + "futurePreferenceField": ["keep"] + }); + let mut config: ExternalSourcesConfig = serde_json::from_value(raw).unwrap(); + config.preference_revision += 1; + let encoded = serde_json::to_value(config).unwrap(); + + assert_eq!( + encoded["integrationPolicy"]["userDefaults"]["futureSetting"], + "keep" + ); + assert_eq!( + encoded["integrationPolicy"]["futurePolicyField"]["revision"], + 2 + ); + assert_eq!(encoded["futurePreferenceField"][0], "keep"); + } + + #[test] + fn incompatible_policy_requires_explicit_reset_and_keeps_a_bounded_backup() { + let future_policy = serde_json::json!({ + "schemaMajor": 13, + "userDefaults": "future-policy-shape", + "workspaceOverrides": ["also", "structurally", "different"], + "futurePolicyField": { "schema": 13 } + }); + let stored_future_policy: StoredExternalIntegrationPolicy = + serde_json::from_value(future_policy.clone()).unwrap(); + let mut config = ExternalSourcesConfig { + integration_policy: stored_future_policy, + integration_policy_backups: vec![ + serde_json::json!({ "schemaMajor": 10, "opaque": "first" }), + serde_json::json!({ "schemaMajor": 11, "opaque": "second" }), + serde_json::json!({ "schemaMajor": 12, "opaque": "third" }), + ], + preference_revision: 7, + ..ExternalSourcesConfig::default() + }; + let public_snapshot = integration_policy_snapshot(&config, None).unwrap(); + assert_eq!( + public_snapshot.status, + ExternalIntegrationPolicyStatus::IncompatibleSchema + ); + assert!(!public_snapshot.global_effective.enabled); + assert!(!public_snapshot.effective.enabled); + let serialized_snapshot = serde_json::to_value(&public_snapshot).unwrap(); + assert!(!serialized_snapshot + .to_string() + .contains("future-policy-shape")); + assert!(!serialized_snapshot + .to_string() + .contains("futurePolicyField")); + + config + .suppressed_source_keys + .push("opencode:project".to_string()); + let persisted = serde_json::to_value(&config).unwrap(); + config = serde_json::from_value(persisted).unwrap(); + assert_eq!(config.integration_policy.raw_value(), future_policy); + assert_eq!(config.suppressed_source_keys, ["opencode:project"]); + + let ordinary_mutation = ExternalIntegrationPolicyMutation { + expected_preference_revision: 7, + scope: ExternalIntegrationPolicyScope::User, + change: ExternalIntegrationPolicyOperation::SetEnabled { enabled: false }, + }; + let error = + apply_integration_policy_mutation_to_config(&mut config, None, &ordinary_mutation) + .expect_err("future schemas cannot be edited by an older host"); + assert_eq!( + ExternalSourceOperationError::decode(&error) + .expect("incompatible schemas use the typed error contract") + .code, + ExternalSourceOperationErrorCode::PolicyIncompatible + ); + assert_eq!(config.preference_revision, 7); + assert_eq!(config.integration_policy.schema_major(), 13); + + let reset = ExternalIntegrationPolicyMutation { + expected_preference_revision: 7, + scope: ExternalIntegrationPolicyScope::User, + change: ExternalIntegrationPolicyOperation::ResetIncompatiblePolicy, + }; + assert!(apply_integration_policy_mutation_to_config(&mut config, None, &reset).unwrap()); + assert_eq!(config.preference_revision, 8); + assert_eq!( + config.integration_policy.schema_major(), + EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR + ); + assert!( + !integration_policy_snapshot(&config, None) + .unwrap() + .effective + .enabled + ); + assert_eq!( + config + .integration_policy_backups + .iter() + .map(|document| document["schemaMajor"].as_u64().unwrap()) + .collect::>(), + vec![11, 12, 13] + ); + assert_eq!(config.integration_policy_backups[2], future_policy); + } + #[tokio::test] async fn subagent_conflict_history_advances_revision_and_rejects_stale_process_actions() { let temp = tempfile::tempdir().unwrap(); @@ -3843,7 +5904,12 @@ mod tests { ) .await .expect_err("the stale process must not overwrite the new conflict generation"); - assert!(error.starts_with("stale_action:")); + assert_eq!( + ExternalSourceOperationError::decode(&error) + .expect("stale conflict actions use the typed error contract") + .code, + ExternalSourceOperationErrorCode::StaleRevision + ); } #[tokio::test] @@ -3871,7 +5937,7 @@ mod tests { scope: ExternalSourceScope::UserGlobal, location: "/tools".to_string(), execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), - health: ExternalSourceHealth::Available, + health: bitfun_product_domains::external_sources::ExternalSourceHealth::Available, content_version: "v1".to_string(), diagnostics: Vec::new(), }; @@ -3884,13 +5950,30 @@ mod tests { config.suppressed_source_keys.push(source.preference_key()); assert!(!external_tool_invocation_is_authorized_by( &config, + source.ecosystem_id.as_str(), approval_key, - &source.preference_key() + &source.preference_key(), + "", )); assert!(external_tool_invocation_is_authorized_by( &config, + source.ecosystem_id.as_str(), + approval_key, + &source.key.stable_key(), + "", + )); + config + .integration_policy + .known_mut() + .expect("the built-in policy schema is known") + .user_defaults + .enabled = false; + assert!(!external_tool_invocation_is_authorized_by( + &config, + source.ecosystem_id.as_str(), approval_key, - &source.key.stable_key() + &source.key.stable_key(), + "", )); } @@ -4023,12 +6106,18 @@ mod tests { }; lock_snapshot(&service.snapshot).tool_approval_requests = vec![request("decision-v1", "v1")]; + let expected_preference_revision = lock_snapshot(&service.snapshot).preference_revision; let refresh_guard = service.refresh_gate.lock().await; let decision_service = Arc::clone(&service); let decision = tokio::spawn(async move { decision_service - .set_tool_target_decision("approval-a", "decision-v1", true) + .set_tool_target_decision( + "approval-a", + "decision-v1", + true, + expected_preference_revision, + ) .await }); tokio::time::timeout( @@ -4061,7 +6150,12 @@ mod tests { .await .unwrap() .expect_err("the approval must not apply to the changed content"); - assert_eq!(error, "external tool decision is stale or unknown"); + assert_eq!( + ExternalSourceOperationError::decode(&error) + .expect("changed tool decisions use the typed error contract") + .code, + ExternalSourceOperationErrorCode::NotFound + ); } #[test] @@ -4095,16 +6189,16 @@ mod tests { ( "external_mcp_approval:local-user:workspace-a:server:old".to_string(), ExternalMcpDecision { - decision_key: - "external_mcp_approval:local-user:workspace-a:server:old".to_string(), + decision_key: "external_mcp_approval:local-user:workspace-a:server:old" + .to_string(), approved: true, }, ), ( "external_mcp_approval:local-user:workspace-b:server:current".to_string(), ExternalMcpDecision { - decision_key: - "external_mcp_approval:local-user:workspace-b:server:current".to_string(), + decision_key: "external_mcp_approval:local-user:workspace-b:server:current" + .to_string(), approved: true, }, ), @@ -4116,11 +6210,11 @@ mod tests { false, ); - assert!(!decisions - .contains_key("external_mcp_approval:local-user:workspace-a:server:old")); + assert!(!decisions.contains_key("external_mcp_approval:local-user:workspace-a:server:old")); assert!(!decisions["external_mcp_approval:local-user:workspace-a:server:new"].approved); - assert!(decisions - .contains_key("external_mcp_approval:local-user:workspace-b:server:current")); + assert!( + decisions.contains_key("external_mcp_approval:local-user:workspace-b:server:current") + ); assert_eq!(decisions.len(), 2); } diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index d9f00a8f96..b14742db04 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -18,6 +18,7 @@ use crate::service::config::types::{model_runtime_binding_fingerprint, AIConfig, use crate::service::config::SubagentModelSelection; use crate::util::BitFunError; use bitfun_external_sources::ExternalSubagentCoordinatorSnapshot; +use bitfun_product_domains::external_sources::EcosystemId; use bitfun_product_domains::external_sources::{ExternalSourceScope, ProviderId, SourceKey}; use bitfun_product_domains::external_subagents::{ external_subagent_approval_key, external_subagent_conflict_key, @@ -35,6 +36,7 @@ pub(super) const DISABLED_SUBAGENT_CONFLICT_CHOICE: &str = "__bitfun_disabled__" static MODEL_CONFIG_UNAVAILABLE_LOGGED: AtomicBool = AtomicBool::new(false); pub(super) struct ExternalSubagentDecisions<'a> { + pub active_ecosystems: &'a BTreeSet, pub approved_envelopes: &'a BTreeSet, pub declined_decisions: &'a BTreeMap, pub conflict_choices: &'a BTreeMap, @@ -108,6 +110,65 @@ pub(super) async fn reconcile_external_subagents( ) } +/// Static projection for read-only Hosts. It never reads model configuration, +/// the tool registry, or the agent registry, and it never produces routes or +/// runtime registrations. +pub(super) fn project_external_subagents_read_only( + workspace_root: Option<&Path>, + execution_domain_id: &str, + snapshot: &ExternalSubagentCoordinatorSnapshot, + decisions: ExternalSubagentDecisions<'_>, +) -> ExternalSubagentProductState { + let source_map = snapshot + .sources + .iter() + .map(|entry| (entry.record.key.clone(), &entry.record)) + .collect::>(); + let facts = ProductFacts::default(); + let mut state = ExternalSubagentProductState::default(); + for definition in &snapshot.definitions { + let resolved = resolve_external_candidate( + workspace_root, + execution_domain_id, + definition, + &source_map, + &snapshot.provider_labels, + &facts, + ); + let ecosystem_active = resolved.source_keys.iter().all(|source_key| { + source_map + .get(source_key) + .is_some_and(|source| decisions.active_ecosystems.contains(&source.ecosystem_id)) + }); + let activation = if !ecosystem_active || resolved.definition.disabled { + ExternalSubagentActivationState::Disabled + } else if decisions + .approved_envelopes + .contains(&resolved.approval_key) + { + ExternalSubagentActivationState::Unavailable + } else if decisions + .declined_decisions + .get(&resolved.approval_key) + .is_some_and(|decision| decision == &resolved.approval_key) + { + ExternalSubagentActivationState::Declined + } else { + state.pending_approvals.push(resolved.approval_key.clone()); + ExternalSubagentActivationState::ApprovalRequired + }; + state.summaries.push(summary_for(&resolved, activation)); + } + state.summaries.sort_by(|left, right| { + left.logical_id + .cmp(&right.logical_id) + .then(left.candidate_id.cmp(&right.candidate_id)) + }); + state.pending_approvals.sort(); + state.pending_approvals.dedup(); + state +} + async fn gather_product_facts( workspace_root: Option<&Path>, definitions: &[ExternalSubagentDefinition], @@ -416,6 +477,18 @@ fn reconcile_with_facts( &snapshot.provider_labels, facts, ); + let ecosystem_active = resolved.source_keys.iter().all(|source_key| { + source_map + .get(source_key) + .is_some_and(|source| decisions.active_ecosystems.contains(&source.ecosystem_id)) + }); + if !ecosystem_active { + state.summaries.push(summary_for( + &resolved, + ExternalSubagentActivationState::Disabled, + )); + continue; + } let summary = summary_for(&resolved, initial_activation_state(&resolved)); if facts.ai_config.is_none() { if !resolved.definition.disabled { @@ -1025,6 +1098,13 @@ fn stable_digest(parts: impl IntoIterator>) -> String { #[cfg(test)] mod tests { use super::*; + + fn test_active_ecosystems() -> &'static BTreeSet { + static ECOSYSTEMS: std::sync::OnceLock> = std::sync::OnceLock::new(); + ECOSYSTEMS.get_or_init(|| { + BTreeSet::from([EcosystemId::new("fake").expect("valid test ecosystem")]) + }) + } use bitfun_product_domains::external_sources::{ EcosystemId, ExecutionDomainId, ExternalSourceCatalogEntry, ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceRecord, @@ -1273,6 +1353,7 @@ mod tests { "local-user", &definition_snapshot, ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1289,6 +1370,7 @@ mod tests { "local-user", &definition_snapshot, ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &approved, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1321,6 +1403,7 @@ mod tests { "local-user", &definition_snapshot, ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &approved, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1361,6 +1444,7 @@ mod tests { "local-user", &first, ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1376,6 +1460,7 @@ mod tests { "local-user", &updated, ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &approved, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1403,6 +1488,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1416,6 +1502,7 @@ mod tests { "local-user", &snapshot("behavior-v2", "catalog-v2"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &approved, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1440,6 +1527,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1463,6 +1551,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &approved, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1492,6 +1581,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1511,6 +1601,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &approved, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1548,6 +1639,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1584,6 +1676,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1615,6 +1708,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &approved, declined_decisions: &empty_map, conflict_choices: &choices, @@ -1655,6 +1749,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &approved, declined_decisions: &empty_map, conflict_choices: &choices, @@ -1685,6 +1780,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &approved, declined_decisions: &empty_map, conflict_choices: &choices, @@ -1736,6 +1832,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &empty_map, @@ -1762,6 +1859,7 @@ mod tests { "local-user", &snapshot("behavior-v1", "catalog-v1"), ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &choices, @@ -1800,6 +1898,7 @@ mod tests { "local-user", &without_external, ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &choices, @@ -1833,6 +1932,7 @@ mod tests { "local-user", &without_external, ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), approved_envelopes: &empty_set, declined_decisions: &empty_map, conflict_choices: &choices, diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index b901fc5f4d..5391e0b268 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -9,11 +9,11 @@ use async_trait::async_trait; use bitfun_external_sources::{ExternalToolCoordinator, ExternalToolCoordinatorSnapshot}; use bitfun_product_domains::external_sources::{ external_tool_approval_key, external_tool_conflict_key, external_tool_decision_key, - ExternalSourceAssetKind, ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, - ExternalSourceScope, ExternalToolActivationState, ExternalToolApprovalRequest, - ExternalToolCatalogEntry, ExternalToolConflict, ExternalToolConflictCandidate, - ExternalToolConflictCandidateKind, ExternalToolDefinition, ExternalToolStaticStatus, - PreparedExternalToolTarget, SourceQualifiedToolTargetId, + EcosystemId, ExternalSourceAssetKind, ExternalSourceDiagnostic, + ExternalSourceDiagnosticSeverity, ExternalSourceScope, ExternalToolActivationState, + ExternalToolApprovalRequest, ExternalToolCatalogEntry, ExternalToolConflict, + ExternalToolConflictCandidate, ExternalToolConflictCandidateKind, ExternalToolDefinition, + ExternalToolStaticStatus, PreparedExternalToolTarget, SourceQualifiedToolTargetId, }; use bitfun_runtime_ports::{ PortErrorKind, ScriptToolDescriptor, ScriptToolExpectedExport, ScriptToolInvokeRequest, @@ -37,17 +37,124 @@ pub(super) struct ExternalToolProductState { } pub(super) struct ExternalToolDecisions<'a> { + pub active_ecosystems: &'a BTreeSet, pub approved_targets: &'a BTreeSet, pub declined_decisions_by_approval: &'a BTreeMap, pub conflict_choices: &'a BTreeMap, } +/// Builds the catalog visible from a Host that may discover external files but +/// is not allowed to load code or mutate runtime routes. This intentionally +/// does not consult the tool registry or the script runtime. +pub(super) fn project_external_tools_read_only( + execution_domain_id: &str, + snapshot: &ExternalToolCoordinatorSnapshot, + decisions: ExternalToolDecisions<'_>, +) -> ExternalToolProductState { + let source_by_key = snapshot + .sources + .iter() + .map(|source| (source.record.key.clone(), source.record.clone())) + .collect::>(); + let mut target_groups = + BTreeMap::>::new(); + for tool in &snapshot.tools { + target_groups + .entry(tool.id.target.clone()) + .or_default() + .push(tool.clone()); + } + let mut state = ExternalToolProductState::default(); + for (target_id, definitions) in target_groups { + let first = &definitions[0]; + let approval_key = external_tool_approval_key( + execution_domain_id, + &target_id, + first.runtime_kind, + first.capabilities.iter().copied(), + ); + let decision_key = external_tool_decision_key(&approval_key, &first.content_version); + let source = source_by_key.get(&target_id.source); + let ecosystem_active = + source.is_some_and(|source| decisions.active_ecosystems.contains(&source.ecosystem_id)); + let unsupported_reason = definitions + .iter() + .find_map(|tool| match &tool.static_status { + ExternalToolStaticStatus::Ready => None, + ExternalToolStaticStatus::Unsupported { reason } + | ExternalToolStaticStatus::Invalid { reason } => Some(reason.clone()), + _ => Some("tool uses a static format not supported by this version".to_string()), + }); + let activation = if let Some(reason) = unsupported_reason { + ExternalToolActivationState::Unsupported { reason } + } else if !ecosystem_active { + ExternalToolActivationState::Disabled + } else if decisions.approved_targets.contains(&approval_key) { + ExternalToolActivationState::RuntimeUnavailable { + reason: "This Host exposes discovery only; use Desktop or an authenticated Peer Host to run external tools".to_string(), + } + } else if decisions + .declined_decisions_by_approval + .get(&approval_key) + .is_some_and(|declined| declined == &decision_key) + { + ExternalToolActivationState::Disabled + } else { + state.approval_requests.push(ExternalToolApprovalRequest { + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + target_id: target_id.clone(), + source_display_name: source + .map(|source| source.display_name.clone()) + .unwrap_or_else(|| "External tools".to_string()), + source_scope: source + .map(|source| source.scope) + .unwrap_or(ExternalSourceScope::WorkspaceLocal), + source_location: source + .map(|source| source.location.clone()) + .unwrap_or_else(|| first.module_path.clone()), + working_directory: first.working_directory.clone(), + runtime_kind: first.runtime_kind, + capabilities: first.capabilities.clone(), + content_version: first.content_version.clone(), + tool_names: definitions.iter().map(|tool| tool.name.clone()).collect(), + }); + ExternalToolActivationState::ApprovalRequired + }; + state.tools.extend( + definitions + .into_iter() + .map(|definition| ExternalToolCatalogEntry { + definition, + approval_key: approval_key.clone(), + decision_key: decision_key.clone(), + activation: activation.clone(), + }), + ); + } + state.tools.sort_by(|left, right| { + left.definition.name.cmp(&right.definition.name).then( + left.definition + .id + .stable_key() + .cmp(&right.definition.id.stable_key()), + ) + }); + state.approval_requests.sort_by(|left, right| { + left.target_id + .stable_key() + .cmp(&right.target_id.stable_key()) + }); + state +} + pub(super) const UNRESOLVED_TOOL_CONFLICT_CHOICE: &str = "__bitfun_unresolved__"; pub(super) const TOOL_CONFLICT_RESELECTION_REQUIRED: &str = "__bitfun_reselection_required__"; #[derive(Clone)] struct LoadedExternalTool { descriptor: ScriptToolDescriptor, + ecosystem_id: String, provider_id: String, runtime_target_id: String, load_generation: u64, @@ -112,8 +219,10 @@ impl Tool for LoadedExternalTool { context: &ToolUseContext, ) -> BitFunResult> { if !crate::external_sources::external_tool_invocation_is_authorized( + &self.ecosystem_id, &self.approval_key, &self.source_preference_key, + &self.workspace_key, ) .await .map_err(BitFunError::tool)? @@ -951,6 +1060,7 @@ impl ExternalToolRuntimeManager { async fn ensure_loaded( &self, workspace_key: &str, + ecosystem_id: &str, provider_id: &str, approval_key: &str, source_preference_key: &str, @@ -1009,6 +1119,7 @@ impl ExternalToolRuntimeManager { .map(|descriptor| { Arc::new(LoadedExternalTool { descriptor, + ecosystem_id: ecosystem_id.to_string(), provider_id: provider_id.to_string(), runtime_target_id: runtime_target_id.clone(), load_generation, @@ -1225,7 +1336,11 @@ pub(super) async fn reconcile_external_tools( let statically_ready = definitions .iter() .all(|tool| matches!(&tool.static_status, ExternalToolStaticStatus::Ready)); - let can_load = statically_ready + let ecosystem_active = source_by_key + .get(&target_id.source) + .is_some_and(|source| decisions.active_ecosystems.contains(&source.ecosystem_id)); + let can_load = ecosystem_active + && statically_ready && matches!( &runtime_availability, ScriptToolRuntimeAvailability::Available { .. } @@ -1284,6 +1399,9 @@ pub(super) async fn reconcile_external_tools( first.capabilities.iter().copied(), ); let decision_key = external_tool_decision_key(&approval_key, &first.content_version); + let ecosystem_active = source_by_key + .get(&target_id.source) + .is_some_and(|source| decisions.active_ecosystems.contains(&source.ecosystem_id)); let unsupported_reason = definitions .iter() .find_map(|tool| match &tool.static_status { @@ -1294,6 +1412,8 @@ pub(super) async fn reconcile_external_tools( }); let base_activation = if let Some(reason) = unsupported_reason { Some(ExternalToolActivationState::Unsupported { reason }) + } else if !ecosystem_active { + Some(ExternalToolActivationState::Disabled) } else if let ScriptToolRuntimeAvailability::Unavailable { reason } = &runtime_availability { Some(ExternalToolActivationState::RuntimeUnavailable { @@ -1342,8 +1462,10 @@ pub(super) async fn reconcile_external_tools( { if let Some(source) = source_by_key.get(&target_id.source) { if crate::external_sources::external_tool_invocation_is_authorized( + source.ecosystem_id.as_str(), &approval_key, &source.preference_key(), + &workspace_key, ) .await .unwrap_or(false) @@ -1394,8 +1516,10 @@ pub(super) async fn reconcile_external_tools( }; let source_preference_key = source_record.preference_key(); match crate::external_sources::external_tool_invocation_is_authorized( + source_record.ecosystem_id.as_str(), &approval_key, &source_preference_key, + &workspace_key, ) .await { @@ -1502,8 +1626,10 @@ pub(super) async fn reconcile_external_tools( Ok(prepared) => { let authorization_failure = match crate::external_sources::external_tool_invocation_is_authorized( + source_record.ecosystem_id.as_str(), &approval_key, &source_preference_key, + &workspace_key, ) .await { @@ -1542,6 +1668,7 @@ pub(super) async fn reconcile_external_tools( match runtime_manager() .ensure_loaded( &workspace_key, + source_record.ecosystem_id.as_str(), target_id.source.provider_id.as_str(), &approval_key, &source_preference_key, @@ -1551,8 +1678,10 @@ pub(super) async fn reconcile_external_tools( { Ok(loaded) => { match crate::external_sources::external_tool_invocation_is_authorized( + source_record.ecosystem_id.as_str(), &approval_key, &source_preference_key, + &workspace_key, ) .await { @@ -2208,6 +2337,7 @@ mod tests { description: "test".to_string(), input_schema: serde_json::json!({"type": "object"}), }, + ecosystem_id: "test".to_string(), provider_id: "test-provider".to_string(), runtime_target_id: runtime_target_id.to_string(), load_generation: 7, @@ -2249,6 +2379,7 @@ mod tests { description: "replacement".to_string(), input_schema: serde_json::json!({"type": "object"}), }, + ecosystem_id: "test".to_string(), provider_id: "test-provider".to_string(), runtime_target_id: runtime_target_id.to_string(), load_generation: 8, @@ -2286,6 +2417,7 @@ mod tests { description: "test".to_string(), input_schema: serde_json::json!({"type": "object"}), }, + ecosystem_id: "test".to_string(), provider_id: "test-provider".to_string(), runtime_target_id: "target".to_string(), load_generation: 8, @@ -2335,6 +2467,7 @@ mod tests { description: "external".to_string(), input_schema: serde_json::json!({"type": "object"}), }, + ecosystem_id: "test".to_string(), provider_id: "test-provider".to_string(), runtime_target_id: "not-loaded".to_string(), load_generation: 9, diff --git a/src/crates/assembly/external-sources/src/lib.rs b/src/crates/assembly/external-sources/src/lib.rs index 2297b8df83..78a7945313 100644 --- a/src/crates/assembly/external-sources/src/lib.rs +++ b/src/crates/assembly/external-sources/src/lib.rs @@ -22,7 +22,7 @@ pub use tool::{ }; use bitfun_product_domains::external_sources::{ - prompt_command_conflict_key, ExpandedPromptCommand, ExternalSourceCatalogEntry, + prompt_command_conflict_key, EcosystemId, ExpandedPromptCommand, ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceProviderError, ExternalSourceRecord, ExternalWatchRoot, PromptCommandAvailability, PromptCommandCatalogEntry, @@ -54,6 +54,7 @@ struct SourceGeneration { /// its own concurrency and timeout policy. pub struct ExternalSourceDiscoveryRequest { provider_id: ProviderId, + ecosystem_id: EcosystemId, provider: Arc, context: ExternalSourceContext, } @@ -63,6 +64,23 @@ impl ExternalSourceDiscoveryRequest { &self.provider_id } + pub fn ecosystem_id(&self) -> &EcosystemId { + &self.ecosystem_id + } + + pub fn disabled(self) -> ExternalSourceDiscoveryResult { + ExternalSourceDiscoveryResult { + provider_id: self.provider_id, + candidate: Ok(PromptCommandProviderSnapshot { + provider: self.provider.identity(), + sources: Vec::new(), + commands: Vec::new(), + unavailable_command_ids: Vec::new(), + diagnostics: Vec::new(), + }), + } + } + pub fn execute(self) -> ExternalSourceDiscoveryResult { let candidate = self.provider.discover(&self.context); ExternalSourceDiscoveryResult { @@ -171,6 +189,7 @@ impl ExternalSourceCoordinator { subagents: Vec::new(), subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), diagnostics: Vec::new(), }, }) @@ -190,6 +209,7 @@ impl ExternalSourceCoordinator { .iter() .map(|generation| ExternalSourceDiscoveryRequest { provider_id: generation.identity.provider_id.clone(), + ecosystem_id: generation.identity.ecosystem_id.clone(), provider: Arc::clone(&generation.provider), context: self.context.clone(), }) @@ -237,6 +257,13 @@ impl ExternalSourceCoordinator { self.snapshot.clone() } + pub fn ecosystem_for_provider(&self, provider_id: &ProviderId) -> Option { + self.providers + .iter() + .find(|provider| &provider.identity.provider_id == provider_id) + .map(|provider| provider.identity.ecosystem_id.clone()) + } + pub fn set_source_enabled(&mut self, stable_key: &str, enabled: bool) -> Result<(), String> { let known = self.providers.iter().any(|provider| { provider.last_success.as_ref().is_some_and(|snapshot| { @@ -380,6 +407,25 @@ impl ExternalSourceCoordinator { roots } + pub fn watch_roots_for_ecosystems( + &self, + ecosystems: &BTreeSet, + ) -> Vec { + let mut roots = self + .providers + .iter() + .filter(|provider| ecosystems.contains(&provider.identity.ecosystem_id)) + .flat_map(|provider| provider.provider.watch_roots(&self.context)) + .collect::>(); + roots.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.recursive.cmp(&right.recursive)) + }); + roots.dedup_by(|left, right| left.path == right.path && left.recursive == right.recursive); + roots + } + pub fn expand_command( &self, name: &str, @@ -669,6 +715,7 @@ impl ExternalSourceCoordinator { subagents: Vec::new(), subagent_conflicts: Vec::new(), pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), diagnostics, }; self.snapshot.clone() diff --git a/src/crates/assembly/external-sources/src/mcp.rs b/src/crates/assembly/external-sources/src/mcp.rs index 081f32d835..4db1912654 100644 --- a/src/crates/assembly/external-sources/src/mcp.rs +++ b/src/crates/assembly/external-sources/src/mcp.rs @@ -1,10 +1,10 @@ use bitfun_product_domains::external_sources::{ - ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, - ExternalMcpServerDefinition, ExternalMcpSourceProvider, ExternalMcpStaticStatus, - ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceContext, - ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceLifecycleState, - ExternalSourceProviderError, ExternalWatchRoot, PreparedExternalMcpServer, ProviderId, - SourceKey, SourceQualifiedMcpServerId, + EcosystemId, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, + ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpSourceProvider, + ExternalMcpStaticStatus, ExternalSourceAssetKind, ExternalSourceCatalogEntry, + ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalSourceLifecycleState, ExternalSourceProviderError, ExternalWatchRoot, + PreparedExternalMcpServer, ProviderId, SourceKey, SourceQualifiedMcpServerId, }; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -31,6 +31,7 @@ pub struct ExternalMcpCoordinatorSnapshot { /// parallel without teaching the coordinator about a concrete ecosystem. pub struct ExternalMcpDiscoveryRequest { provider_id: ProviderId, + ecosystem_id: EcosystemId, provider: Arc, input: ExternalMcpDiscoveryInput, } @@ -40,6 +41,22 @@ impl ExternalMcpDiscoveryRequest { &self.provider_id } + pub fn ecosystem_id(&self) -> &EcosystemId { + &self.ecosystem_id + } + + pub fn disabled(self) -> ExternalMcpDiscoveryResult { + ExternalMcpDiscoveryResult { + provider_id: self.provider_id, + candidate: Ok(ExternalMcpProviderSnapshot { + provider: self.provider.identity(), + sources: Vec::new(), + servers: Vec::new(), + diagnostics: Vec::new(), + }), + } + } + pub fn execute(self) -> ExternalMcpDiscoveryResult { ExternalMcpDiscoveryResult { provider_id: self.provider_id, @@ -143,6 +160,7 @@ impl ExternalMcpCoordinator { .iter() .map(|generation| ExternalMcpDiscoveryRequest { provider_id: generation.identity.provider_id.clone(), + ecosystem_id: generation.identity.ecosystem_id.clone(), provider: Arc::clone(&generation.provider), input: ExternalMcpDiscoveryInput { context: self.context.clone(), @@ -193,6 +211,13 @@ impl ExternalMcpCoordinator { self.snapshot.clone() } + pub fn ecosystem_for_provider(&self, provider_id: &ProviderId) -> Option { + self.providers + .iter() + .find(|provider| &provider.identity.provider_id == provider_id) + .map(|provider| provider.identity.ecosystem_id.clone()) + } + pub fn set_source_enabled(&mut self, stable_key: &str, enabled: bool) -> Result<(), String> { let known = self.providers.iter().any(|provider| { provider.last_success.as_ref().is_some_and(|snapshot| { @@ -291,6 +316,28 @@ impl ExternalMcpCoordinator { .collect() } + pub fn watch_roots_for_ecosystems( + &self, + ecosystems: &BTreeSet, + ) -> Vec { + let mut roots = BTreeMap::new(); + for provider in &self.providers { + if !ecosystems.contains(&provider.identity.ecosystem_id) { + continue; + } + for root in provider.provider.watch_roots(&self.context) { + roots + .entry(root.path) + .and_modify(|recursive| *recursive |= root.recursive) + .or_insert(root.recursive); + } + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } + fn suppressed_source_keys(&self) -> BTreeSet { self.providers .iter() diff --git a/src/crates/assembly/external-sources/src/subagent.rs b/src/crates/assembly/external-sources/src/subagent.rs index 37305022ef..597df5efe7 100644 --- a/src/crates/assembly/external-sources/src/subagent.rs +++ b/src/crates/assembly/external-sources/src/subagent.rs @@ -1,5 +1,5 @@ use bitfun_product_domains::external_sources::{ - ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceDiagnostic, + EcosystemId, ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, ExternalSourceLifecycleState, ExternalSourceProviderError, ExternalSourceRecord, ExternalWatchRoot, ProviderId, }; @@ -40,6 +40,7 @@ pub struct ExternalSubagentCoordinatorSnapshot { pub struct ExternalSubagentDiscoveryRequest { provider_id: ProviderId, + ecosystem_id: EcosystemId, provider: Arc, input: ExternalSubagentDiscoveryInput, } @@ -49,6 +50,22 @@ impl ExternalSubagentDiscoveryRequest { &self.provider_id } + pub fn ecosystem_id(&self) -> &EcosystemId { + &self.ecosystem_id + } + + pub fn disabled(self) -> ExternalSubagentDiscoveryResult { + ExternalSubagentDiscoveryResult { + provider_id: self.provider_id, + candidate: Ok(ExternalSubagentProviderSnapshot { + provider: self.provider.identity(), + sources: Vec::new(), + definitions: Vec::new(), + diagnostics: Vec::new(), + }), + } + } + pub fn input(&self) -> &ExternalSubagentDiscoveryInput { &self.input } @@ -171,6 +188,7 @@ impl ExternalSubagentCoordinator { .iter() .map(|generation| ExternalSubagentDiscoveryRequest { provider_id: generation.identity.provider_id.clone(), + ecosystem_id: generation.identity.ecosystem_id.clone(), provider: generation.provider.clone(), input: ExternalSubagentDiscoveryInput { context: self.context.clone(), @@ -234,6 +252,13 @@ impl ExternalSubagentCoordinator { self.snapshot.clone() } + pub fn ecosystem_for_provider(&self, provider_id: &ProviderId) -> Option { + self.providers + .iter() + .find(|provider| &provider.identity.provider_id == provider_id) + .map(|provider| provider.identity.ecosystem_id.clone()) + } + pub fn replace_suppressed_sources(&mut self, sources: BTreeSet) { self.suppressed_sources = sources; self.rebuild_snapshot_at(Instant::now()); @@ -280,6 +305,28 @@ impl ExternalSubagentCoordinator { .collect() } + pub fn watch_roots_for_ecosystems( + &self, + ecosystems: &BTreeSet, + ) -> Vec { + let mut roots = BTreeMap::new(); + for provider in &self.providers { + if !ecosystems.contains(&provider.identity.ecosystem_id) { + continue; + } + for root in provider.provider.watch_roots(&self.context) { + roots + .entry(root.path) + .and_modify(|recursive| *recursive |= root.recursive) + .or_insert(root.recursive); + } + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } + fn rebuild_snapshot_at(&mut self, now: Instant) -> ExternalSubagentCoordinatorSnapshot { self.generation = self.generation.saturating_add(1); let mut sources = Vec::new(); diff --git a/src/crates/assembly/external-sources/src/tool.rs b/src/crates/assembly/external-sources/src/tool.rs index 870f1c38c3..96a0aadff0 100644 --- a/src/crates/assembly/external-sources/src/tool.rs +++ b/src/crates/assembly/external-sources/src/tool.rs @@ -1,5 +1,5 @@ use bitfun_product_domains::external_sources::{ - ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceContext, + EcosystemId, ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, ExternalSourceLifecycleState, ExternalSourceProviderError, ExternalToolDefinition, ExternalToolProviderIdentity, ExternalToolProviderSnapshot, ExternalToolSourceProvider, ExternalWatchRoot, @@ -28,6 +28,7 @@ pub struct ExternalToolCoordinatorSnapshot { pub struct ExternalToolDiscoveryRequest { provider_id: ProviderId, + ecosystem_id: EcosystemId, provider: Arc, context: ExternalSourceContext, } @@ -37,6 +38,22 @@ impl ExternalToolDiscoveryRequest { &self.provider_id } + pub fn ecosystem_id(&self) -> &EcosystemId { + &self.ecosystem_id + } + + pub fn disabled(self) -> ExternalToolDiscoveryResult { + ExternalToolDiscoveryResult { + provider_id: self.provider_id, + candidate: Ok(ExternalToolProviderSnapshot { + provider: self.provider.identity(), + sources: Vec::new(), + tools: Vec::new(), + diagnostics: Vec::new(), + }), + } + } + pub fn execute(self) -> ExternalToolDiscoveryResult { ExternalToolDiscoveryResult { provider_id: self.provider_id, @@ -137,6 +154,7 @@ impl ExternalToolCoordinator { .iter() .map(|generation| ExternalToolDiscoveryRequest { provider_id: generation.identity.provider_id.clone(), + ecosystem_id: generation.identity.ecosystem_id.clone(), provider: generation.provider.clone(), context: self.context.clone(), }) @@ -184,6 +202,13 @@ impl ExternalToolCoordinator { self.snapshot.clone() } + pub fn ecosystem_for_provider(&self, provider_id: &ProviderId) -> Option { + self.providers + .iter() + .find(|provider| &provider.identity.provider_id == provider_id) + .map(|provider| provider.identity.ecosystem_id.clone()) + } + pub fn set_source_enabled(&mut self, stable_key: &str, enabled: bool) -> Result<(), String> { let known = self.providers.iter().any(|provider| { provider.last_success.as_ref().is_some_and(|snapshot| { @@ -271,6 +296,28 @@ impl ExternalToolCoordinator { .collect() } + pub fn watch_roots_for_ecosystems( + &self, + ecosystems: &BTreeSet, + ) -> Vec { + let mut roots = BTreeMap::new(); + for provider in &self.providers { + if !ecosystems.contains(&provider.identity.ecosystem_id) { + continue; + } + for root in provider.provider.watch_roots(&self.context) { + roots + .entry(root.path) + .and_modify(|recursive| *recursive |= root.recursive) + .or_insert(root.recursive); + } + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } + fn rebuild_snapshot(&mut self) -> ExternalToolCoordinatorSnapshot { self.generation = self.generation.saturating_add(1); let mut sources = Vec::new(); diff --git a/src/crates/assembly/external-sources/tests/coordinator_contracts.rs b/src/crates/assembly/external-sources/tests/coordinator_contracts.rs index 5a2b8b25fd..78deb5cd32 100644 --- a/src/crates/assembly/external-sources/tests/coordinator_contracts.rs +++ b/src/crates/assembly/external-sources/tests/coordinator_contracts.rs @@ -127,6 +127,36 @@ impl PromptCommandSourceProvider for FakeProvider { } } +#[test] +fn assembly_can_disable_one_ecosystem_without_clearing_other_provider_generations() { + let first = FakeProvider::new("first", "ecosystem.first", "project", 10); + let second = FakeProvider::new("second", "ecosystem.second", "project", 20); + let mut coordinator = + ExternalSourceCoordinator::new(context(), vec![Arc::new(first), Arc::new(second)]) + .expect("construct coordinator"); + + let results = coordinator + .discovery_requests() + .into_iter() + .map(|request| { + if request.ecosystem_id().as_str() == "ecosystem.second" { + request.disabled() + } else { + request.execute() + } + }) + .collect(); + let snapshot = coordinator.apply_discovery_results(results); + + assert_eq!(snapshot.sources.len(), 1); + assert_eq!( + snapshot.sources[0].record.ecosystem_id.as_str(), + "ecosystem.first" + ); + assert_eq!(snapshot.commands.len(), 1); + assert_eq!(snapshot.commands[0].definition.name, "review"); +} + #[test] fn provider_failure_isolated_and_successful_deletion_withdraws_only_its_generation() { let first = FakeProvider::new("first", "ecosystem.first", "project", 10); diff --git a/src/crates/contracts/product-domains/src/external_integration_policy.rs b/src/crates/contracts/product-domains/src/external_integration_policy.rs new file mode 100644 index 0000000000..65cf7271c2 --- /dev/null +++ b/src/crates/contracts/product-domains/src/external_integration_policy.rs @@ -0,0 +1,711 @@ +//! Ecosystem-neutral policy contracts for external integrations. +//! +//! Product assembly registers ecosystems and declares capability defaults and +//! safety ceilings. This module only preserves, evaluates, and projects policy; +//! it does not know about OpenCode or any other concrete ecosystem. + +use crate::external_sources::{ + validate_id, EcosystemId, ExternalIntegrationCapabilityId, ExternalSourceContractError, +}; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::collections::{BTreeMap, BTreeSet}; + +pub const EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR: u32 = 1; + +/// Product-facing integration modes stay open so an older Host can preserve a +/// policy written by a newer Host. Unknown values fail closed during policy +/// evaluation and are never projected as selectable UI options. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum ExternalIntegrationMode { + Recommended, + DiscoverOnly, + Disabled, + Custom, + Unknown(String), +} + +impl ExternalIntegrationMode { + pub fn as_str(&self) -> &str { + match self { + Self::Recommended => "recommended", + Self::DiscoverOnly => "discover_only", + Self::Disabled => "disabled", + Self::Custom => "custom", + Self::Unknown(value) => value, + } + } + + pub fn is_known(&self) -> bool { + !matches!(self, Self::Unknown(_)) + } + + fn parse(value: String) -> Result { + validate_id(&value, "external integration mode")?; + Ok(match value.as_str() { + "recommended" => Self::Recommended, + "discover_only" => Self::DiscoverOnly, + "disabled" => Self::Disabled, + "custom" => Self::Custom, + _ => Self::Unknown(value), + }) + } +} + +impl Default for ExternalIntegrationMode { + fn default() -> Self { + Self::Recommended + } +} + +impl Serialize for ExternalIntegrationMode { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ExternalIntegrationMode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::parse(String::deserialize(deserializer)?).map_err(D::Error::custom) + } +} + +/// Access decisions are ordered from most restrictive to most permissive. +/// Unknown values are preserved for forward compatibility and evaluate to +/// `Disabled` on Hosts that do not understand them. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum ExternalIntegrationAccess { + Disabled, + DiscoverOnly, + AskBeforeUse, + Auto, + Unknown(String), +} + +impl ExternalIntegrationAccess { + pub fn as_str(&self) -> &str { + match self { + Self::Disabled => "disabled", + Self::DiscoverOnly => "discover_only", + Self::AskBeforeUse => "ask_before_use", + Self::Auto => "auto", + Self::Unknown(value) => value, + } + } + + pub fn is_known(&self) -> bool { + !matches!(self, Self::Unknown(_)) + } + + fn parse(value: String) -> Result { + validate_id(&value, "external integration access")?; + Ok(match value.as_str() { + "disabled" => Self::Disabled, + "discover_only" => Self::DiscoverOnly, + "ask_before_use" => Self::AskBeforeUse, + "auto" => Self::Auto, + _ => Self::Unknown(value), + }) + } + + fn rank(&self) -> u8 { + match self { + Self::Unknown(_) | Self::Disabled => 0, + Self::DiscoverOnly => 1, + Self::AskBeforeUse => 2, + Self::Auto => 3, + } + } + + fn at_most(self, ceiling: Self) -> (Self, bool) { + if matches!(self, Self::Unknown(_)) { + return (Self::Disabled, true); + } + if self.rank() <= ceiling.rank() { + (self, false) + } else { + (ceiling, true) + } + } +} + +impl Default for ExternalIntegrationAccess { + fn default() -> Self { + Self::DiscoverOnly + } +} + +impl Serialize for ExternalIntegrationAccess { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ExternalIntegrationAccess { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::parse(String::deserialize(deserializer)?).map_err(D::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ExternalEcosystemPolicy { + pub mode: ExternalIntegrationMode, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub capability_overrides: BTreeMap, + /// Preserves fields introduced by a newer minor schema during read-modify-write. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +impl Default for ExternalEcosystemPolicy { + fn default() -> Self { + Self { + mode: ExternalIntegrationMode::Recommended, + capability_overrides: BTreeMap::new(), + extensions: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ExternalIntegrationPolicySettings { + pub enabled: bool, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub ecosystems: BTreeMap, + /// Preserves fields introduced by a newer minor schema during read-modify-write. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +impl Default for ExternalIntegrationPolicySettings { + fn default() -> Self { + Self { + enabled: true, + ecosystems: BTreeMap::new(), + extensions: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct ExternalEcosystemPolicyOverride { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub capability_overrides: BTreeMap, + /// Preserves fields introduced by a newer minor schema during read-modify-write. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct ExternalIntegrationPolicyOverride { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub ecosystems: BTreeMap, + /// Preserves fields introduced by a newer minor schema during read-modify-write. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +impl ExternalIntegrationPolicyOverride { + pub fn is_empty(&self) -> bool { + self.enabled.is_none() && self.ecosystems.is_empty() && self.extensions.is_empty() + } +} + +fn current_external_integration_schema_major() -> u32 { + EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ExternalIntegrationPolicyDocument { + #[serde(default = "current_external_integration_schema_major")] + pub schema_major: u32, + pub user_defaults: ExternalIntegrationPolicySettings, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub workspace_overrides: BTreeMap, + /// Preserves fields introduced by a newer minor schema during read-modify-write. + #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +impl Default for ExternalIntegrationPolicyDocument { + fn default() -> Self { + Self { + schema_major: EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR, + user_defaults: ExternalIntegrationPolicySettings::default(), + workspace_overrides: BTreeMap::new(), + extensions: BTreeMap::new(), + } + } +} + +/// Capability defaults are registered by product assembly so policy evaluation +/// remains neutral and future ecosystems can declare their own safe profile. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalIntegrationCapabilityDescriptor { + pub capability_id: ExternalIntegrationCapabilityId, + pub recommended_access: ExternalIntegrationAccess, + pub safety_ceiling: ExternalIntegrationAccess, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalIntegrationEcosystemDescriptor { + pub ecosystem_id: EcosystemId, + pub display_name: String, + pub adapter_revision: String, + pub capabilities: Vec, +} + +/// Public, compatibility-safe projection of user policy settings. Persistence +/// extension fields intentionally stay out of Host APIs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalIntegrationPolicySettingsView { + pub enabled: bool, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub ecosystems: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalEcosystemPolicyView { + pub mode: ExternalIntegrationMode, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub capability_overrides: BTreeMap, +} + +impl From<&ExternalIntegrationPolicySettings> for ExternalIntegrationPolicySettingsView { + fn from(settings: &ExternalIntegrationPolicySettings) -> Self { + Self { + enabled: settings.enabled, + ecosystems: settings + .ecosystems + .iter() + .map(|(id, policy)| { + ( + id.clone(), + ExternalEcosystemPolicyView { + mode: policy.mode.clone(), + capability_overrides: policy.capability_overrides.clone(), + }, + ) + }) + .collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalIntegrationPolicyOverrideView { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub ecosystems: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalEcosystemPolicyOverrideView { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub capability_overrides: BTreeMap, +} + +impl From<&ExternalIntegrationPolicyOverride> for ExternalIntegrationPolicyOverrideView { + fn from(policy: &ExternalIntegrationPolicyOverride) -> Self { + Self { + enabled: policy.enabled, + ecosystems: policy + .ecosystems + .iter() + .map(|(id, ecosystem)| { + ( + id.clone(), + ExternalEcosystemPolicyOverrideView { + mode: ecosystem.mode.clone(), + capability_overrides: ecosystem.capability_overrides.clone(), + }, + ) + }) + .collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum ExternalIntegrationPolicyStatus { + Compatible, + IncompatibleSchema, + Unknown(String), +} + +impl ExternalIntegrationPolicyStatus { + pub fn as_str(&self) -> &str { + match self { + Self::Compatible => "compatible", + Self::IncompatibleSchema => "incompatible_schema", + Self::Unknown(value) => value, + } + } + + pub fn is_compatible(&self) -> bool { + matches!(self, Self::Compatible) + } + + fn parse(value: String) -> Result { + validate_id(&value, "external integration policy status")?; + Ok(match value.as_str() { + "compatible" => Self::Compatible, + "incompatible_schema" => Self::IncompatibleSchema, + _ => Self::Unknown(value), + }) + } +} + +impl Default for ExternalIntegrationPolicyStatus { + fn default() -> Self { + Self::Compatible + } +} + +impl Serialize for ExternalIntegrationPolicyStatus { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ExternalIntegrationPolicyStatus { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::parse(String::deserialize(deserializer)?).map_err(D::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EffectiveExternalEcosystemPolicy { + pub ecosystem_id: EcosystemId, + pub mode: ExternalIntegrationMode, + pub capabilities: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub policy_limited_capabilities: BTreeSet, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EffectiveExternalIntegrationPolicy { + pub enabled: bool, + pub ecosystems: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalIntegrationPolicySnapshot { + pub schema_major: u32, + pub status: ExternalIntegrationPolicyStatus, + pub user_defaults: ExternalIntegrationPolicySettingsView, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_override: Option, + pub global_effective: EffectiveExternalIntegrationPolicy, + pub effective: EffectiveExternalIntegrationPolicy, + pub registered_ecosystems: Vec, +} + +impl Default for ExternalIntegrationPolicySnapshot { + fn default() -> Self { + Self { + schema_major: EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR, + status: ExternalIntegrationPolicyStatus::Compatible, + user_defaults: ExternalIntegrationPolicySettingsView::from( + &ExternalIntegrationPolicySettings::default(), + ), + workspace_override: None, + global_effective: EffectiveExternalIntegrationPolicy { + enabled: true, + ecosystems: BTreeMap::new(), + }, + effective: EffectiveExternalIntegrationPolicy { + enabled: true, + ecosystems: BTreeMap::new(), + }, + registered_ecosystems: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalIntegrationPolicyScope { + User, + Workspace, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "operation", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +#[non_exhaustive] +pub enum ExternalIntegrationPolicyOperation { + SetEnabled { + enabled: bool, + }, + SetEcosystemMode { + ecosystem_id: EcosystemId, + mode: ExternalIntegrationMode, + }, + SetCapabilityAccess { + ecosystem_id: EcosystemId, + capability_id: ExternalIntegrationCapabilityId, + access: ExternalIntegrationAccess, + }, + ResetWorkspace, + ResetIncompatiblePolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalIntegrationPolicyMutation { + pub expected_preference_revision: u64, + pub scope: ExternalIntegrationPolicyScope, + pub change: ExternalIntegrationPolicyOperation, +} + +fn validate_registered_ecosystems( + registered_ecosystems: &[ExternalIntegrationEcosystemDescriptor], +) -> Result<(), ExternalSourceContractError> { + let mut ecosystem_ids = BTreeSet::new(); + for ecosystem in registered_ecosystems { + if !ecosystem_ids.insert(ecosystem.ecosystem_id.clone()) { + return Err(ExternalSourceContractError::InvalidPolicyDescriptor( + "duplicate ecosystem id", + )); + } + validate_id(&ecosystem.adapter_revision, "adapter revision")?; + let mut capability_ids = BTreeSet::new(); + for capability in &ecosystem.capabilities { + if !capability_ids.insert(capability.capability_id.clone()) { + return Err(ExternalSourceContractError::InvalidPolicyDescriptor( + "duplicate capability id", + )); + } + if !capability.recommended_access.is_known() || !capability.safety_ceiling.is_known() { + return Err(ExternalSourceContractError::InvalidPolicyDescriptor( + "unknown capability access", + )); + } + if capability.recommended_access.rank() > capability.safety_ceiling.rank() { + return Err(ExternalSourceContractError::InvalidPolicyDescriptor( + "recommended access exceeds the safety ceiling", + )); + } + } + } + Ok(()) +} + +pub fn evaluate_external_integration_policy( + document: &ExternalIntegrationPolicyDocument, + workspace_key: Option<&str>, + registered_ecosystems: &[ExternalIntegrationEcosystemDescriptor], +) -> Result { + if document.schema_major != EXTERNAL_INTEGRATION_POLICY_SCHEMA_MAJOR { + return Err(ExternalSourceContractError::UnsupportedPolicySchemaMajor( + document.schema_major, + )); + } + validate_registered_ecosystems(registered_ecosystems)?; + let workspace_override = workspace_key.and_then(|key| document.workspace_overrides.get(key)); + let enabled = workspace_override + .and_then(|policy| policy.enabled) + .unwrap_or(document.user_defaults.enabled); + let mut ecosystems = BTreeMap::new(); + for descriptor in registered_ecosystems { + let user_policy = document + .user_defaults + .ecosystems + .get(&descriptor.ecosystem_id) + .cloned() + .unwrap_or_default(); + let workspace_policy = + workspace_override.and_then(|policy| policy.ecosystems.get(&descriptor.ecosystem_id)); + let mode = if enabled { + workspace_policy + .and_then(|policy| policy.mode.clone()) + .unwrap_or_else(|| user_policy.mode.clone()) + } else { + ExternalIntegrationMode::Disabled + }; + let mut capabilities = BTreeMap::new(); + let mut policy_limited_capabilities = BTreeSet::new(); + for capability in &descriptor.capabilities { + let configured = workspace_policy + .and_then(|policy| policy.capability_overrides.get(&capability.capability_id)) + .or_else(|| { + user_policy + .capability_overrides + .get(&capability.capability_id) + }); + let requested = if !enabled { + ExternalIntegrationAccess::Disabled + } else { + match &mode { + ExternalIntegrationMode::Recommended => capability.recommended_access.clone(), + ExternalIntegrationMode::DiscoverOnly => { + ExternalIntegrationAccess::DiscoverOnly + } + ExternalIntegrationMode::Disabled | ExternalIntegrationMode::Unknown(_) => { + ExternalIntegrationAccess::Disabled + } + ExternalIntegrationMode::Custom => configured + .cloned() + .unwrap_or(ExternalIntegrationAccess::DiscoverOnly), + } + }; + let (effective, limited) = requested.at_most(capability.safety_ceiling.clone()); + if limited { + policy_limited_capabilities.insert(capability.capability_id.clone()); + } + capabilities.insert(capability.capability_id.clone(), effective); + } + ecosystems.insert( + descriptor.ecosystem_id.clone(), + EffectiveExternalEcosystemPolicy { + ecosystem_id: descriptor.ecosystem_id.clone(), + mode, + capabilities, + policy_limited_capabilities, + }, + ); + } + Ok(EffectiveExternalIntegrationPolicy { + enabled, + ecosystems, + }) +} + +pub fn external_integration_policy_snapshot( + document: &ExternalIntegrationPolicyDocument, + workspace_key: Option<&str>, + registered_ecosystems: Vec, +) -> Result { + validate_registered_ecosystems(®istered_ecosystems)?; + let workspace_override = workspace_key + .and_then(|key| document.workspace_overrides.get(key)) + .map(ExternalIntegrationPolicyOverrideView::from); + let (status, global_effective, effective) = match ( + evaluate_external_integration_policy(document, None, ®istered_ecosystems), + evaluate_external_integration_policy(document, workspace_key, ®istered_ecosystems), + ) { + (Ok(global_effective), Ok(effective)) => ( + ExternalIntegrationPolicyStatus::Compatible, + global_effective, + effective, + ), + ( + Err(ExternalSourceContractError::UnsupportedPolicySchemaMajor(_)), + Err(ExternalSourceContractError::UnsupportedPolicySchemaMajor(_)), + ) => { + return incompatible_external_integration_policy_snapshot( + document.schema_major, + registered_ecosystems, + ) + } + (Err(error), _) | (_, Err(error)) => return Err(error), + }; + Ok(ExternalIntegrationPolicySnapshot { + schema_major: document.schema_major, + status, + user_defaults: ExternalIntegrationPolicySettingsView::from(&document.user_defaults), + workspace_override, + global_effective, + effective, + registered_ecosystems, + }) +} + +/// Build a public, fail-closed projection for a policy document whose major +/// schema is not understood by this Host. The raw document stays exclusively +/// at the persistence boundary and is never reflected through Host APIs. +pub fn incompatible_external_integration_policy_snapshot( + schema_major: u32, + registered_ecosystems: Vec, +) -> Result { + validate_registered_ecosystems(®istered_ecosystems)?; + let disabled = || EffectiveExternalIntegrationPolicy { + enabled: false, + ecosystems: registered_ecosystems + .iter() + .map(|descriptor| { + ( + descriptor.ecosystem_id.clone(), + EffectiveExternalEcosystemPolicy { + ecosystem_id: descriptor.ecosystem_id.clone(), + mode: ExternalIntegrationMode::Disabled, + capabilities: descriptor + .capabilities + .iter() + .map(|capability| { + ( + capability.capability_id.clone(), + ExternalIntegrationAccess::Disabled, + ) + }) + .collect(), + policy_limited_capabilities: descriptor + .capabilities + .iter() + .map(|capability| capability.capability_id.clone()) + .collect(), + }, + ) + }) + .collect(), + }; + Ok(ExternalIntegrationPolicySnapshot { + schema_major, + status: ExternalIntegrationPolicyStatus::IncompatibleSchema, + user_defaults: ExternalIntegrationPolicySettingsView { + enabled: false, + ecosystems: BTreeMap::new(), + }, + workspace_override: None, + global_effective: disabled(), + effective: disabled(), + registered_ecosystems, + }) +} diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index bb7fa207d3..68907cab1e 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -4,6 +4,7 @@ //! surfaces and lifecycle coordination consume these types without branching on //! a concrete ecosystem or carrying arbitrary extension payloads. +use crate::external_integration_policy::ExternalIntegrationPolicySnapshot; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; @@ -14,7 +15,10 @@ const MAX_ID_LENGTH: usize = 160; const MAX_TOOL_NAME_LENGTH: usize = 64; const MAX_TEXT_LENGTH: usize = 4096; -fn validate_id(value: &str, label: &'static str) -> Result<(), ExternalSourceContractError> { +pub(crate) fn validate_id( + value: &str, + label: &'static str, +) -> Result<(), ExternalSourceContractError> { if value.is_empty() || value.len() > MAX_ID_LENGTH || value.trim() != value @@ -36,6 +40,8 @@ fn validate_text(value: &str, label: &'static str) -> Result<(), ExternalSourceC pub enum ExternalSourceContractError { InvalidIdentifier(&'static str), InvalidText(&'static str), + InvalidPolicyDescriptor(&'static str), + UnsupportedPolicySchemaMajor(u32), } impl fmt::Display for ExternalSourceContractError { @@ -43,12 +49,130 @@ impl fmt::Display for ExternalSourceContractError { match self { Self::InvalidIdentifier(label) => write!(formatter, "invalid {label} identifier"), Self::InvalidText(label) => write!(formatter, "invalid {label} text"), + Self::InvalidPolicyDescriptor(reason) => { + write!( + formatter, + "invalid external integration descriptor: {reason}" + ) + } + Self::UnsupportedPolicySchemaMajor(major) => { + write!( + formatter, + "unsupported external integration policy schema major: {major}" + ) + } } } } impl Error for ExternalSourceContractError {} +/// Stable product error codes shared by Desktop, Server, CLI and remote hosts. +/// User-facing copy is owned by each surface; `detail` is bounded diagnostic +/// context and must not be used for control flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSourceOperationErrorCode { + InvalidRequest, + HostUnavailable, + HostCapabilityUnavailable, + PolicyIncompatible, + PolicyLimited, + StaleRevision, + Conflict, + NotFound, + Unavailable, + Internal, +} + +impl ExternalSourceOperationErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::HostUnavailable => "host_unavailable", + Self::HostCapabilityUnavailable => "host_capability_unavailable", + Self::PolicyIncompatible => "policy_incompatible", + Self::PolicyLimited => "policy_limited", + Self::StaleRevision => "stale_revision", + Self::Conflict => "conflict", + Self::NotFound => "not_found", + Self::Unavailable => "unavailable", + Self::Internal => "internal", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceOperationError { + pub code: ExternalSourceOperationErrorCode, + pub detail: String, + pub retryable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, +} + +impl ExternalSourceOperationError { + pub fn new( + code: ExternalSourceOperationErrorCode, + detail: impl Into, + retryable: bool, + ) -> Self { + let detail = detail.into(); + Self { + code, + detail: detail.chars().take(MAX_TEXT_LENGTH).collect(), + retryable, + correlation_id: None, + } + } + + pub fn with_correlation_id(mut self, correlation_id: impl Into) -> Self { + self.correlation_id = Some(correlation_id.into().chars().take(MAX_ID_LENGTH).collect()); + self + } + + /// Encode a typed failure while legacy internal call paths are migrated + /// away from `Result<_, String>`. Decoding is exact JSON parsing; callers + /// must never infer error categories from message text. + pub fn encode(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| { + r#"{"code":"internal","detail":"External source operation failed","retryable":false}"# + .to_string() + }) + } + + pub fn decode(encoded: &str) -> Option { + serde_json::from_str(encoded).ok() + } + + pub fn host_capability_unavailable(detail: impl Into) -> Self { + Self::new( + ExternalSourceOperationErrorCode::HostCapabilityUnavailable, + detail, + false, + ) + } + + pub fn invalid_request(detail: impl Into) -> Self { + Self::new( + ExternalSourceOperationErrorCode::InvalidRequest, + detail, + false, + ) + } +} + +impl fmt::Display for ExternalSourceOperationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code.as_str(), self.detail) + } +} + +impl Error for ExternalSourceOperationError {} + +pub type ExternalSourceOperationResult = Result; + macro_rules! open_id { ($name:ident, $label:literal) => { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -83,6 +207,10 @@ open_id!(CommandLocalId, "command"); open_id!(ToolTargetLocalId, "tool target"); open_id!(ToolExportLocalId, "tool export"); open_id!(McpServerLocalId, "MCP server"); +open_id!( + ExternalIntegrationCapabilityId, + "external integration capability" +); #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -1506,6 +1634,150 @@ pub struct ExternalSourceCatalogSnapshot { pub subagent_conflicts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pending_subagent_approvals: Vec, + /// Effective policy is owned by product assembly and projected unchanged + /// to every product surface. + #[serde(default)] + pub integration_policy: ExternalIntegrationPolicySnapshot, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub diagnostics: Vec, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalPromptCommandDefinitionSummary { + pub id: SourceQualifiedCommandId, + pub name: String, + pub description: String, + pub availability: PromptCommandAvailability, + pub content_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalPromptCommandSummary { + pub definition: ExternalPromptCommandDefinitionSummary, +} + +/// Stable cross-host projection. Executable prompt templates and prepared +/// runtime payloads never cross a product-surface transport boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourceHostCapabilities { + pub can_refresh: bool, + pub can_mutate_policy: bool, + pub can_manage_sources: bool, + pub can_approve_runtime: bool, + pub can_execute_external_assets: bool, +} + +impl ExternalSourceHostCapabilities { + pub const fn read_write() -> Self { + Self { + can_refresh: true, + can_mutate_policy: true, + can_manage_sources: true, + can_approve_runtime: true, + can_execute_external_assets: true, + } + } + + pub const fn read_only_projection() -> Self { + Self { + can_refresh: true, + can_mutate_policy: false, + can_manage_sources: false, + can_approve_runtime: false, + can_execute_external_assets: false, + } + } +} + +impl Default for ExternalSourceHostCapabilities { + fn default() -> Self { + Self::read_write() + } +} + +/// Stable cross-host projection. Executable prompt templates and prepared +/// runtime payloads never cross a product-surface transport boundary. Host +/// capability facts are transport-owned and do not alter the authoritative +/// product catalog or persisted policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSourcePublicSnapshot { + #[serde(default)] + pub host_capabilities: ExternalSourceHostCapabilities, + pub generation: u64, + pub discovery_pending: bool, + pub sources: Vec, + pub commands: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub command_conflicts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_approval_requests: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_conflicts: Vec, + #[serde(default)] + pub mcp_generation: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_servers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_approval_requests: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_conflicts: Vec, + #[serde(default)] + pub subagent_generation: u64, + #[serde(default)] + pub preference_revision: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subagents: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subagent_conflicts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pending_subagent_approvals: Vec, + #[serde(default)] + pub integration_policy: ExternalIntegrationPolicySnapshot, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl From for ExternalSourcePublicSnapshot { + fn from(snapshot: ExternalSourceCatalogSnapshot) -> Self { + Self { + host_capabilities: ExternalSourceHostCapabilities::read_write(), + generation: snapshot.generation, + discovery_pending: snapshot.discovery_pending, + sources: snapshot.sources, + commands: snapshot + .commands + .into_iter() + .map(|entry| ExternalPromptCommandSummary { + definition: ExternalPromptCommandDefinitionSummary { + id: entry.definition.id, + name: entry.definition.name, + description: entry.definition.description, + availability: entry.definition.availability, + content_version: entry.definition.content_version, + }, + }) + .collect(), + command_conflicts: snapshot.command_conflicts, + tools: snapshot.tools, + tool_approval_requests: snapshot.tool_approval_requests, + tool_conflicts: snapshot.tool_conflicts, + mcp_generation: snapshot.mcp_generation, + mcp_servers: snapshot.mcp_servers, + mcp_approval_requests: snapshot.mcp_approval_requests, + mcp_conflicts: snapshot.mcp_conflicts, + subagent_generation: snapshot.subagent_generation, + preference_revision: snapshot.preference_revision, + subagents: snapshot.subagents, + subagent_conflicts: snapshot.subagent_conflicts, + pending_subagent_approvals: snapshot.pending_subagent_approvals, + integration_policy: snapshot.integration_policy, + diagnostics: snapshot.diagnostics, + } + } +} diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index 9967ce10ef..bfeb1af98f 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -5,6 +5,9 @@ pub mod canvas; +#[cfg(feature = "external-sources")] +pub mod external_integration_policy; + #[cfg(feature = "external-sources")] pub mod external_sources; diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index cbc0c993bf..d49019a47e 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -1,14 +1,23 @@ +use bitfun_product_domains::external_integration_policy::{ + evaluate_external_integration_policy, external_integration_policy_snapshot, + ExternalEcosystemPolicy, ExternalEcosystemPolicyOverride, ExternalIntegrationAccess, + ExternalIntegrationCapabilityDescriptor, ExternalIntegrationEcosystemDescriptor, + ExternalIntegrationMode, ExternalIntegrationPolicyDocument, ExternalIntegrationPolicyOverride, + ExternalIntegrationPolicyStatus, +}; use bitfun_product_domains::external_sources::{ external_mcp_approval_key, external_mcp_conflict_key, external_tool_approval_key, external_tool_conflict_key, prompt_command_conflict_key, EcosystemId, ExecutionDomainId, - ExpandedPromptCommand, ExternalMcpActivationState, ExternalMcpApprovalRequest, - ExternalMcpCatalogEntry, ExternalMcpConflict, ExternalMcpConflictCandidate, - ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, - ExternalMcpServerDefinition, ExternalMcpStaticStatus, ExternalMcpTransportKind, - ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, - ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalToolCapability, - ExternalToolDefinition, ExternalToolRuntimeKind, ExternalToolStaticStatus, ExternalWatchRoot, - PreparedExternalMcpServer, PreparedExternalMcpTransport, PromptCommandAvailability, + ExpandedPromptCommand, ExternalIntegrationCapabilityId, ExternalMcpActivationState, + ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, + ExternalMcpConflictCandidate, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity, + ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpStaticStatus, + ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, + ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalSourceProviderError, ExternalSourcePublicSnapshot, ExternalSourceRecord, + ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, + ExternalToolStaticStatus, ExternalWatchRoot, PreparedExternalMcpServer, + PreparedExternalMcpTransport, PromptCommandAvailability, PromptCommandCatalogEntry, PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot, PromptCommandSourceProvider, SecretValue, SourceKey, SourceQualifiedCommandId, SourceQualifiedMcpServerId, SourceQualifiedToolId, SourceQualifiedToolTargetId, @@ -846,3 +855,341 @@ fn external_mcp_product_view_is_version_guarded_and_contains_only_disclosed_fiel assert!(!encoded.contains("Bearer secret")); assert!(encoded.contains("approval_required")); } + +fn external_capability(value: &str) -> ExternalIntegrationCapabilityId { + ExternalIntegrationCapabilityId::new(value).expect("valid external capability id") +} + +const TEST_ECOSYSTEM_ID: &str = "test-ecosystem"; +const EXTERNAL_CAPABILITY_COMMAND: &str = "command"; +const EXTERNAL_CAPABILITY_TOOL: &str = "tool"; +const EXTERNAL_CAPABILITY_SUBAGENT: &str = "subagent"; +const EXTERNAL_CAPABILITY_MCP: &str = "mcp"; + +fn test_external_integration_ecosystems() -> Vec { + let capability = + |id, recommended_access, safety_ceiling| ExternalIntegrationCapabilityDescriptor { + capability_id: external_capability(id), + recommended_access, + safety_ceiling, + }; + vec![ExternalIntegrationEcosystemDescriptor { + ecosystem_id: EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(), + display_name: "Test ecosystem".to_string(), + adapter_revision: "1".to_string(), + capabilities: vec![ + capability( + EXTERNAL_CAPABILITY_COMMAND, + ExternalIntegrationAccess::Auto, + ExternalIntegrationAccess::Auto, + ), + capability( + EXTERNAL_CAPABILITY_TOOL, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + capability( + EXTERNAL_CAPABILITY_SUBAGENT, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + capability( + EXTERNAL_CAPABILITY_MCP, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + ], + }] +} + +#[test] +fn recommended_external_integration_policy_is_low_friction_and_fail_closed() { + let effective = evaluate_external_integration_policy( + &ExternalIntegrationPolicyDocument::default(), + Some("workspace-a"), + &test_external_integration_ecosystems(), + ) + .expect("default policy evaluates"); + let opencode = effective + .ecosystems + .get(&EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap()) + .expect("test ecosystem is registered"); + + assert_eq!(opencode.mode, ExternalIntegrationMode::Recommended); + assert_eq!( + opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_COMMAND)], + ExternalIntegrationAccess::Auto + ); + for capability in [ + EXTERNAL_CAPABILITY_TOOL, + EXTERNAL_CAPABILITY_SUBAGENT, + EXTERNAL_CAPABILITY_MCP, + ] { + assert_eq!( + opencode.capabilities[&external_capability(capability)], + ExternalIntegrationAccess::AskBeforeUse + ); + } +} + +#[test] +fn workspace_policy_overrides_only_the_fields_the_user_changed() { + let ecosystem = EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(); + let mut document = ExternalIntegrationPolicyDocument::default(); + document.user_defaults.ecosystems.insert( + ecosystem.clone(), + ExternalEcosystemPolicy { + mode: ExternalIntegrationMode::DiscoverOnly, + ..ExternalEcosystemPolicy::default() + }, + ); + document.workspace_overrides.insert( + "workspace-a".to_string(), + ExternalIntegrationPolicyOverride { + ecosystems: [( + ecosystem.clone(), + ExternalEcosystemPolicyOverride { + mode: Some(ExternalIntegrationMode::Custom), + capability_overrides: [( + external_capability(EXTERNAL_CAPABILITY_COMMAND), + ExternalIntegrationAccess::Auto, + )] + .into_iter() + .collect(), + ..ExternalEcosystemPolicyOverride::default() + }, + )] + .into_iter() + .collect(), + ..ExternalIntegrationPolicyOverride::default() + }, + ); + + let effective = evaluate_external_integration_policy( + &document, + Some("workspace-a"), + &test_external_integration_ecosystems(), + ) + .unwrap(); + let opencode = &effective.ecosystems[&ecosystem]; + assert_eq!(opencode.mode, ExternalIntegrationMode::Custom); + assert_eq!( + opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_COMMAND)], + ExternalIntegrationAccess::Auto + ); + assert_eq!( + opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_MCP)], + ExternalIntegrationAccess::DiscoverOnly + ); + + let inherited = evaluate_external_integration_policy( + &document, + Some("workspace-b"), + &test_external_integration_ecosystems(), + ) + .unwrap(); + assert_eq!( + inherited.ecosystems[&ecosystem].mode, + ExternalIntegrationMode::DiscoverOnly + ); +} + +#[test] +fn high_risk_auto_access_is_limited_by_the_capability_owner() { + let ecosystem = EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(); + let mcp = external_capability(EXTERNAL_CAPABILITY_MCP); + let mut document = ExternalIntegrationPolicyDocument::default(); + document.user_defaults.ecosystems.insert( + ecosystem.clone(), + ExternalEcosystemPolicy { + mode: ExternalIntegrationMode::Custom, + capability_overrides: [(mcp.clone(), ExternalIntegrationAccess::Auto)] + .into_iter() + .collect(), + ..ExternalEcosystemPolicy::default() + }, + ); + + let effective = evaluate_external_integration_policy( + &document, + None, + &test_external_integration_ecosystems(), + ) + .unwrap(); + let opencode = &effective.ecosystems[&ecosystem]; + assert_eq!( + opencode.capabilities[&mcp], + ExternalIntegrationAccess::AskBeforeUse + ); + assert!(opencode.policy_limited_capabilities.contains(&mcp)); +} + +#[test] +fn future_policy_values_and_minor_fields_survive_read_modify_write() { + let raw = serde_json::json!({ + "schemaMajor": 1, + "userDefaults": { + "enabled": true, + "ecosystems": { + "opencode": { + "mode": "future_mode", + "capabilityOverrides": { + "future-capability": "future_access" + }, + "futureEcosystemField": { "enabled": true } + } + }, + "futureSettingsField": "preserve-me" + }, + "workspaceOverrides": {}, + "futureDocumentField": [1, 2, 3] + }); + let mut document: ExternalIntegrationPolicyDocument = + serde_json::from_value(raw.clone()).expect("future minor data remains readable"); + document.user_defaults.enabled = false; + let encoded = serde_json::to_value(&document).expect("policy remains serializable"); + + assert_eq!( + encoded["userDefaults"]["ecosystems"]["opencode"]["mode"], + "future_mode" + ); + assert_eq!( + encoded["userDefaults"]["ecosystems"]["opencode"]["capabilityOverrides"] + ["future-capability"], + "future_access" + ); + assert_eq!( + encoded["userDefaults"]["ecosystems"]["opencode"]["futureEcosystemField"], + raw["userDefaults"]["ecosystems"]["opencode"]["futureEcosystemField"] + ); + assert_eq!( + encoded["userDefaults"]["futureSettingsField"], + "preserve-me" + ); + assert_eq!(encoded["futureDocumentField"], raw["futureDocumentField"]); + + let effective = evaluate_external_integration_policy( + &document, + None, + &test_external_integration_ecosystems(), + ) + .unwrap(); + assert!(!effective.enabled); +} + +#[test] +fn incompatible_policy_schema_major_is_rejected_without_downgrade() { + let document = ExternalIntegrationPolicyDocument { + schema_major: 2, + ..ExternalIntegrationPolicyDocument::default() + }; + let error = evaluate_external_integration_policy( + &document, + None, + &test_external_integration_ecosystems(), + ) + .expect_err("future major schemas must fail closed"); + assert!(error.to_string().contains("schema major: 2")); +} + +#[test] +fn incompatible_policy_schema_has_a_safe_read_only_public_snapshot() { + let raw = serde_json::json!({ + "schemaMajor": 2, + "userDefaults": { + "enabled": true, + "futureSecretHostField": "persistence-only" + }, + "futureDocumentField": { "keep": true } + }); + let document: ExternalIntegrationPolicyDocument = serde_json::from_value(raw).unwrap(); + let snapshot = external_integration_policy_snapshot( + &document, + Some("workspace-a"), + test_external_integration_ecosystems(), + ) + .expect("incompatible schemas remain inspectable through a safe snapshot"); + + assert_eq!( + snapshot.status, + ExternalIntegrationPolicyStatus::IncompatibleSchema + ); + assert!(!snapshot.global_effective.enabled); + assert!(!snapshot.effective.enabled); + assert!(snapshot + .effective + .ecosystems + .values() + .all(|ecosystem| ecosystem + .capabilities + .values() + .all(|access| { matches!(access, ExternalIntegrationAccess::Disabled) }))); + + let public = serde_json::to_string(&snapshot).unwrap(); + assert!(!public.contains("futureSecretHostField")); + assert!(!public.contains("futureDocumentField")); + + let persisted = serde_json::to_string(&document).unwrap(); + assert!(persisted.contains("futureSecretHostField")); + assert!(persisted.contains("futureDocumentField")); +} + +#[test] +fn integration_registry_rejects_ambiguous_or_unsafe_descriptors() { + let mut duplicate_ecosystem = test_external_integration_ecosystems(); + duplicate_ecosystem.push(duplicate_ecosystem[0].clone()); + let duplicate_error = evaluate_external_integration_policy( + &ExternalIntegrationPolicyDocument::default(), + None, + &duplicate_ecosystem, + ) + .expect_err("duplicate ecosystem registrations must fail closed"); + assert!(duplicate_error.to_string().contains("duplicate ecosystem")); + + let mut unsafe_recommendation = test_external_integration_ecosystems(); + unsafe_recommendation[0].capabilities[1].recommended_access = ExternalIntegrationAccess::Auto; + let unsafe_error = evaluate_external_integration_policy( + &ExternalIntegrationPolicyDocument::default(), + None, + &unsafe_recommendation, + ) + .expect_err("registry defaults cannot exceed their safety ceiling"); + assert!(unsafe_error + .to_string() + .contains("exceeds the safety ceiling")); +} + +#[test] +fn public_snapshot_never_exposes_executable_prompt_templates() { + let snapshot = ExternalSourceCatalogSnapshot { + generation: 1, + discovery_pending: false, + sources: Vec::new(), + commands: vec![PromptCommandCatalogEntry { + definition: command("opencode", "project-commands", 1), + }], + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 0, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 0, + preference_revision: 0, + subagents: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + let public = ExternalSourcePublicSnapshot::from(snapshot); + let encoded = serde_json::to_value(public).expect("serialize public projection"); + + assert_eq!(encoded["commands"][0]["definition"]["name"], "review"); + assert!(encoded["commands"][0]["definition"] + .get("template") + .is_none()); +} diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx index 26500feb31..6081a0bb69 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx @@ -24,6 +24,7 @@ import { GalleryDetailModal } from '@/app/components'; import type { SkillInfo, SkillLevel, SkillMarketItem } from '@/infrastructure/config/types'; import { buildSkillCoverageSourceMap, + canDeleteSkill, findSkillByKey, getSkillSourceLabel, } from '@/infrastructure/config/skillSourcePresentation'; @@ -398,7 +399,7 @@ const SkillsScene: React.FC = () => { {t('list.item.detail')} - {!skill.isBuiltin && ( + {canDeleteSkill(skill) && ( - ) : undefined} + )} /> - - {unavailableReason ? ( - + + {hostUnavailable ? ( + {null} ) : ( <> {error ? ( -
-
{t(error.kind === 'mutation' - ? 'errors.mutationUnknown' - : snapshot - ? 'errors.refreshFailed' - : 'errors.loadFailed')}
+
+
{t(externalErrorMessageKey(error, Boolean(snapshot)))}
+ {error.correlationId ? ( +
{t('operationErrors.referenceId', { id: error.correlationId })}
+ ) : null}
{t('common.technicalDetails')}
{error.detail}
) : null} + {snapshot && hostReadOnly ? ( +
+
+ ) : null} + {snapshot && policy ? ( +
+
+
+ +
+
+ {t('policy.title')} + + {t('policy.externalBadge')} + +
+
+ {externalAttentionCount > 0 ? ( + + ) : t('policy.readySummary', { count: externalAssetCount })} +
+
+
+ void updatePolicy({ + operation: 'set_enabled', + enabled: event.currentTarget.checked, + })} + /> +
+ + {policyIncompatible ? ( +
+
+ ) : null} + {policyUnknown ? ( +
+
+ ) : null} + +
+ + + + + {!workspacePath ? ( + + {t('policy.scope.workspaceUnavailable')} + + ) : null} + {workspacePolicyInherited ? ( + + {t('policy.inherited')} + + ) : policyScope === 'workspace' ? ( + + {t('policy.projectOverride')} + + ) : null} + {policyScope === 'workspace' && policy.workspaceOverride ? ( + + ) : null} +
+ + {ecosystemPolicies.map((ecosystem) => ( + +
+
+ +
+
+ {ecosystem.descriptor.displayName} + + {ecosystem.state === 'checking' ? +
+
+ {t('policy.adapterRevision', { + revision: ecosystem.descriptor.adapterRevision, + })} + {' · '} + {t('policy.sourceCount', { count: ecosystem.sourceLocations.length })} +
+
+
+
+ { + const access = String( + Array.isArray(value) ? value[0] : value, + ) as ExternalIntegrationAccess; + void updateCapabilityAccess( + ecosystem.ecosystemId, + capabilityId, + access, + ); + }} + /> +
+ ); + })} +
+ ) : null} +
+ ))} +
+ ) : null} {operationStatus ? (
{operationStatus}
@@ -631,7 +1225,11 @@ const ExternalSourcesConfig: React.FC = () => {
) : null} {(snapshot?.diagnostics?.length ?? 0) > 0 ? ( -
+
diagnostic.severity !== 'info') ? 'true' : undefined} + > {t('diagnostics.summary', { count: snapshot?.diagnostics?.length ?? 0 })} @@ -739,7 +1337,8 @@ const ExternalSourcesConfig: React.FC = () => { - - ); + ) : null; const details = ( <>
{skill.description}
diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.scss b/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.scss index b759694196..9a3b4c3986 100644 --- a/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.scss +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.scss @@ -22,19 +22,6 @@ // ─── clickable row ───────────────────────────────────────────────────────────── -.bitfun-collection-item__row { - transition: background $motion-base $easing-standard; - - &.is-clickable { - cursor: pointer; - user-select: none; - } - - &.is-clickable:hover { - background: var(--element-bg-subtle); - } -} - // ─── label + inline badge ────────────────────────────────────────────────────── .bitfun-collection-item__label { @@ -102,6 +89,16 @@ gap: $size-gap-2; } +.bitfun-collection-item__details-toggle { + svg { + transition: transform $motion-base $easing-standard; + } + + &[aria-expanded='true'] svg { + transform: rotate(180deg); + } +} + // ─── expandable details ──────────────────────────────────────────────────────── .bitfun-collection-item__details { @@ -142,6 +139,11 @@ color: var(--color-text-primary); } + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + &--danger:hover { background: color-mix(in srgb, var(--color-error) 8%, transparent); color: var(--color-error); diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.test.tsx b/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.test.tsx new file mode 100644 index 0000000000..4b6a80e6fd --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import ConfigCollectionItem from './ConfigCollectionItem'; + +describe('ConfigCollectionItem', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('uses an independent native button for expandable details', () => { + act(() => { + root.render( + Active} + details={Configuration location} + />, + ); + }); + + const row = container.querySelector('.bitfun-collection-item__row'); + const toggle = container.querySelector('.bitfun-collection-item__details-toggle'); + const control = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Active'); + expect(row?.getAttribute('role')).toBeNull(); + expect(toggle?.type).toBe('button'); + expect(toggle?.getAttribute('aria-expanded')).toBe('false'); + expect(toggle?.getAttribute('aria-controls')).toBeTruthy(); + + act(() => { + control?.click(); + }); + expect(container.textContent).not.toContain('Configuration location'); + + act(() => { + toggle?.click(); + }); + + expect(toggle?.getAttribute('aria-expanded')).toBe('true'); + expect(container.textContent).toContain('Configuration location'); + }); + + it('does not expose disabled details as an interactive control', () => { + act(() => { + root.render( + Unavailable} + details={Configuration location} + disabled + />, + ); + }); + + const toggle = container.querySelector('.bitfun-collection-item__details-toggle'); + expect(toggle?.disabled).toBe(true); + expect(toggle?.getAttribute('aria-expanded')).toBe('false'); + + act(() => { + toggle?.click(); + }); + + expect(toggle?.getAttribute('aria-expanded')).toBe('false'); + expect(container.textContent).not.toContain('Configuration location'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.tsx b/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.tsx index b45fbd5eb8..a3ae712974 100644 --- a/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.tsx +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigCollectionItem.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import React, { useId, useState } from 'react'; +import { ChevronDown } from 'lucide-react'; import './ConfigCollectionItem.scss'; export interface ConfigCollectionItemProps extends React.HTMLAttributes { @@ -26,12 +27,14 @@ export const ConfigCollectionItem: React.FC = ({ ...rootProps }) => { const [internalExpanded, setInternalExpanded] = useState(false); + const labelId = useId(); + const detailsId = useId(); const isControlled = expandedProp !== undefined; const isExpanded = isControlled ? expandedProp : internalExpanded; const hasDetails = Boolean(details); - const handleRowClick = () => { - if (!hasDetails) return; + const toggleDetails = () => { + if (!hasDetails || disabled) return; if (isControlled) { onToggle?.(); } else { @@ -44,17 +47,14 @@ export const ConfigCollectionItem: React.FC = ({ {...rootProps} className={`bitfun-collection-item ${isExpanded ? 'is-expanded' : ''} ${disabled ? 'is-disabled' : ''} ${className}`} > -
+
- {label} + {label} {badge && ( = ({ )}
-
e.stopPropagation()} - > -
{control}
+
+
+ {control} + {hasDetails ? ( + + ) : null} +
{isExpanded && details && ( -
{details}
+
{details}
)}
); diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.tsx b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.tsx index 9e80e1e689..9dd3537da5 100644 --- a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.tsx +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.tsx @@ -29,15 +29,17 @@ export interface ConfigPageContentProps { children: React.ReactNode; className?: string; + id?: string; } export const ConfigPageContent: React.FC = ({ children, className = '', + id, }) => { return ( -
+
{children}
diff --git a/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts b/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts index 3b7fda7336..03aed0a9ae 100644 --- a/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts +++ b/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { ModeSkillInfo, SkillInfo } from './types'; import { buildSkillCoverageSourceMap, + canDeleteSkill, findSkillByKey, formatSkillOrigin, getModeSkillRuntimeStatus, @@ -45,6 +46,16 @@ describe('skill source presentation', () => { expect(getSkillSourceLabel(skill({ sourceLabel: '', sourceId: '', sourceSlot: 'future' }), '其他来源')).toBe('其他来源'); }); + it('only allows BitFun-owned non-builtin skills to be deleted', () => { + expect(canDeleteSkill(skill())).toBe(true); + expect(canDeleteSkill(skill({ isBuiltin: true }))).toBe(false); + expect(canDeleteSkill(skill({ sourceId: 'bitfun-system', isBuiltin: false }))).toBe(true); + expect(canDeleteSkill(skill({ sourceId: 'opencode' }))).toBe(false); + expect(canDeleteSkill(skill({ sourceId: '', sourceSlot: 'home.codex' }))).toBe(false); + expect(canDeleteSkill(skill({ sourceId: '', sourceSlot: 'future' }))).toBe(false); + expect(canDeleteSkill(skill({ sourceId: '', sourceSlot: '' }))).toBe(false); + }); + it('formats source and scope with surface-localized labels', () => { expect(formatSkillOrigin(skill(), { fallbackSourceLabel: '其他来源', diff --git a/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts b/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts index 3f45ac3d0d..ef1eecc7fb 100644 --- a/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts +++ b/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts @@ -32,6 +32,17 @@ export function getSkillSourceLabel( || fallbackLabel; } +export function canDeleteSkill(skill: SkillInfo): boolean { + if (skill.isBuiltin) return false; + + const sourceId = skill.sourceId?.trim().toLowerCase(); + if (sourceId) { + return sourceId === 'bitfun' || sourceId === 'bitfun-system'; + } + + return skill.sourceSlot?.trim().toLowerCase().startsWith('bitfun') ?? false; +} + export interface SkillOriginLabels { fallbackSourceLabel: string; userLabel: string; diff --git a/src/web-ui/src/infrastructure/peer-device/PeerHostInvokeBridge.tsx b/src/web-ui/src/infrastructure/peer-device/PeerHostInvokeBridge.tsx index 1162edfc41..00af9b34a7 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerHostInvokeBridge.tsx +++ b/src/web-ui/src/infrastructure/peer-device/PeerHostInvokeBridge.tsx @@ -11,6 +11,20 @@ import { isTauriRuntime } from '@/infrastructure/runtime'; const log = createLogger('PeerHostInvokeBridge'); +function serializeInvokeError(error: unknown): string { + if (typeof error === 'string') return error; + if (error instanceof Error) return error.message; + try { + return JSON.stringify(error); + } catch { + return 'Host operation failed'; + } +} + +function safeCommandForLog(command: string): string { + return /^[a-z0-9_]{1,80}$/i.test(command) ? command : 'invalid'; +} + interface HostInvokeBridgeRequest { id: string; command: string; @@ -42,8 +56,12 @@ export function PeerHostInvokeBridge(): null { error: null, }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log.warn('Peer host invoke failed', { command, message }); + const message = serializeInvokeError(error); + const loggedCommand = safeCommandForLog(command); + log.warn('Peer host invoke failed', { + command: loggedCommand, + error_category: 'host_invoke', + }); try { await invoke('peer_host_invoke_complete', { id, @@ -51,13 +69,18 @@ export function PeerHostInvokeBridge(): null { value: null, error: message, }); - } catch (completeError) { - log.error('Failed to report peer host invoke error', completeError); + } catch { + log.error('Failed to report peer host invoke error', { + command: loggedCommand, + error_category: 'completion', + }); } } }); - } catch (error) { - log.error('Failed to register peer host invoke listener', error); + } catch { + log.error('Failed to register peer host invoke listener', { + error_category: 'listener_registration', + }); } })(); diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index 4c2a965434..4bbbb4aed4 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -23,17 +23,88 @@ "mcpUpdated": "Saved the MCP server choice and refreshed its status." }, "unavailable": { - "title": "External settings unavailable", - "desktopOnly": "External sources are currently available only in the desktop app.", - "remoteWorkspace": "Remote workspaces cannot use local external settings yet. BitFun did not load local settings in their place." + "hostTitle": "Compatibility service unavailable", + "hostDescription": "This workspace host does not provide external compatibility yet. BitFun did not fall back to files or settings from this device." + }, + "policy": { + "title": "External compatibility", + "externalBadge": "External", + "readySummary": "{{count}} external items detected with quiet, risk-based defaults.", + "attentionSummary": "{{count}} items need attention; unrelated work is not blocked.", + "enabledLabel": "Allow external compatibility", + "updated": "External compatibility settings were saved.", + "recoveryRequired": "This policy was written by a newer BitFun version, so external access is safely off.", + "unknownStatus": "This Host reports an unsupported policy state. External access stays off until BitFun is updated.", + "hostReadOnlyHint": "View only on this Host. Manage external integrations from Desktop or an authenticated Peer Host.", + "backupAndReset": "Back up and reset", + "resetConfirmTitle": "Back up and reset policy?", + "resetConfirmMessage": "BitFun will preserve the newer policy in a local backup, replace it with safe defaults, and keep external execution off until you enable it.", + "recoveryResetComplete": "The incompatible policy was backed up and reset to safe defaults.", + "inherited": "Inherited", + "projectOverride": "Project override", + "resetWorkspace": "Use global settings", + "adapterRevision": "Adapter {{revision}}", + "sourceCount": "{{count}} source locations", + "capabilities": "Customize capabilities", + "capabilitiesFor": "Customize {{ecosystem}} capabilities", + "modeLabel": "{{ecosystem}} compatibility mode", + "capabilityAccessLabel": "{{ecosystem}} {{capability}} access", + "unsupportedSafelyOff": "Unsupported by this version (safely off)", + "capabilitiesHint": "Choose how each kind of external content is discovered and used.", + "safetyLimited": "Safety limit", + "scope": { + "user": "Global", + "workspace": "This project", + "workspaceHint": "Project settings override only this workspace.", + "workspaceUnavailable": "Open a workspace to add a project override." + }, + "mode": { + "recommended": "Recommended", + "discoverOnly": "Discover only", + "disabled": "Off", + "custom": "Custom" + }, + "access": { + "disabled": "Off", + "discoverOnly": "Discover", + "askBeforeUse": "Ask before use", + "auto": "Use automatically" + }, + "capability": { + "command": "Commands", + "tool": "Tools", + "subagent": "Agents", + "mcp": "MCP servers" + }, + "health": { + "available": "Adapter available", + "partial": "Adapter partially available", + "degraded": "Adapter degraded", + "unavailable": "Adapter unavailable" + }, + "state": { + "checking": "Checking", + "attention": "Needs attention", + "ready": "Ready", + "noConfig": "No configuration" + } }, "errors": { "loadFailed": "BitFun could not load the current external settings. Do not assume external content is available; refresh to try again.", "refreshFailed": "BitFun could not check for changes. Previously enabled content remains available, but recent changes may be missing; refresh to retry.", "mutationUnknown": "BitFun could not confirm whether your change was saved. Do not assume an enable, disable, or source choice took effect; refresh to confirm the current state." }, + "operationErrors": { + "rejected": "This change is not allowed in the current Host or policy. No settings were changed.", + "refreshRequired": "The catalog changed before this action completed. Refresh, review the current state, and try again.", + "policyIncompatible": "External settings were written by a newer BitFun version. Back up and reset them before making changes.", + "unavailableRetry": "The external integration Host is temporarily unavailable. Retry or refresh in a moment.", + "internal": "BitFun could not complete this operation. Retry once; use the reference id if it continues.", + "referenceId": "Reference: {{id}}" + }, "diagnostics": { "summary": "{{count}} items need attention. Review the details to see what is affected.", + "sourceSummary": "{{name}} has {{count}} issues", "category": { "confirmationStateUnavailable": "BitFun could not verify saved tool confirmations, so affected tools remain disabled. Check BitFun settings storage, then refresh.", "conflictHistoryUnavailable": "BitFun could not save conflict information, so affected names remain unavailable. Check BitFun settings storage, then refresh.", diff --git a/src/web-ui/src/locales/en-US/settings/mcp.json b/src/web-ui/src/locales/en-US/settings/mcp.json index af9b217722..f34efecce4 100644 --- a/src/web-ui/src/locales/en-US/settings/mcp.json +++ b/src/web-ui/src/locales/en-US/settings/mcp.json @@ -7,6 +7,49 @@ "description": "Manage MCP server status and JSON configuration." } }, + "external": { + "title": "External MCP servers", + "description": "MCP servers discovered from compatible ecosystems. Manage trust and conflicts from External integrations.", + "manage": "Manage external integrations", + "retry": "Retry external MCP sources", + "loading": "Checking external MCP sources...", + "unavailable": "External MCP sources are unavailable on this host.", + "empty": "No external MCP servers discovered.", + "unknown": "Unknown", + "scope": { + "userGlobal": "User", + "project": "Project", + "remoteUser": "Remote user", + "remoteProject": "Remote project" + }, + "status": { + "checking": "Checking", + "stale": "Last valid", + "degraded": "Source issue", + "readOnly": "Read-only", + "approvalRequired": "Approval required", + "starting": "Starting", + "active": "Active", + "declined": "Declined", + "conflict": "Conflict", + "covered": "Covered", + "sourceDisabled": "Source disabled", + "configurationChanged": "Changed", + "unsupported": "Unsupported", + "runtimeUnavailable": "Unavailable", + "removed": "Removed" + }, + "details": { + "source": "Source", + "scope": "Scope", + "location": "Configuration", + "transport": "Transport" + }, + "transport": { + "localStdio": "Local process", + "streamableHttp": "Streamable HTTP" + } + }, "tabs": { "servers": "Servers", "add": "Add Server" diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index b0185fc6a8..b3160073e6 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -23,17 +23,88 @@ "mcpUpdated": "已保存 MCP 服务器选择并刷新状态。" }, "unavailable": { - "title": "外部配置暂不可用", - "desktopOnly": "外部来源目前仅在桌面应用中可用。", - "remoteWorkspace": "远程工作区暂不能使用本机外部配置,BitFun 也不会改为加载本机配置。" + "hostTitle": "兼容服务不可用", + "hostDescription": "当前工作区宿主尚未提供外部兼容能力。BitFun 不会改用本机的文件或配置。" + }, + "policy": { + "title": "外部兼容", + "externalBadge": "外部", + "readySummary": "已发现 {{count}} 项外部内容,并采用低打扰的风险默认策略。", + "attentionSummary": "有 {{count}} 项需要处理,不影响无关工作。", + "enabledLabel": "允许外部兼容", + "updated": "外部兼容设置已保存。", + "recoveryRequired": "此策略由较新的 BitFun 版本写入,外部能力已安全关闭。", + "unknownStatus": "当前 Host 返回了不支持的策略状态。升级 BitFun 前,外部能力保持关闭。", + "hostReadOnlyHint": "当前 Host 仅支持查看。请在桌面端或已认证的 Peer Host 中管理外部扩展。", + "backupAndReset": "备份并重置", + "resetConfirmTitle": "备份并重置策略?", + "resetConfirmMessage": "BitFun 会在本地保留新版策略备份,恢复安全默认值,并在你主动开启前保持外部执行关闭。", + "recoveryResetComplete": "不兼容策略已备份,并已恢复安全默认值。", + "inherited": "继承全局", + "projectOverride": "项目覆盖", + "resetWorkspace": "使用全局设置", + "adapterRevision": "适配器 {{revision}}", + "sourceCount": "{{count}} 个来源位置", + "capabilities": "定制能力", + "capabilitiesFor": "定制 {{ecosystem}} 能力", + "modeLabel": "{{ecosystem}} 兼容模式", + "capabilityAccessLabel": "{{ecosystem}} 的{{capability}}访问策略", + "unsupportedSafelyOff": "当前版本不支持(已安全关闭)", + "capabilitiesHint": "分别选择各类外部内容的发现与使用方式。", + "safetyLimited": "安全上限", + "scope": { + "user": "全局", + "workspace": "当前项目", + "workspaceHint": "项目设置仅覆盖当前工作区。", + "workspaceUnavailable": "打开工作区后可添加项目覆盖。" + }, + "mode": { + "recommended": "推荐", + "discoverOnly": "仅发现", + "disabled": "关闭", + "custom": "自定义" + }, + "access": { + "disabled": "关闭", + "discoverOnly": "仅发现", + "askBeforeUse": "使用前询问", + "auto": "自动使用" + }, + "capability": { + "command": "命令", + "tool": "工具", + "subagent": "Agent", + "mcp": "MCP 服务器" + }, + "state": { + "checking": "检查中", + "attention": "需要处理", + "ready": "已就绪", + "noConfig": "未发现配置" + }, + "health": { + "available": "适配器可用", + "partial": "适配器部分可用", + "degraded": "适配器已降级", + "unavailable": "适配器不可用" + } }, "errors": { "loadFailed": "BitFun 无法加载当前外部配置。请勿假定外部内容可用,并刷新重试。", "refreshFailed": "BitFun 无法检查变更。之前已启用的内容仍可使用,但可能未包含最近的变化;请刷新重试。", "mutationUnknown": "BitFun 无法确认本次更改是否已保存。请勿假定启用、停用或来源选择已经生效,并刷新确认当前状态。" }, + "operationErrors": { + "rejected": "当前 Host 或策略不允许此更改,设置未发生变化。", + "refreshRequired": "操作完成前目录已变化。请刷新并确认当前状态后重试。", + "policyIncompatible": "外部设置由较新的 BitFun 版本写入。请先备份并重置,再进行更改。", + "unavailableRetry": "外部扩展 Host 暂时不可用。请稍后重试或刷新。", + "internal": "BitFun 无法完成此操作。请重试一次;若问题持续,请提供参考编号。", + "referenceId": "参考编号:{{id}}" + }, "diagnostics": { "summary": "有 {{count}} 项内容需要注意,请查看详情了解影响范围。", + "sourceSummary": "{{name}} 有 {{count}} 项问题", "category": { "confirmationStateUnavailable": "BitFun 无法验证已保存的工具确认状态,因此相关工具保持停用。请检查 BitFun 设置存储后刷新。", "conflictHistoryUnavailable": "BitFun 无法保存冲突信息,因此相关名称保持不可用。请检查 BitFun 设置存储后刷新。", diff --git a/src/web-ui/src/locales/zh-CN/settings/mcp.json b/src/web-ui/src/locales/zh-CN/settings/mcp.json index 1dbd7d622a..351f1f45e9 100644 --- a/src/web-ui/src/locales/zh-CN/settings/mcp.json +++ b/src/web-ui/src/locales/zh-CN/settings/mcp.json @@ -7,6 +7,49 @@ "description": "统一管理 MCP 服务状态与 JSON 配置。" } }, + "external": { + "title": "外部 MCP 服务器", + "description": "从兼容生态发现的 MCP 服务器。信任与冲突统一在外部扩展中管理。", + "manage": "管理外部扩展", + "retry": "重试加载外部 MCP", + "loading": "正在检查外部 MCP 来源...", + "unavailable": "当前主机无法读取外部 MCP 来源。", + "empty": "未发现外部 MCP 服务器。", + "unknown": "未知", + "scope": { + "userGlobal": "用户", + "project": "项目", + "remoteUser": "远程用户", + "remoteProject": "远程项目" + }, + "status": { + "checking": "检查中", + "stale": "使用上次有效配置", + "degraded": "来源异常", + "readOnly": "只读", + "approvalRequired": "需要确认", + "starting": "启动中", + "active": "已启用", + "declined": "已拒绝", + "conflict": "有冲突", + "covered": "已覆盖", + "sourceDisabled": "来源已关闭", + "configurationChanged": "配置已变更", + "unsupported": "不支持", + "runtimeUnavailable": "不可用", + "removed": "已移除" + }, + "details": { + "source": "来源", + "scope": "范围", + "location": "配置位置", + "transport": "传输方式" + }, + "transport": { + "localStdio": "本地进程", + "streamableHttp": "Streamable HTTP" + } + }, "tabs": { "servers": "服务器", "add": "添加服务器" diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 4604319598..46dde5b625 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -23,17 +23,88 @@ "mcpUpdated": "已儲存 MCP 伺服器選擇並重新整理狀態。" }, "unavailable": { - "title": "外部設定暫不可用", - "desktopOnly": "外部來源目前僅在桌面應用中可用。", - "remoteWorkspace": "遠端工作區目前無法使用本機外部設定,BitFun 也不會改為載入本機設定。" + "hostTitle": "相容服務無法使用", + "hostDescription": "目前工作區宿主尚未提供外部相容能力。BitFun 不會改用本機的檔案或設定。" + }, + "policy": { + "title": "外部相容", + "externalBadge": "外部", + "readySummary": "已發現 {{count}} 項外部內容,並採用低干擾的風險預設策略。", + "attentionSummary": "有 {{count}} 項需要處理,不影響無關工作。", + "enabledLabel": "允許外部相容", + "updated": "外部相容設定已儲存。", + "recoveryRequired": "此策略由較新的 BitFun 版本寫入,外部能力已安全關閉。", + "unknownStatus": "目前 Host 回傳了不支援的策略狀態。升級 BitFun 前,外部能力維持關閉。", + "hostReadOnlyHint": "目前 Host 僅支援檢視。請在桌面端或已驗證的 Peer Host 中管理外部擴充。", + "backupAndReset": "備份並重設", + "resetConfirmTitle": "備份並重設策略?", + "resetConfirmMessage": "BitFun 會在本機保留新版策略備份,恢復安全預設值,並在你主動開啟前維持外部執行關閉。", + "recoveryResetComplete": "不相容策略已備份,並已恢復安全預設值。", + "inherited": "繼承全域", + "projectOverride": "專案覆寫", + "resetWorkspace": "使用全域設定", + "adapterRevision": "適配器 {{revision}}", + "sourceCount": "{{count}} 個來源位置", + "capabilities": "自訂能力", + "capabilitiesFor": "自訂 {{ecosystem}} 能力", + "modeLabel": "{{ecosystem}} 相容模式", + "capabilityAccessLabel": "{{ecosystem}} 的{{capability}}存取策略", + "unsupportedSafelyOff": "目前版本不支援(已安全關閉)", + "capabilitiesHint": "分別選擇各類外部內容的探索與使用方式。", + "safetyLimited": "安全上限", + "scope": { + "user": "全域", + "workspace": "目前專案", + "workspaceHint": "專案設定僅覆蓋目前工作區。", + "workspaceUnavailable": "開啟工作區後可新增專案覆蓋。" + }, + "mode": { + "recommended": "建議", + "discoverOnly": "僅探索", + "disabled": "關閉", + "custom": "自訂" + }, + "access": { + "disabled": "關閉", + "discoverOnly": "僅探索", + "askBeforeUse": "使用前詢問", + "auto": "自動使用" + }, + "capability": { + "command": "命令", + "tool": "工具", + "subagent": "Agent", + "mcp": "MCP 伺服器" + }, + "state": { + "checking": "檢查中", + "attention": "需要處理", + "ready": "已就緒", + "noConfig": "未發現設定" + }, + "health": { + "available": "適配器可用", + "partial": "適配器部分可用", + "degraded": "適配器已降級", + "unavailable": "適配器無法使用" + } }, "errors": { "loadFailed": "BitFun 無法載入目前的外部設定。請勿假設外部內容可用,並重新整理後重試。", "refreshFailed": "BitFun 無法檢查變更。先前已啟用的內容仍可使用,但可能未包含最近的變化;請重新整理後重試。", "mutationUnknown": "BitFun 無法確認本次變更是否已儲存。請勿假設啟用、停用或來源選擇已經生效,並重新整理以確認目前狀態。" }, + "operationErrors": { + "rejected": "目前 Host 或策略不允許此變更,設定未發生變化。", + "refreshRequired": "操作完成前目錄已變更。請重新整理並確認目前狀態後重試。", + "policyIncompatible": "外部設定由較新的 BitFun 版本寫入。請先備份並重設,再進行變更。", + "unavailableRetry": "外部擴充 Host 暫時無法使用。請稍後重試或重新整理。", + "internal": "BitFun 無法完成此操作。請重試一次;若問題持續,請提供參考編號。", + "referenceId": "參考編號:{{id}}" + }, "diagnostics": { "summary": "有 {{count}} 項內容需要注意,請查看詳細資料以瞭解影響範圍。", + "sourceSummary": "{{name}} 有 {{count}} 項問題", "category": { "confirmationStateUnavailable": "BitFun 無法驗證已儲存的工具確認狀態,因此相關工具保持停用。請檢查 BitFun 設定儲存空間後重新整理。", "conflictHistoryUnavailable": "BitFun 無法儲存衝突資訊,因此相關名稱保持不可用。請檢查 BitFun 設定儲存空間後重新整理。", diff --git a/src/web-ui/src/locales/zh-TW/settings/mcp.json b/src/web-ui/src/locales/zh-TW/settings/mcp.json index 9322d44531..ec7e7280d1 100644 --- a/src/web-ui/src/locales/zh-TW/settings/mcp.json +++ b/src/web-ui/src/locales/zh-TW/settings/mcp.json @@ -7,6 +7,49 @@ "description": "統一管理 MCP 服務狀態與 JSON 設定。" } }, + "external": { + "title": "外部 MCP 伺服器", + "description": "從相容生態發現的 MCP 伺服器。信任與衝突統一在外部擴充中管理。", + "manage": "管理外部擴充", + "retry": "重試載入外部 MCP", + "loading": "正在檢查外部 MCP 來源...", + "unavailable": "目前主機無法讀取外部 MCP 來源。", + "empty": "未發現外部 MCP 伺服器。", + "unknown": "未知", + "scope": { + "userGlobal": "使用者", + "project": "專案", + "remoteUser": "遠端使用者", + "remoteProject": "遠端專案" + }, + "status": { + "checking": "檢查中", + "stale": "使用上次有效設定", + "degraded": "來源異常", + "readOnly": "唯讀", + "approvalRequired": "需要確認", + "starting": "啟動中", + "active": "已啟用", + "declined": "已拒絕", + "conflict": "有衝突", + "covered": "已覆蓋", + "sourceDisabled": "來源已關閉", + "configurationChanged": "設定已變更", + "unsupported": "不支援", + "runtimeUnavailable": "不可用", + "removed": "已移除" + }, + "details": { + "source": "來源", + "scope": "範圍", + "location": "設定位置", + "transport": "傳輸方式" + }, + "transport": { + "localStdio": "本機程序", + "streamableHttp": "Streamable HTTP" + } + }, "tabs": { "servers": "伺服器", "add": "新增伺服器"