diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 1193d8a9..4bd65f18 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -448,6 +448,7 @@ pub fn run() { plugins::plugin_storage_delete, plugins::plugin_workspace_list_dir, plugins::plugin_workspace_stat, + plugins::plugin_workspace_exec_tool, plugins::plugin_query_project_structure, plugins::plugin_config_read_document, plugins::plugin_config_update_document, @@ -488,6 +489,7 @@ fn plugin_workspace_invoke_handler( plugins::plugin_storage_delete, plugins::plugin_workspace_list_dir, plugins::plugin_workspace_stat, + plugins::plugin_workspace_exec_tool, plugins::plugin_query_project_structure, plugins::plugin_config_read_document, plugins::plugin_config_update_document, diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index 23ae8b2b..f286d87b 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -17,10 +17,10 @@ use backend::dto::{ PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto, PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto, PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto, - PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, - PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ProjectPluginSettingsDto, - ProjectPluginSettingsViewDto, ReloadPluginDto, ReviewPluginPackageDto, - SetProjectPluginEnabledRequestDto, + PluginWorkspaceExecDto, PluginWorkspaceExecResultDto, PluginWorkspacePathDto, + PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspaceWriteBinaryDto, + PluginWorkspaceWriteTextDto, ProjectPluginSettingsDto, ProjectPluginSettingsViewDto, + ReloadPluginDto, ReviewPluginPackageDto, SetProjectPluginEnabledRequestDto, }; use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore}; use domain::{PluginId, RelativePath}; @@ -414,6 +414,19 @@ pub async fn plugin_workspace_stat( .map_err(ErrorDto::from) } +/// Executes a short, permission-gated tool in a project workspace. +#[tauri::command] +pub async fn plugin_workspace_exec_tool( + input: PluginWorkspaceExecDto, + state: State<'_, AppState>, +) -> Result { + state + .plugin_workspace_exec + .execute(input.into()) + .await + .map_err(ErrorDto::from) +} + /// Queries a bounded generic project structure for the public plugin API. #[tauri::command] pub async fn plugin_query_project_structure( @@ -888,6 +901,7 @@ mod tests { trust_level: PluginTrustLevel::Full, capabilities: Vec::new(), activation_scope: domain::PluginActivationScope::default(), + permissions: domain::PluginPermissions::default(), contributes: PluginContributionSet::default(), }) } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 293c76df..1bb05a88 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -196,12 +196,17 @@ pub use plugin::{ PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic, PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing, + PluginWorkspaceExecInput, PluginWorkspaceExecResult, PluginWorkspaceExecTool, PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile, PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention, ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure, QueryProjectStructureInput, ReconcilePluginMcpServers, ReloadPlugin, ReloadPluginInput, ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput, UninstallPlugin, UninstallPluginInput, UninstallPluginResult, + PLUGIN_WORKSPACE_EXEC_DEFAULT_STDERR_BYTES, PLUGIN_WORKSPACE_EXEC_DEFAULT_STDOUT_BYTES, + PLUGIN_WORKSPACE_EXEC_DEFAULT_TIMEOUT_MS, PLUGIN_WORKSPACE_EXEC_MAX_STDERR_BYTES, + PLUGIN_WORKSPACE_EXEC_MAX_STDIN_BYTES, PLUGIN_WORKSPACE_EXEC_MAX_STDOUT_BYTES, + PLUGIN_WORKSPACE_EXEC_MAX_TIMEOUT_MS, }; pub use project::{ CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index ee9908a7..0ca31cdc 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -1,6 +1,7 @@ //! Plugin application use cases. -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::path::Path; use std::sync::{Arc, Mutex}; use domain::ports::EnvironmentReader; @@ -8,19 +9,22 @@ use domain::ports::{ BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator, LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError, - PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError, - ProcessSpawner, ProjectStore, RemotePath, SpawnSpec, + PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, + PluginWorkspaceExecAuditSink, ProcessError, ProcessSpawner, ProjectStore, RemotePath, + SpawnSpec, WorkspaceToolExecError, WorkspaceToolExecSpec, WorkspaceToolRunner, }; use domain::{ AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash, DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource, PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerSpec, - PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath, + PluginRegistryEntry, PluginTrustLevel, PluginWorkspaceExecAuditEntry, + PluginWorkspaceExecAuditOutcome, PluginWorkspaceExecEffect, Project, ProjectId, ProjectPath, ProjectPluginSettingsStore, RemovalOutcome, StagedPluginPackage, TaskId, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::ProjectPluginResolver; use crate::{AppError, SpawnBackgroundCommand, SpawnBackgroundCommandInput}; /// Contribution counts for admin display. @@ -2070,6 +2074,421 @@ impl PluginCommandTasks { } } +/// Default deadline for a short plugin workspace tool. +pub const PLUGIN_WORKSPACE_EXEC_DEFAULT_TIMEOUT_MS: u64 = 5_000; +/// Host maximum deadline for a short plugin workspace tool. +pub const PLUGIN_WORKSPACE_EXEC_MAX_TIMEOUT_MS: u64 = 30_000; +/// Default retained stdout size. +pub const PLUGIN_WORKSPACE_EXEC_DEFAULT_STDOUT_BYTES: usize = 256_000; +/// Default retained stderr size. +pub const PLUGIN_WORKSPACE_EXEC_DEFAULT_STDERR_BYTES: usize = 64_000; +/// Host maximum retained stdout size. +pub const PLUGIN_WORKSPACE_EXEC_MAX_STDOUT_BYTES: usize = 1_000_000; +/// Host maximum retained stderr size. +pub const PLUGIN_WORKSPACE_EXEC_MAX_STDERR_BYTES: usize = 256_000; +/// Host maximum UTF-8 stdin size. +pub const PLUGIN_WORKSPACE_EXEC_MAX_STDIN_BYTES: usize = 64_000; + +const WORKSPACE_EXEC_INHERITED_ENV: &[&str] = &[ + "HOME", "LANG", "LC_ALL", "LC_CTYPE", "PATH", "TERM", "TMPDIR", "TMP", "TEMP", "TZ", +]; +const WORKSPACE_EXEC_OVERLAY_ENV: &[&str] = + &["LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "TERM", "TZ"]; + +/// Host-injected request for one short, invisible plugin tool invocation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceExecInput { + /// Calling plugin id, injected by the runtime service facade. + pub plugin_id: String, + /// Project id owning and confining the invocation. + pub project_id: String, + /// Bare executable name. + pub command: String, + /// Arguments passed directly, never through a shell. + #[serde(default)] + pub args: Vec, + /// Project-relative cwd, defaulting to the root. + #[serde(default)] + pub cwd: Option, + /// Host-allowlisted environment overlay. + #[serde(default)] + pub env: Vec<(String, String)>, + /// Optional UTF-8 stdin. + #[serde(default)] + pub stdin: Option, + /// Requested deadline, clamped by the host maximum. + #[serde(default)] + pub timeout_ms: Option, + /// Requested retained stdout size, clamped by the host maximum. + #[serde(default)] + pub max_stdout_bytes: Option, + /// Requested retained stderr size, clamped by the host maximum. + #[serde(default)] + pub max_stderr_bytes: Option, +} + +/// Public result of one short plugin workspace tool invocation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceExecResult { + /// Exit code, absent for a signalled process. + pub exit_code: Option, + /// Platform signal when available. + pub signal: Option, + /// Whether the host deadline killed the process. + pub timed_out: bool, + /// Lossy UTF-8 rendering of retained stdout. + pub stdout: String, + /// Lossy UTF-8 rendering of retained stderr. + pub stderr: String, + /// Whether stdout exceeded its retained limit. + pub stdout_truncated: bool, + /// Whether stderr exceeded its retained limit. + pub stderr_truncated: bool, + /// Measured wall-clock duration. + pub duration_ms: u64, +} + +/// Generic, permission-gated, project-confined executable service for plugins. +pub struct PluginWorkspaceExecTool { + projects: Arc, + fs: Arc, + plugins: Arc, + runner: Arc, + env: Arc, + clock: Arc, + audit: Arc, +} + +impl PluginWorkspaceExecTool { + /// Builds the use case from replaceable ports. + #[must_use] + pub fn new( + projects: Arc, + fs: Arc, + plugins: Arc, + runner: Arc, + env: Arc, + clock: Arc, + audit: Arc, + ) -> Self { + Self { + projects, + fs, + plugins, + runner, + env, + clock, + audit, + } + } + + /// Executes an allowlisted binary without creating a visible task. + pub async fn execute( + &self, + input: PluginWorkspaceExecInput, + ) -> Result { + let plugin_id = PluginId::new(input.plugin_id.clone()) + .map_err(|err| AppError::Invalid(err.to_string()))?; + let project_id = parse_project_id(&input.project_id)?; + let project = self.projects.load_project(project_id).await?; + validate_workspace_exec_command(&input.command, &input.args, input.stdin.as_deref())?; + let cwd_relative = normalize_exec_cwd(input.cwd.as_deref())?; + let cwd = canonical_exec_cwd(self.fs.as_ref(), &project, &cwd_relative).await?; + let manifest = self + .plugins + .enabled_manifest(&project.root, &plugin_id) + .await? + .ok_or_else(|| AppError::Invalid("plugin is not active for this project".to_owned()))?; + let matched = manifest + .permissions + .workspace_exec + .as_ref() + .and_then(|permission| { + permission + .allow + .iter() + .enumerate() + .find(|(_, rule)| rule.matches(&input.command, &input.args, &cwd_relative)) + }); + let Some((rule_index, rule)) = matched else { + self.audit.record(exec_audit( + plugin_id, + project_id, + &input, + &cwd_relative, + None, + None, + PluginWorkspaceExecAuditOutcome::Denied, + None, + false, + 0, + 0, + 0, + self.clock.now_millis(), + )); + return Err(AppError::Invalid( + "workspace tool invocation is not allowed by the active plugin manifest".to_owned(), + )); + }; + let effect = rule.effect; + let env = workspace_exec_environment(self.env.as_ref(), &input.env)?; + let timeout_ms = bounded_nonzero( + "timeoutMs", + input.timeout_ms, + PLUGIN_WORKSPACE_EXEC_DEFAULT_TIMEOUT_MS, + PLUGIN_WORKSPACE_EXEC_MAX_TIMEOUT_MS, + )?; + let max_stdout_bytes = bounded_nonzero_usize( + "maxStdoutBytes", + input.max_stdout_bytes, + PLUGIN_WORKSPACE_EXEC_DEFAULT_STDOUT_BYTES, + PLUGIN_WORKSPACE_EXEC_MAX_STDOUT_BYTES, + )?; + let max_stderr_bytes = bounded_nonzero_usize( + "maxStderrBytes", + input.max_stderr_bytes, + PLUGIN_WORKSPACE_EXEC_DEFAULT_STDERR_BYTES, + PLUGIN_WORKSPACE_EXEC_MAX_STDERR_BYTES, + )?; + let spec = WorkspaceToolExecSpec { + command: input.command.clone(), + args: input.args.clone(), + cwd, + env, + stdin: input.stdin.clone(), + timeout_ms, + max_stdout_bytes, + max_stderr_bytes, + sandbox: workspace_exec_sandbox(&project.root, effect), + }; + match self.runner.run(spec).await { + Ok(output) => { + self.audit.record(exec_audit( + plugin_id, + project_id, + &input, + &cwd_relative, + Some(effect), + Some(rule_index), + PluginWorkspaceExecAuditOutcome::Completed, + output.exit_code, + output.timed_out, + output.stdout.len(), + output.stderr.len(), + output.duration_ms, + self.clock.now_millis(), + )); + Ok(PluginWorkspaceExecResult { + exit_code: output.exit_code, + signal: output.signal, + timed_out: output.timed_out, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + stdout_truncated: output.stdout_truncated, + stderr_truncated: output.stderr_truncated, + duration_ms: output.duration_ms, + }) + } + Err(WorkspaceToolExecError::Execution(message)) => { + self.audit.record(exec_audit( + plugin_id, + project_id, + &input, + &cwd_relative, + Some(effect), + Some(rule_index), + PluginWorkspaceExecAuditOutcome::Failed, + None, + false, + 0, + 0, + 0, + self.clock.now_millis(), + )); + Err(AppError::Process(message)) + } + } + } +} + +fn validate_workspace_exec_command( + command: &str, + args: &[String], + stdin: Option<&str>, +) -> Result<(), AppError> { + if command.is_empty() + || command.len() > 128 + || !command + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-')) + { + return Err(AppError::Invalid( + "command must be a bare ASCII executable name".to_owned(), + )); + } + if args.len() > 64 + || args + .iter() + .any(|arg| arg.len() > 4096 || arg.contains('\0')) + { + return Err(AppError::Invalid( + "args must contain at most 64 bounded NUL-free tokens".to_owned(), + )); + } + if stdin.is_some_and(|value| value.len() > PLUGIN_WORKSPACE_EXEC_MAX_STDIN_BYTES) { + return Err(AppError::Invalid(format!( + "stdin exceeds {PLUGIN_WORKSPACE_EXEC_MAX_STDIN_BYTES} bytes" + ))); + } + Ok(()) +} + +fn normalize_exec_cwd(raw: Option<&str>) -> Result { + let normalized = normalize_workspace_relative_path(raw.unwrap_or("."))?; + Ok(if normalized.is_empty() { + ".".to_owned() + } else { + normalized + }) +} + +async fn canonical_exec_cwd( + fs: &dyn FileSystem, + project: &Project, + relative: &str, +) -> Result { + let root = fs + .canonicalize(&RemotePath::new(project.root.as_str().to_owned())) + .await + .map_err(|_| AppError::NotFound("project root is inaccessible".to_owned()))?; + let candidate = resolve_workspace_path(project, relative)?; + let candidate = fs + .canonicalize(&candidate.absolute) + .await + .map_err(|_| AppError::NotFound("workspace tool cwd is inaccessible".to_owned()))?; + if !Path::new(candidate.as_str()).starts_with(Path::new(root.as_str())) { + return Err(AppError::Invalid( + "workspace tool cwd escapes the project root through a symlink".to_owned(), + )); + } + ProjectPath::new(candidate.as_str().to_owned()) + .map_err(|err| AppError::Invalid(err.to_string())) +} + +fn workspace_exec_environment( + env_reader: &dyn EnvironmentReader, + overlay: &[(String, String)], +) -> Result, AppError> { + let mut env = BTreeMap::new(); + for name in WORKSPACE_EXEC_INHERITED_ENV { + if let Some(value) = env_reader.get(name) { + env.insert((*name).to_owned(), value); + } + } + for (name, value) in overlay { + if !WORKSPACE_EXEC_OVERLAY_ENV.contains(&name.as_str()) + || value.contains('\0') + || value.len() > 4096 + { + return Err(AppError::Invalid(format!( + "environment variable `{name}` is not allowed or is invalid" + ))); + } + env.insert(name.clone(), value.clone()); + } + Ok(env.into_iter().collect()) +} + +fn bounded_nonzero( + name: &str, + value: Option, + default: u64, + max: u64, +) -> Result { + let value = value.unwrap_or(default); + if value == 0 { + return Err(AppError::Invalid(format!("{name} must be positive"))); + } + Ok(value.min(max)) +} + +fn bounded_nonzero_usize( + name: &str, + value: Option, + default: usize, + max: usize, +) -> Result { + let value = value.unwrap_or(default); + if value == 0 { + return Err(AppError::Invalid(format!("{name} must be positive"))); + } + Ok(value.min(max)) +} + +fn workspace_exec_sandbox( + root: &ProjectPath, + effect: PluginWorkspaceExecEffect, +) -> domain::SandboxPlan { + let project_access = match effect { + PluginWorkspaceExecEffect::Read => domain::PathAccess::RO | domain::PathAccess::EXEC, + PluginWorkspaceExecEffect::Write + | PluginWorkspaceExecEffect::Network + | PluginWorkspaceExecEffect::Destructive => { + domain::PathAccess::RO | domain::PathAccess::RW | domain::PathAccess::EXEC + } + }; + let mut allowed = vec![domain::PathGrant { + abs_root: root.as_str().to_owned(), + access: project_access, + }]; + for system_root in ["/bin", "/usr", "/lib", "/lib64", "/etc"] { + allowed.push(domain::PathGrant { + abs_root: system_root.to_owned(), + access: domain::PathAccess::RO | domain::PathAccess::EXEC, + }); + } + domain::SandboxPlan { + allowed, + default_posture: domain::permission::Posture::Deny, + } +} + +#[allow(clippy::too_many_arguments)] +fn exec_audit( + plugin_id: PluginId, + project_id: ProjectId, + input: &PluginWorkspaceExecInput, + cwd: &str, + effect: Option, + permission_rule_index: Option, + outcome: PluginWorkspaceExecAuditOutcome, + exit_code: Option, + timed_out: bool, + stdout_bytes: usize, + stderr_bytes: usize, + duration_ms: u64, + occurred_at_ms: i64, +) -> PluginWorkspaceExecAuditEntry { + PluginWorkspaceExecAuditEntry { + plugin_id, + project_id, + command: input.command.clone(), + arg_count: input.args.len(), + cwd: cwd.to_owned(), + effect, + permission_rule_index, + outcome, + exit_code, + timed_out, + stdout_bytes, + stderr_bytes, + duration_ms, + occurred_at_ms, + } +} + fn parse_agent_id(raw: &str) -> Result { Uuid::parse_str(raw) .map(AgentId::from_uuid) @@ -2267,6 +2686,7 @@ impl ListPlugins { trust_level: PluginTrustLevel::Full, capabilities: Vec::new(), activation_scope: domain::PluginActivationScope::default(), + permissions: domain::PluginPermissions::default(), contributes: PluginContributionSet::default(), }; out.push(admin_from_descriptor( @@ -3334,9 +3754,39 @@ struct RawManifest { capabilities: Vec, #[serde(default)] activation_scope: domain::PluginActivationScope, + #[serde(default)] + permissions: RawPluginPermissions, contributes: RawContributes, } +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginPermissions { + #[serde(default)] + workspace_exec: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct RawWorkspaceExecPermission { + #[serde(default)] + allow: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawWorkspaceExecRule { + command: String, + #[serde(default)] + args: Vec, + #[serde(default = "default_workspace_exec_cwd")] + cwd: String, + effect: domain::PluginWorkspaceExecEffect, +} + +fn default_workspace_exec_cwd() -> String { + ".".to_owned() +} + #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct RawEngines { @@ -3510,6 +3960,7 @@ impl PluginManifestValidator for JsonPluginManifestValidator { ))), }) .collect::, _>>()?; + let permissions = validate_plugin_permissions(raw.permissions)?; let contributes = validate_contributes(raw.contributes)?; Ok(PluginManifest { idea_plugin_manifest_version: 1, @@ -3524,11 +3975,82 @@ impl PluginManifestValidator for JsonPluginManifestValidator { trust_level: PluginTrustLevel::Full, capabilities, activation_scope: raw.activation_scope, + permissions, contributes, }) } } +fn validate_plugin_permissions( + raw: RawPluginPermissions, +) -> Result { + let workspace_exec = raw + .workspace_exec + .map(|permission| { + if permission.allow.len() > 64 { + return Err(PluginManifestError::Invalid( + "permissions.workspaceExec.allow must contain at most 64 rules".to_owned(), + )); + } + let allow = permission + .allow + .into_iter() + .enumerate() + .map(|(index, rule)| validate_workspace_exec_rule(index, rule)) + .collect::, _>>()?; + Ok(domain::PluginWorkspaceExecPermission { allow }) + }) + .transpose()?; + Ok(domain::PluginPermissions { workspace_exec }) +} + +fn validate_workspace_exec_rule( + index: usize, + rule: RawWorkspaceExecRule, +) -> Result { + let field = format!("permissions.workspaceExec.allow[{index}]"); + let command = rule.command.trim(); + if command.is_empty() + || command.len() > 128 + || !command + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-')) + { + return Err(PluginManifestError::Invalid(format!( + "{field}.command must be a bare ASCII executable name" + ))); + } + if rule.args.len() > 64 + || rule.args.iter().any(|arg| { + arg.is_empty() + || arg.len() > 4096 + || arg.contains('\0') + || (arg.contains('*') && arg != "*") + }) + { + return Err(PluginManifestError::Invalid(format!( + "{field}.args must contain at most 64 non-empty bounded tokens; only a whole `*` token is a wildcard" + ))); + } + let cwd = if rule.cwd == "*" { + "*".to_owned() + } else { + let normalized = normalize_workspace_relative_path(&rule.cwd) + .map_err(|err| PluginManifestError::Invalid(format!("{field}.cwd: {err}")))?; + if normalized.is_empty() { + ".".to_owned() + } else { + normalized + } + }; + Ok(domain::PluginWorkspaceExecRule { + command: command.to_owned(), + args: rule.args, + cwd, + effect: rule.effect, + }) +} + fn validate_contributes(raw: RawContributes) -> Result { let mut seen = HashSet::new(); let mut insert = |id: &str| { @@ -3808,6 +4330,13 @@ mod tests { "main": "dist/index.js", "trustLevel": "full", "capabilities": ["ui", "mcp", "tooling"], + "permissions": { + "workspaceExec": { + "allow": [ + {"command":"tool","args":["status","*"],"cwd":".","effect":"read"} + ] + } + }, "contributes": { "skills": [{"id":"build-debug","name":"build-debug","description":"Diagnose build failures.","kind":"workflow","path":"skills/build-debug/SKILL.md"}], "menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}], @@ -4187,6 +4716,13 @@ mod tests { ) .unwrap(); assert_eq!(m.id.as_str(), "dev.acme.gitgraph"); + let workspace_exec = m.permissions.workspace_exec.as_ref().unwrap(); + assert_eq!(workspace_exec.allow.len(), 1); + assert_eq!(workspace_exec.allow[0].command, "tool"); + assert_eq!( + workspace_exec.allow[0].effect, + PluginWorkspaceExecEffect::Read + ); assert_eq!( m.capabilities, vec![ @@ -4224,6 +4760,29 @@ mod tests { assert_eq!(m.contributes.slash_commands.len(), 1); assert_eq!(m.contributes.slash_commands[0].name, "/graph"); assert!(m.contributes.slash_commands[0].requires_confirmation); + let exec = m.permissions.workspace_exec.unwrap(); + assert_eq!(exec.allow.len(), 1); + assert!(exec.allow[0].matches("tool", &["status".to_owned()], ".")); + } + + #[test] + fn workspace_exec_manifest_rejects_shell_paths_and_partial_wildcards() { + for command_and_args in [ + serde_json::json!({"command":"/bin/tool","args":["status"],"cwd":".","effect":"read"}), + serde_json::json!({"command":"tool","args":["--format=*"],"cwd":".","effect":"read"}), + ] { + let mut value: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap(); + value["permissions"]["workspaceExec"]["allow"] = serde_json::json!([command_and_args]); + assert!(validator() + .validate( + &serde_json::to_vec(&value).unwrap(), + &domain::PluginPackageRef { + plugin_id: None, + root: "x".into() + }, + ) + .is_err()); + } } #[test] @@ -5267,6 +5826,33 @@ mod tests { } } + struct FakeWorkspaceToolRunner { + output: Mutex>, + specs: Mutex>, + } + + #[async_trait::async_trait] + impl WorkspaceToolRunner for FakeWorkspaceToolRunner { + async fn run( + &self, + spec: WorkspaceToolExecSpec, + ) -> Result { + self.specs.lock().unwrap().push(spec); + self.output.lock().unwrap().clone() + } + } + + #[derive(Default)] + struct FakeWorkspaceExecAudit { + entries: Mutex>, + } + + impl PluginWorkspaceExecAuditSink for FakeWorkspaceExecAudit { + fn record(&self, entry: PluginWorkspaceExecAuditEntry) { + self.entries.lock().unwrap().push(entry); + } + } + #[derive(Default)] struct FakeEnvironmentReader { values: Mutex>, @@ -5405,6 +5991,140 @@ mod tests { (project_id, store, Arc::new(FakeWorkspaceFs::default())) } + fn workspace_exec_fixture() -> ( + ProjectId, + PluginWorkspaceExecTool, + Arc, + Arc, + ) { + let (project_id, projects, fs) = workspace_fixture(); + fs.seed_file("/workspace/example/.marker", b"ok".to_vec()); + let packages = Arc::new(FakePackages::with_manifest(valid_manifest())); + let registry = Arc::new(FakeRegistry { + registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)), + }); + let settings = Arc::new(FakeRuntimeProjectSettings { + settings: domain::ProjectPluginSettings { + plugins: vec![domain::ProjectPluginActivation { + plugin_id: plugin_id(), + enabled: true, + }], + ..domain::ProjectPluginSettings::default() + }, + }); + let resolver = Arc::new(ProjectPluginResolver::new( + settings, + packages, + registry, + Arc::new(validator()), + )); + let runner = Arc::new(FakeWorkspaceToolRunner { + output: Mutex::new(Ok(domain::WorkspaceToolExecOutput { + exit_code: Some(0), + signal: None, + timed_out: false, + stdout: b"clean\n".to_vec(), + stderr: Vec::new(), + stdout_truncated: false, + stderr_truncated: false, + duration_ms: 12, + })), + specs: Mutex::new(Vec::new()), + }); + let env = Arc::new(FakeEnvironmentReader::default()); + env.set("PATH", "/usr/bin:/bin"); + let audit = Arc::new(FakeWorkspaceExecAudit::default()); + let tool = PluginWorkspaceExecTool::new( + projects, + fs, + resolver, + runner.clone(), + env, + Arc::new(FixedClock(1234)), + audit.clone(), + ); + (project_id, tool, runner, audit) + } + + fn workspace_exec_input(project_id: ProjectId) -> PluginWorkspaceExecInput { + PluginWorkspaceExecInput { + plugin_id: plugin_id().as_str().to_owned(), + project_id: project_id.to_string(), + command: "tool".to_owned(), + args: vec!["status".to_owned(), "--short".to_owned()], + cwd: Some(".".to_owned()), + env: vec![("NO_COLOR".to_owned(), "1".to_owned())], + stdin: None, + timeout_ms: Some(u64::MAX), + max_stdout_bytes: Some(usize::MAX), + max_stderr_bytes: Some(usize::MAX), + } + } + + #[tokio::test] + async fn workspace_exec_allows_matching_rule_clamps_limits_and_audits_metadata() { + let (project_id, tool, runner, audit) = workspace_exec_fixture(); + let result = tool + .execute(workspace_exec_input(project_id)) + .await + .unwrap(); + assert_eq!(result.stdout, "clean\n"); + let specs = runner.specs.lock().unwrap(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].timeout_ms, PLUGIN_WORKSPACE_EXEC_MAX_TIMEOUT_MS); + assert_eq!( + specs[0].max_stdout_bytes, + PLUGIN_WORKSPACE_EXEC_MAX_STDOUT_BYTES + ); + assert_eq!( + specs[0].max_stderr_bytes, + PLUGIN_WORKSPACE_EXEC_MAX_STDERR_BYTES + ); + assert!(specs[0] + .env + .contains(&("PATH".to_owned(), "/usr/bin:/bin".to_owned()))); + assert!(specs[0] + .env + .contains(&("NO_COLOR".to_owned(), "1".to_owned()))); + drop(specs); + let entries = audit.entries.lock().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].outcome, + PluginWorkspaceExecAuditOutcome::Completed + ); + assert_eq!(entries[0].arg_count, 2); + assert_eq!(entries[0].stdout_bytes, 6); + assert_eq!(entries[0].permission_rule_index, Some(0)); + } + + #[tokio::test] + async fn workspace_exec_denies_unmatched_args_and_never_runs_process() { + let (project_id, tool, runner, audit) = workspace_exec_fixture(); + let mut input = workspace_exec_input(project_id); + input.args = vec!["commit".to_owned()]; + let error = tool.execute(input).await.unwrap_err(); + assert_eq!(error.code(), "INVALID"); + assert!(runner.specs.lock().unwrap().is_empty()); + assert_eq!( + audit.entries.lock().unwrap()[0].outcome, + PluginWorkspaceExecAuditOutcome::Denied + ); + } + + #[tokio::test] + async fn workspace_exec_rejects_cwd_escape_and_non_allowlisted_env() { + let (project_id, tool, runner, _) = workspace_exec_fixture(); + let mut escape = workspace_exec_input(project_id); + escape.cwd = Some("../outside".to_owned()); + assert_eq!(tool.execute(escape).await.unwrap_err().code(), "INVALID"); + + let mut secret = workspace_exec_input(project_id); + secret.env = vec![("AWS_SECRET_ACCESS_KEY".to_owned(), "secret".to_owned())]; + assert_eq!(tool.execute(secret).await.unwrap_err().code(), "INVALID"); + assert!(runner.specs.lock().unwrap().is_empty()); + } + #[tokio::test] async fn plugin_workspace_rejects_paths_outside_project_root() { let (project_id, projects, fs) = workspace_fixture(); diff --git a/crates/application/src/project_plugin.rs b/crates/application/src/project_plugin.rs index cc738b91..02d81330 100644 --- a/crates/application/src/project_plugin.rs +++ b/crates/application/src/project_plugin.rs @@ -803,6 +803,7 @@ mod tests { trust_level: PluginTrustLevel::Full, capabilities: vec![PluginCapability::Mcp], activation_scope: PluginActivationScope::Project, + permissions: domain::PluginPermissions::default(), contributes: PluginContributionSet { mcp_servers: vec![ PluginMcpServerContribution { diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 34d0baac..0c5d5659 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -449,6 +449,59 @@ impl From for application::PluginWorkspacePathInput { } } +/// Permission-gated short workspace tool request DTO. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceExecDto { + /// Calling plugin id injected by the plugin runtime adapter. + pub plugin_id: String, + /// Project id. + pub project_id: String, + /// Bare executable name. + pub command: String, + /// Direct argument vector. + #[serde(default)] + pub args: Vec, + /// Project-relative working directory. + #[serde(default)] + pub cwd: Option, + /// Host-allowlisted environment overlay. + #[serde(default)] + pub env: std::collections::BTreeMap, + /// Optional UTF-8 stdin. + #[serde(default)] + pub stdin: Option, + /// Requested timeout. + #[serde(default)] + pub timeout_ms: Option, + /// Requested retained stdout size. + #[serde(default)] + pub max_stdout_bytes: Option, + /// Requested retained stderr size. + #[serde(default)] + pub max_stderr_bytes: Option, +} + +impl From for application::PluginWorkspaceExecInput { + fn from(value: PluginWorkspaceExecDto) -> Self { + Self { + plugin_id: value.plugin_id, + project_id: value.project_id, + command: value.command, + args: value.args, + cwd: value.cwd, + env: value.env.into_iter().collect(), + stdin: value.stdin, + timeout_ms: value.timeout_ms, + max_stdout_bytes: value.max_stdout_bytes, + max_stderr_bytes: value.max_stderr_bytes, + } + } +} + +/// Result of a permission-gated short workspace tool invocation. +pub type PluginWorkspaceExecResultDto = application::PluginWorkspaceExecResult; + /// Plugin workspace text write request DTO. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -6558,6 +6611,52 @@ mod tests { ); } + #[test] + fn dto_plugin_workspace_exec_maps_manifest_scoped_transport_contract() { + let dto: PluginWorkspaceExecDto = serde_json::from_value(json!({ + "pluginId": "dev.acme.tools", + "projectId": Uuid::from_u128(346).to_string(), + "command": "tool", + "args": ["status", "--short"], + "cwd": ".", + "env": {"NO_COLOR": "1"}, + "stdin": "input", + "timeoutMs": 5000, + "maxStdoutBytes": 256000, + "maxStderrBytes": 64000 + })) + .unwrap(); + let input: application::PluginWorkspaceExecInput = dto.into(); + assert_eq!(input.plugin_id, "dev.acme.tools"); + assert_eq!(input.command, "tool"); + assert_eq!(input.args, ["status", "--short"]); + assert_eq!(input.env, [("NO_COLOR".to_owned(), "1".to_owned())]); + + let result = application::PluginWorkspaceExecResult { + exit_code: Some(0), + signal: None, + timed_out: false, + stdout: "ok\n".to_owned(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + duration_ms: 8, + }; + assert_eq!( + serde_json::to_value(result).unwrap(), + json!({ + "exitCode": 0, + "signal": null, + "timedOut": false, + "stdout": "ok\n", + "stderr": "", + "stdoutTruncated": false, + "stderrTruncated": false, + "durationMs": 8 + }) + ); + } + #[test] fn dto_plugins_project_structure_query_maps_to_application_input() { let dto = PluginProjectStructureQueryDto { diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index ea659ef4..184e454e 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -41,16 +41,16 @@ use application::{ OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess, - PluginToolchainDiagnostics, PluginWorkspaceAccess, ProjectPluginResolver, ProposeContext, - PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext, - ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, - ReadMemory, ReadMemoryIndex, ReadOpenProjectRestoreState, ReadProjectContext, - ReadProjectSettings, ReadSkill, ReadTemplate, ReadUserSettings, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, - RecordProjectClosedForRestore, RecordProjectOpenedForRestore, RecordTurn, RecordTurnProvider, - ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput, - RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, - ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, + PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceExecTool, + ProjectPluginResolver, ProposeContext, PurgeManagedTemp, QueryProjectStructure, + ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue, + ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, + ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectSettings, ReadSkill, ReadTemplate, + ReadUserSettings, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, + ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore, + RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, + ReloadPluginInput, RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint, + ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, RestartCellAgentRuntime, RestoreOpenProjects, RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, @@ -102,20 +102,21 @@ use infrastructure::{ embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, - EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor, - FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore, - FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, - FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore, - FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore, - FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, - FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore, - FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore, - FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, - FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer, - HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, - IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, - LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, - LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, + DiagnosticPluginWorkspaceExecAudit, EmbeddedCompatibilityMatrix, EmbedderEnvProbe, + ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, + FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsDeviceSessionStore, + FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, + FsIssueStore, FsLiveStateStore, FsManagedTempStore, FsMcpToolPermissionStore, FsMemoryStore, + FsModelServerRegistry, FsOpenProjectRestoreStore, FsOrchestratorWatcher, FsPermissionStore, + FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore, + FsProjectPluginSettingsStore, FsProjectSettingsStore, FsProjectStore, FsProviderSessionStore, + FsScheduledProjectTaskStore, FsSecretStore, FsSkillStore, FsSprintStore, + FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore, + FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, + HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, + InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, + LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, + LocalWorkspaceToolRunner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, @@ -2319,6 +2320,8 @@ pub struct BackendCore { pub reconcile_plugin_mcp_servers: Arc, /// Public plugin workspace/file access facade. pub plugin_workspace_access: Arc, + /// Permission-gated short workspace tool execution facade. + pub plugin_workspace_exec: Arc, /// Public plugin structured config document facade. pub plugin_config_documents: Arc, /// Public plugin-owned storage facade. @@ -3855,6 +3858,17 @@ impl BackendCore { PluginWorkspaceAccess::new(Arc::clone(&store_port), Arc::clone(&fs_port)) .with_events(Arc::clone(&events_port)), ); + let plugin_workspace_exec = Arc::new(PluginWorkspaceExecTool::new( + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&project_plugin_resolver), + Arc::new(LocalWorkspaceToolRunner::new( + infrastructure::default_enforcer(), + )), + Arc::clone(&environment_reader_port), + Arc::clone(&clock) as Arc, + Arc::new(DiagnosticPluginWorkspaceExecAudit), + )); let plugin_config_documents = Arc::new( PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port)) .with_events(Arc::clone(&events_port)), @@ -4565,6 +4579,7 @@ impl BackendCore { list_plugin_runtime_contributions, reconcile_plugin_mcp_servers, plugin_workspace_access, + plugin_workspace_exec, plugin_config_documents, plugin_storage_access, query_project_structure, diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 8ef8187c..8e4b9552 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -249,11 +249,13 @@ pub use plugin::{ PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMcpToolMetadata, - PluginMenuItemContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry, - PluginSkillContribution, PluginSkillId, PluginSkillRef, PluginSlashCommandContribution, - PluginToolRef, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion, - ProjectPluginActivation, ProjectPluginSettings, RelativePath, RemovalOutcome, - StagedPluginPackage, + PluginMenuItemContribution, PluginPackageRef, PluginPermissions, PluginRegistry, + PluginRegistryEntry, PluginSkillContribution, PluginSkillId, PluginSkillRef, + PluginSlashCommandContribution, PluginToolRef, PluginTopLevelMenuContribution, + PluginTrustLevel, PluginVersion, PluginWorkspaceExecAuditEntry, + PluginWorkspaceExecAuditOutcome, PluginWorkspaceExecEffect, PluginWorkspaceExecPermission, + PluginWorkspaceExecRule, ProjectPluginActivation, ProjectPluginSettings, RelativePath, + RemovalOutcome, StagedPluginPackage, }; pub use sandbox::{ @@ -290,11 +292,12 @@ pub use ports::{ ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError, - PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner, - ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore, - ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, - RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask, + PluginStorageStore, PluginStoreError, PluginWorkspaceExecAuditSink, PreparedContext, + ProcessError, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, + ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, + RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask, ScheduledTaskStoreError, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, - TemplateStore, UserSettingsStore, WindowStateStore, + TemplateStore, UserSettingsStore, WindowStateStore, WorkspaceToolExecError, + WorkspaceToolExecOutput, WorkspaceToolExecSpec, WorkspaceToolRunner, }; diff --git a/crates/domain/src/plugin.rs b/crates/domain/src/plugin.rs index 15dfe0fd..938795d6 100644 --- a/crates/domain/src/plugin.rs +++ b/crates/domain/src/plugin.rs @@ -289,6 +289,132 @@ pub enum PluginCapability { Tooling, } +/// Declared impact of one generic workspace tool invocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginWorkspaceExecEffect { + /// The command is expected to inspect state only. + Read, + /// The command may modify project-owned files. + Write, + /// The command may use the network. + Network, + /// The command may perform destructive project-owned mutations. + Destructive, +} + +/// One manifest-declared allow rule for short, hidden workspace tool execution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceExecRule { + /// Bare executable name resolved by the host without a shell. + pub command: String, + /// Exact argv tokens. `*` matches one token, or the remaining suffix when last. + #[serde(default)] + pub args: Vec, + /// Allowed normalized project-relative cwd. `*` allows any confined cwd. + #[serde(default = "default_workspace_exec_cwd")] + pub cwd: String, + /// Declared impact used by host policy and audit. + pub effect: PluginWorkspaceExecEffect, +} + +fn default_workspace_exec_cwd() -> String { + ".".to_owned() +} + +impl PluginWorkspaceExecRule { + /// Whether this rule authorizes the exact executable, argv, and confined cwd. + #[must_use] + pub fn matches(&self, command: &str, args: &[String], cwd: &str) -> bool { + self.command == command + && (self.cwd == "*" || self.cwd == cwd) + && workspace_exec_args_match(&self.args, args) + } +} + +fn workspace_exec_args_match(pattern: &[String], args: &[String]) -> bool { + let Some((last, prefix)) = pattern.split_last() else { + return args.is_empty(); + }; + if last == "*" { + return args.len() >= prefix.len() + && prefix + .iter() + .zip(args) + .all(|(expected, actual)| expected == "*" || expected == actual); + } + pattern.len() == args.len() + && pattern + .iter() + .zip(args) + .all(|(expected, actual)| expected == "*" || expected == actual) +} + +/// Manifest permission block for generic project-scoped executable access. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceExecPermission { + /// Explicit allow rules. An empty list denies every invocation. + #[serde(default)] + pub allow: Vec, +} + +/// Security-sensitive permissions requested by a plugin manifest. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginPermissions { + /// Short, hidden external-tool execution permissions. + #[serde(default)] + pub workspace_exec: Option, +} + +/// Audited terminal outcome of a plugin workspace execution request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginWorkspaceExecAuditOutcome { + /// A manifest/project/runtime permission rejected the request. + Denied, + /// The allowed process was supervised to a terminal result. + Completed, + /// The allowed process could not be started or supervised. + Failed, +} + +/// Metadata-only audit event. Captured stdout/stderr contents are never included. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginWorkspaceExecAuditEntry { + /// Calling plugin identity injected by the host runtime. + pub plugin_id: PluginId, + /// Project that confined the invocation. + pub project_id: crate::ProjectId, + /// Bare executable name. + pub command: String, + /// Argument count; argument values are intentionally omitted. + pub arg_count: usize, + /// Normalized project-relative cwd. + pub cwd: String, + /// Manifest-declared effect, absent for a request denied before rule matching. + pub effect: Option, + /// Index of the manifest allow rule used, when allowed. + pub permission_rule_index: Option, + /// Terminal audit outcome. + pub outcome: PluginWorkspaceExecAuditOutcome, + /// Exit code when available. + pub exit_code: Option, + /// Whether the process hit the host timeout. + pub timed_out: bool, + /// Retained stdout byte count, never content. + pub stdout_bytes: usize, + /// Retained stderr byte count, never content. + pub stderr_bytes: usize, + /// Execution duration. + pub duration_ms: u64, + /// Audit timestamp from the host clock. + pub occurred_at_ms: i64, +} + /// Manifest-declared runtime activation scope. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -677,6 +803,9 @@ pub struct PluginManifest { /// Activation scope. Missing in older manifests means app-level activation. #[serde(default)] pub activation_scope: PluginActivationScope, + /// Security-sensitive host permissions requested by the plugin. + #[serde(default)] + pub permissions: PluginPermissions, /// Contributions. pub contributes: PluginContributionSet, } @@ -982,4 +1111,19 @@ mod tests { serde_json::json!("project") ); } + + #[test] + fn workspace_exec_rule_matches_exact_tokens_and_trailing_suffix_wildcard() { + let rule = PluginWorkspaceExecRule { + command: "tool".to_owned(), + args: vec!["status".to_owned(), "*".to_owned()], + cwd: ".".to_owned(), + effect: PluginWorkspaceExecEffect::Read, + }; + assert!(rule.matches("tool", &["status".to_owned()], ".")); + assert!(rule.matches("tool", &["status".to_owned(), "--short".to_owned()], ".")); + assert!(!rule.matches("tool", &["commit".to_owned()], ".")); + assert!(!rule.matches("other", &["status".to_owned()], ".")); + assert!(!rule.matches("tool", &["status".to_owned()], "nested")); + } } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 00a373a9..c21e9dc3 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1760,6 +1760,74 @@ pub trait ProcessSpawner: Send + Sync { async fn run(&self, spec: SpawnSpec) -> Result; } +/// Fully bounded request executed by the workspace-tool infrastructure adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceToolExecSpec { + /// Bare executable name; no shell is involved. + pub command: String, + /// Exact argument vector. + pub args: Vec, + /// Canonical working directory under the project root. + pub cwd: ProjectPath, + /// Minimal host-approved environment. + pub env: Vec<(String, String)>, + /// Optional UTF-8 standard input. + pub stdin: Option, + /// Hard runtime deadline. + pub timeout_ms: u64, + /// Maximum retained stdout bytes. + pub max_stdout_bytes: usize, + /// Maximum retained stderr bytes. + pub max_stderr_bytes: usize, + /// Filesystem sandbox applied to the child. + pub sandbox: crate::sandbox::SandboxPlan, +} + +/// Captured result of a bounded workspace-tool invocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceToolExecOutput { + /// Exit code, absent when killed by signal. + pub exit_code: Option, + /// Platform signal name/number when available. + pub signal: Option, + /// Whether the host deadline killed the process. + pub timed_out: bool, + /// Retained stdout prefix. + pub stdout: Vec, + /// Retained stderr prefix. + pub stderr: Vec, + /// Whether stdout exceeded its retained limit. + pub stdout_truncated: bool, + /// Whether stderr exceeded its retained limit. + pub stderr_truncated: bool, + /// Wall-clock duration measured by the adapter. + pub duration_ms: u64, +} + +/// Failure to start or supervise a bounded workspace tool. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum WorkspaceToolExecError { + /// Process or pipe failure. + #[error("workspace tool execution failed: {0}")] + Execution(String), +} + +/// Adapter for short, invisible, bounded external-tool invocations. +#[async_trait] +pub trait WorkspaceToolRunner: Send + Sync { + /// Runs one request to completion without creating a background-task record. + async fn run( + &self, + spec: WorkspaceToolExecSpec, + ) -> Result; +} + +/// Metadata-only audit sink for plugin workspace tool invocations. +pub trait PluginWorkspaceExecAuditSink: Send + Sync { + /// Records one denied, failed, or completed request without output contents. + fn record(&self, entry: crate::PluginWorkspaceExecAuditEntry); +} + /// Reads host environment variables through an injected adapter. pub trait EnvironmentReader: Send + Sync { /// Returns one environment variable value, if present. diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index a434b0fc..d88c41e1 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -48,6 +48,7 @@ pub mod session; pub mod sprints; pub mod store; pub mod timeparse; +pub mod workspace_tool; pub use assistant::{FsAssistantContextStore, TicketAssistantEnvironmentPreparer}; pub use background_task::{ @@ -126,3 +127,4 @@ pub use store::{ StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; +pub use workspace_tool::{DiagnosticPluginWorkspaceExecAudit, LocalWorkspaceToolRunner}; diff --git a/crates/infrastructure/src/workspace_tool.rs b/crates/infrastructure/src/workspace_tool.rs new file mode 100644 index 00000000..0f0d3c25 --- /dev/null +++ b/crates/infrastructure/src/workspace_tool.rs @@ -0,0 +1,266 @@ +//! Bounded, sandboxed process adapter for the plugin workspace execution SDK. + +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use domain::ports::{ + PluginWorkspaceExecAuditSink, WorkspaceToolExecError, WorkspaceToolExecOutput, + WorkspaceToolExecSpec, WorkspaceToolRunner, +}; +use domain::sandbox::SandboxEnforcer; +use domain::PluginWorkspaceExecAuditEntry; + +const MAX_CONCURRENT_WORKSPACE_TOOLS: usize = 4; + +/// Local process runner with hard concurrency, time, output and sandbox bounds. +pub struct LocalWorkspaceToolRunner { + sandbox: Arc, + permits: Arc, +} + +impl LocalWorkspaceToolRunner { + /// Builds the adapter with the selected platform sandbox enforcer. + #[must_use] + pub fn new(sandbox: Arc) -> Self { + Self { + sandbox, + permits: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_WORKSPACE_TOOLS)), + } + } +} + +#[async_trait] +impl WorkspaceToolRunner for LocalWorkspaceToolRunner { + async fn run( + &self, + spec: WorkspaceToolExecSpec, + ) -> Result { + let _permit = Arc::clone(&self.permits) + .acquire_owned() + .await + .map_err(|_| WorkspaceToolExecError::Execution("runner is shutting down".to_owned()))?; + let sandbox = Arc::clone(&self.sandbox); + let (tx, rx) = tokio::sync::oneshot::channel(); + std::thread::spawn(move || { + let result = run_blocking(spec, sandbox.as_ref()); + let _ = tx.send(result); + }); + rx.await.map_err(|_| { + WorkspaceToolExecError::Execution("workspace tool runner thread stopped".to_owned()) + })? + } +} + +fn run_blocking( + spec: WorkspaceToolExecSpec, + sandbox: &dyn SandboxEnforcer, +) -> Result { + let started = Instant::now(); + sandbox + .enforce(&spec.sandbox) + .map_err(|error| WorkspaceToolExecError::Execution(format!("sandbox: {error}")))?; + + let mut command = Command::new(&spec.command); + command + .args(&spec.args) + .current_dir(spec.cwd.as_str()) + .env_clear() + .envs(spec.env.iter().cloned()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command + .spawn() + .map_err(|error| WorkspaceToolExecError::Execution(format!("{}: {error}", spec.command)))?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| WorkspaceToolExecError::Execution("stdout pipe unavailable".to_owned()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| WorkspaceToolExecError::Execution("stderr pipe unavailable".to_owned()))?; + let stdout_limit = spec.max_stdout_bytes; + let stderr_limit = spec.max_stderr_bytes; + let stdout_reader = std::thread::spawn(move || read_bounded(stdout, stdout_limit)); + let stderr_reader = std::thread::spawn(move || read_bounded(stderr, stderr_limit)); + + let stdin_writer = match spec.stdin { + Some(stdin) => { + let mut pipe = child.stdin.take().ok_or_else(|| { + WorkspaceToolExecError::Execution("stdin pipe unavailable".to_owned()) + })?; + Some(std::thread::spawn(move || pipe.write_all(stdin.as_bytes()))) + } + None => { + drop(child.stdin.take()); + None + } + }; + + let deadline = Duration::from_millis(spec.timeout_ms); + let (status, timed_out) = loop { + if let Some(status) = child + .try_wait() + .map_err(|error| WorkspaceToolExecError::Execution(error.to_string()))? + { + break (status, false); + } + if started.elapsed() >= deadline { + child + .kill() + .map_err(|error| WorkspaceToolExecError::Execution(error.to_string()))?; + let status = child + .wait() + .map_err(|error| WorkspaceToolExecError::Execution(error.to_string()))?; + break (status, true); + } + std::thread::sleep(Duration::from_millis(5)); + }; + + let (stdout, stdout_truncated) = stdout_reader.join().map_err(|_| { + WorkspaceToolExecError::Execution("stdout reader thread panicked".to_owned()) + })??; + let (stderr, stderr_truncated) = stderr_reader.join().map_err(|_| { + WorkspaceToolExecError::Execution("stderr reader thread panicked".to_owned()) + })??; + if let Some(writer) = stdin_writer { + writer + .join() + .map_err(|_| { + WorkspaceToolExecError::Execution("stdin writer thread panicked".to_owned()) + })? + .or_else(|error| { + if error.kind() == std::io::ErrorKind::BrokenPipe { + Ok(()) + } else { + Err(error) + } + }) + .map_err(|error| WorkspaceToolExecError::Execution(error.to_string()))?; + } + + Ok(WorkspaceToolExecOutput { + exit_code: status.code(), + signal: exit_signal(&status), + timed_out, + stdout, + stderr, + stdout_truncated, + stderr_truncated, + duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + }) +} + +fn read_bounded( + mut reader: impl Read, + limit: usize, +) -> Result<(Vec, bool), WorkspaceToolExecError> { + let mut retained = Vec::with_capacity(limit.min(8 * 1024)); + let mut truncated = false; + let mut chunk = [0_u8; 8 * 1024]; + loop { + let read = reader + .read(&mut chunk) + .map_err(|error| WorkspaceToolExecError::Execution(error.to_string()))?; + if read == 0 { + break; + } + let remaining = limit.saturating_sub(retained.len()); + let keep = remaining.min(read); + retained.extend_from_slice(&chunk[..keep]); + truncated |= keep < read; + } + Ok((retained, truncated)) +} + +#[cfg(unix)] +fn exit_signal(status: &std::process::ExitStatus) -> Option { + use std::os::unix::process::ExitStatusExt; + status.signal().map(|signal| signal.to_string()) +} + +#[cfg(not(unix))] +fn exit_signal(_status: &std::process::ExitStatus) -> Option { + None +} + +/// Structured diagnostic audit sink that never serializes captured output. +#[derive(Debug, Default, Clone, Copy)] +pub struct DiagnosticPluginWorkspaceExecAudit; + +impl PluginWorkspaceExecAuditSink for DiagnosticPluginWorkspaceExecAudit { + fn record(&self, entry: PluginWorkspaceExecAuditEntry) { + match serde_json::to_string(&entry) { + Ok(json) => eprintln!("[plugin-workspace-exec] {json}"), + Err(error) => eprintln!("[plugin-workspace-exec] audit serialization failed: {error}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::permission::Posture; + use domain::sandbox::SandboxPlan; + use domain::ProjectPath; + + #[test] + fn bounded_reader_drains_but_retains_only_prefix() { + let (bytes, truncated) = read_bounded(&b"abcdef"[..], 3).unwrap(); + assert_eq!(bytes, b"abc"); + assert!(truncated); + } + + fn spec(args: &[&str], timeout_ms: u64, stdout_limit: usize) -> WorkspaceToolExecSpec { + WorkspaceToolExecSpec { + command: "sh".to_owned(), + args: args.iter().map(|value| (*value).to_owned()).collect(), + cwd: ProjectPath::new("/").unwrap(), + env: vec![("PATH".to_owned(), "/usr/bin:/bin".to_owned())], + stdin: None, + timeout_ms, + max_stdout_bytes: stdout_limit, + max_stderr_bytes: 32, + sandbox: SandboxPlan { + allowed: Vec::new(), + default_posture: Posture::Allow, + }, + } + } + + #[tokio::test] + async fn local_runner_times_out_and_reaps_process() { + let runner = LocalWorkspaceToolRunner::new(Arc::new(crate::sandbox::NoopSandbox)); + let result = runner.run(spec(&["-c", "sleep 1"], 20, 32)).await.unwrap(); + assert!(result.timed_out); + assert!(result.duration_ms < 1_000); + } + + #[tokio::test] + async fn local_runner_timeout_is_not_blocked_by_unread_stdin() { + let runner = LocalWorkspaceToolRunner::new(Arc::new(crate::sandbox::NoopSandbox)); + let mut request = spec(&["-c", "sleep 1"], 20, 32); + request.stdin = Some("x".repeat(64_000)); + let result = runner.run(request).await.unwrap(); + assert!(result.timed_out); + assert!(result.duration_ms < 1_000); + } + + #[tokio::test] + async fn local_runner_keeps_streams_separate_and_truncates_output() { + let runner = LocalWorkspaceToolRunner::new(Arc::new(crate::sandbox::NoopSandbox)); + let result = runner + .run(spec(&["-c", "printf 123456; printf err >&2"], 1_000, 3)) + .await + .unwrap(); + assert_eq!(result.stdout, b"123"); + assert_eq!(result.stderr, b"err"); + assert!(result.stdout_truncated); + assert!(!result.stderr_truncated); + } +} diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts index d816ece8..41dc3af7 100644 --- a/frontend/src/adapters/http/unsupported.ts +++ b/frontend/src/adapters/http/unsupported.ts @@ -59,6 +59,8 @@ import type { PluginToolchainDiagnosticRequest, PluginToolchainGateway, PluginWorkspaceGateway, + PluginWorkspaceExecInput, + PluginWorkspaceExecResult, PluginWorkspacePathInput, PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, @@ -221,6 +223,9 @@ export class WebPluginGateway implements PluginGateway { /** Web stub: public plugin workspace services are only meaningful where plugins run. */ export class WebPluginWorkspaceGateway implements PluginWorkspaceGateway { + async execTool(_input: PluginWorkspaceExecInput): Promise { + return unsupportedOnWeb("Plugin workspace tool execution"); + } async readText(_input: PluginWorkspacePathInput): Promise { return unsupportedOnWeb("Plugin workspace access"); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index d8fc9115..767dc130 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -160,6 +160,8 @@ import type { PluginToolchainDiagnosticRequest, PluginToolchainGateway, PluginWorkspaceGateway, + PluginWorkspaceExecInput, + PluginWorkspaceExecResult, PluginWorkspacePathInput, PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, @@ -4553,6 +4555,10 @@ function basename(path: string): string { export class MockPluginWorkspaceGateway implements PluginWorkspaceGateway { private readonly files = new Map>(); + async execTool(_input: PluginWorkspaceExecInput): Promise { + throw new Error("Plugin workspace tool execution is unavailable in the mock adapter"); + } + private bucket(projectId: string): Map { let files = this.files.get(projectId); if (!files) { diff --git a/frontend/src/adapters/pluginWorkspace.ts b/frontend/src/adapters/pluginWorkspace.ts index 1e043a4b..8eb15bfe 100644 --- a/frontend/src/adapters/pluginWorkspace.ts +++ b/frontend/src/adapters/pluginWorkspace.ts @@ -16,6 +16,8 @@ import type { import type { PluginProjectStructureQuery, PluginWorkspaceGateway, + PluginWorkspaceExecInput, + PluginWorkspaceExecResult, PluginWorkspacePathInput, PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, @@ -74,4 +76,8 @@ export class TauriPluginWorkspaceGateway implements PluginWorkspaceGateway { queryProjectStructure(input: PluginProjectStructureQuery): Promise { return invoke("plugin_query_project_structure", { input }); } + + execTool(input: PluginWorkspaceExecInput): Promise { + return invoke("plugin_workspace_exec_tool", { input }); + } } diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 0d66e639..8a644259 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -1564,6 +1564,7 @@ describe("CustomAgentChatView", () => { it("cancels only the current request and keeps the structured session usable", async () => { let resolveCancel: (result: CancelAgentChatResult) => void = () => {}; let firstTurnChunk: (chunk: ReplyChunk) => void = () => {}; + let reattachedChunk: (chunk: ReplyChunk) => void = () => {}; let rejectFirstTurn: (reason: unknown) => void = () => {}; const system = new MockSystemGateway(); const workState = new MockWorkStateGateway(); @@ -1581,10 +1582,12 @@ describe("CustomAgentChatView", () => { }); const agent = { launchAgentChat: vi.fn(), - reattachAgentChat: vi.fn(async (sessionId: string) => ({ - sessionId, - scrollback: [], - })), + reattachAgentChat: vi.fn( + async (sessionId: string, onChunk: (chunk: ReplyChunk) => void) => { + reattachedChunk = onChunk; + return { sessionId, scrollback: [] }; + }, + ), sendAgentChat: vi .fn() .mockImplementationOnce((_sessionId: string, _prompt: string, onChunk) => { @@ -1594,6 +1597,41 @@ describe("CustomAgentChatView", () => { }); }) .mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => { + reattachedChunk({ kind: "final", content: "Stale cancelled answer" }); + onChunk({ + kind: "progress", + progress: { + source: "providerNative", + kind: "tool", + stage: "completed", + label: "web_search", + provider: "codex", + nativeEvent: "item.completed", + }, + }); + onChunk({ + kind: "progress", + progress: { + source: "providerNative", + kind: "message", + stage: "completed", + label: "message intermédiaire", + text: "Complete intermediate answer", + provider: "codex", + nativeEvent: "item.completed", + }, + }); + onChunk({ + kind: "progress", + progress: { + source: "providerNative", + kind: "turn", + stage: "completed", + label: "tour terminé côté provider", + provider: "codex", + nativeEvent: "turn.completed", + }, + }); onChunk({ kind: "final", content: "Next answer" }); }), stopLiveAgent: vi.fn(), @@ -1729,7 +1767,11 @@ describe("CustomAgentChatView", () => { "next turn", expect.any(Function), ); + expect(await screen.findByText("web_search")).toBeTruthy(); + expect(await screen.findByText("Complete intermediate answer")).toBeTruthy(); expect(await screen.findByText("Next answer")).toBeTruthy(); + expect(screen.queryByText("Stale cancelled answer")).toBeNull(); + expect(screen.queryByRole("button", { name: "Stop" })).toBeNull(); }); it.each(["alreadyCancelled", "alreadyCompleted"] as const)( diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 4ba398f7..ee89d07c 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -1503,10 +1503,17 @@ export function CustomAgentChatView({ markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current); activeInteractionIdRef.current = null; } + stopTerminalOverrideRef.current = true; + setStopTerminalOverride(true); + setRequesterDelegation({ + currentAgentBusy: false, + delegationTargetName: null, + }); setBusy(false); + void refreshRequesterDelegation(); } }, - [projectId, updateChatViewState], + [projectId, refreshRequesterDelegation, updateChatViewState], ); useEffect(() => { @@ -2315,7 +2322,6 @@ export function CustomAgentChatView({ const attachmentLabels = outgoingAttachments.map((item) => item.label); const displayText = text || "Pièce jointe"; const interactionId = `${agentId}:${++interactionSeqRef.current}`; - ignoreUnscopedChunksAfterStopRef.current = false; stopTerminalOverrideRef.current = false; setStopTerminalOverride(false); activeInteractionIdRef.current = interactionId; diff --git a/frontend/src/features/projects/ProjectTabs.test.tsx b/frontend/src/features/projects/ProjectTabs.test.tsx index 903bd0a6..c1d310d7 100644 --- a/frontend/src/features/projects/ProjectTabs.test.tsx +++ b/frontend/src/features/projects/ProjectTabs.test.tsx @@ -25,6 +25,7 @@ function renderTabs(props: Partial> = { activeTabId={props.activeTabId ?? "a"} onSelect={props.onSelect ?? vi.fn()} onClose={props.onClose ?? vi.fn()} + onReorder={props.onReorder} projectsPanelOpen={props.projectsPanelOpen ?? false} onOpenProjectsPanel={onOpenProjectsPanel} activityByProjectId={props.activityByProjectId} @@ -94,3 +95,44 @@ describe("ProjectTabs project activity dots (#159)", () => { expect(screen.getByTitle(/beta.*échange en cours/i)).toBeTruthy(); }); }); + +describe("ProjectTabs reordering (#347)", () => { + it("reorders tabs horizontally by drag-and-drop without selecting or closing them", () => { + const onReorder = vi.fn(); + const onSelect = vi.fn(); + const onClose = vi.fn(); + renderTabs({ onReorder, onSelect, onClose }); + const alpha = screen.getByRole("tab", { name: /alpha/ }); + const beta = screen.getByRole("tab", { name: /beta/ }); + const alphaContainer = alpha.parentElement!; + const betaContainer = beta.parentElement!; + const data = new Map(); + const dataTransfer = { + effectAllowed: "none", + dropEffect: "none", + setData: (type: string, value: string) => data.set(type, value), + getData: (type: string) => data.get(type) ?? "", + }; + + fireEvent.dragStart(betaContainer, { dataTransfer }); + fireEvent.dragOver(alphaContainer, { dataTransfer }); + fireEvent.drop(alphaContainer, { dataTransfer, clientX: 0 }); + + expect(onReorder).toHaveBeenCalledWith(["b", "a"]); + expect(onSelect).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("moves the focused tab with Alt+Arrow and announces the change", () => { + const onReorder = vi.fn(); + renderTabs({ onReorder }); + + fireEvent.keyDown(screen.getByRole("tab", { name: /alpha/ }), { + key: "ArrowRight", + altKey: true, + }); + + expect(onReorder).toHaveBeenCalledWith(["b", "a"]); + expect(screen.getByText("alpha déplacé.")).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/projects/ProjectTabs.tsx b/frontend/src/features/projects/ProjectTabs.tsx index 6f7ccabb..10ba4142 100644 --- a/frontend/src/features/projects/ProjectTabs.tsx +++ b/frontend/src/features/projects/ProjectTabs.tsx @@ -21,6 +21,7 @@ export interface ProjectTabsProps { activeTabId: string | null; onSelect: (id: string) => void; onClose: (id: string) => void; + onReorder?: (orderedIds: string[]) => void; /** Whether the Projects panel is currently open (in any placement). */ projectsPanelOpen: boolean; /** Open the Projects panel (floating) — create/switch project. */ @@ -36,6 +37,7 @@ export function ProjectTabs({ activeTabId, onSelect, onClose, + onReorder, projectsPanelOpen, onOpenProjectsPanel, activityByProjectId = {}, @@ -72,6 +74,7 @@ export function ProjectTabs({ value={activeTabId} onSelect={onSelect} onClose={onClose} + onReorder={onReorder} /> )} {/* Add-project affordance (#46): always a bordered `secondary` button so diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 58b0db49..4e84c82f 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -1071,6 +1071,7 @@ export function ProjectsView() { activeTabId={vm.activeTabId} onSelect={(id) => vm.activateTab(id)} onClose={(id) => void vm.closeTab(id)} + onReorder={(orderedIds) => void vm.reorderTabs(orderedIds)} activityByProjectId={projectActivity} projectsPanelOpen={placementOf(placements, "projects") !== "closed"} onOpenProjectsPanel={() => { diff --git a/frontend/src/features/projects/projects.test.tsx b/frontend/src/features/projects/projects.test.tsx index 093ed708..13e449c9 100644 --- a/frontend/src/features/projects/projects.test.tsx +++ b/frontend/src/features/projects/projects.test.tsx @@ -3,7 +3,7 @@ * real `DIProvider`. Covers create → list/tab, open → tab, close → remove, * duplicate root (INVALID) and unknown id (NOT_FOUND) error handling. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen, @@ -76,6 +76,10 @@ function tablist() { return screen.getByRole("tablist"); } +function tabLabels() { + return within(tablist()).getAllByRole("tab").map((tab) => tab.textContent?.trim()); +} + /** * Opens a panel floating via the single « Panneaux » menu (#26): click the menu, * click the panel entry (opens its placement submenu), then pick « Flottant ». @@ -165,6 +169,56 @@ describe("ProjectsView (with MockProjectGateway)", () => { expect(screen.queryByRole("status")).toBeNull(); }); + it("reorders project tabs, persists the order, and preserves the active tab", async () => { + const project = new MockProjectGateway(); + const alpha = await project.createProject("alpha", "/p/a"); + const beta = await project.createProject("beta", "/p/b"); + project.setOpenProjectRestoreOrder([alpha.id, beta.id]); + const saveOrder = vi.spyOn(project, "saveOpenProjectRestoreOrder"); + renderView(project); + + await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"])); + expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true"); + fireEvent.keyDown(screen.getByRole("tab", { name: "beta" }), { + key: "ArrowLeft", + altKey: true, + }); + + await waitFor(() => expect(tabLabels()).toEqual(["beta", "alpha"])); + expect(saveOrder).toHaveBeenCalledWith([beta.id, alpha.id]); + expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true"); + await expect(project.readOpenProjectRestoreState()).resolves.toMatchObject({ + entries: [ + expect.objectContaining({ projectId: beta.id, position: 0 }), + expect.objectContaining({ projectId: alpha.id, position: 1 }), + ], + }); + }); + + it("rolls a tab reorder back when persistence fails", async () => { + const project = new MockProjectGateway(); + const alpha = await project.createProject("alpha", "/p/a"); + const beta = await project.createProject("beta", "/p/b"); + project.setOpenProjectRestoreOrder([alpha.id, beta.id]); + let rejectSave: (reason: unknown) => void = () => {}; + vi.spyOn(project, "saveOpenProjectRestoreOrder").mockImplementationOnce( + () => new Promise((_resolve, reject) => { rejectSave = reject; }), + ); + renderView(project); + + await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"])); + fireEvent.keyDown(screen.getByRole("tab", { name: "beta" }), { + key: "ArrowLeft", + altKey: true, + }); + await waitFor(() => expect(tabLabels()).toEqual(["beta", "alpha"])); + rejectSave({ code: "STORE", message: "could not persist tab order" }); + + await waitFor(() => expect(tabLabels()).toEqual(["alpha", "beta"])); + expect(screen.getByRole("alert").textContent).toContain("could not persist tab order"); + expect(screen.getByRole("tab", { name: "beta" }).getAttribute("aria-selected")).toBe("true"); + }); + it("shows one restore warning, details its cause, and retries at the retained position", async () => { const project = new MockProjectGateway(); const alpha = await project.createProject("alpha", "/p/a"); diff --git a/frontend/src/features/projects/useProjects.ts b/frontend/src/features/projects/useProjects.ts index cd34b544..9b85c86a 100644 --- a/frontend/src/features/projects/useProjects.ts +++ b/frontend/src/features/projects/useProjects.ts @@ -40,6 +40,8 @@ export interface ProjectsViewModel { openProject: (projectId: string) => Promise; /** Closes a tab (does not delete the project from the registry). */ closeTab: (projectId: string) => Promise; + /** Reorders open tabs optimistically and persists their restore order. */ + reorderTabs: (orderedProjectIds: string[]) => Promise; /** Removes a project from IdeA's registry without deleting its files. */ removeProject: (projectId: string) => Promise; /** Retries one failed restore, or every failure when omitted. */ @@ -68,6 +70,9 @@ export function useProjects(): ProjectsViewModel { const [restoreFailures, setRestoreFailures] = useState([]); const [restoreNotificationVisible, setRestoreNotificationVisible] = useState(false); const initializationStarted = useRef(false); + const openTabsRef = useRef([]); + const reorderInFlight = useRef(false); + openTabsRef.current = openTabs; const refresh = useCallback(async () => { setBusy(true); @@ -174,6 +179,43 @@ export function useProjects(): ProjectsViewModel { [project], ); + const reorderTabs = useCallback( + async (orderedProjectIds: string[]) => { + if (reorderInFlight.current) return; + const previous = openTabsRef.current; + if ( + orderedProjectIds.length !== previous.length || + new Set(orderedProjectIds).size !== previous.length || + orderedProjectIds.some((id) => !previous.some((tab) => tab.id === id)) + ) return; + if (orderedProjectIds.every((id, index) => id === previous[index]?.id)) return; + const byId = new Map(previous.map((tab) => [tab.id, tab])); + const next = orderedProjectIds.map((id) => byId.get(id)!); + reorderInFlight.current = true; + openTabsRef.current = next; + setOpenTabs(next); + setError(null); + try { + await project.saveOpenProjectRestoreOrder(orderedProjectIds); + } catch (e) { + setOpenTabs((current) => { + const currentById = new Map(current.map((tab) => [tab.id, tab])); + const restored = previous + .filter((tab) => currentById.has(tab.id)) + .map((tab) => currentById.get(tab.id)!); + const previousIds = new Set(previous.map((tab) => tab.id)); + const merged = [...restored, ...current.filter((tab) => !previousIds.has(tab.id))]; + openTabsRef.current = merged; + return merged; + }); + setError(describe(e)); + } finally { + reorderInFlight.current = false; + } + }, + [project], + ); + const reconcileRemovedProject = useCallback( (projectId: string, nextProjects: Project[]) => { setProjects(nextProjects); @@ -290,6 +332,7 @@ export function useProjects(): ProjectsViewModel { createProject, openProject, closeTab, + reorderTabs, removeProject, retryRestore, removeFromRestore, diff --git a/frontend/src/plugins/runtime/index.ts b/frontend/src/plugins/runtime/index.ts index 3ef0af8d..64005f79 100644 --- a/frontend/src/plugins/runtime/index.ts +++ b/frontend/src/plugins/runtime/index.ts @@ -64,6 +64,8 @@ export { type WorkspaceBinaryFile, type WorkspaceDirEntry, type WorkspaceDirectoryListing, + type WorkspaceExecToolRequest, + type WorkspaceExecToolResult, type WorkspaceResolvedPath, type WorkspaceStat, type WorkspaceStructureQuery, diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 892f1cee..c44a6da3 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -268,6 +268,7 @@ describe("loadPlugins", () => { "workspace", ]); expect((globalThis as Record).__workspaceServiceKeys).toEqual([ + "execTool", "getCurrentProject", "getProjectRoot", "listDirectory", diff --git a/frontend/src/plugins/runtime/services.test.ts b/frontend/src/plugins/runtime/services.test.ts index e64d5143..7fd4c217 100644 --- a/frontend/src/plugins/runtime/services.test.ts +++ b/frontend/src/plugins/runtime/services.test.ts @@ -125,6 +125,16 @@ function gateways(overrides: { ...overrides.window, }; const pluginWorkspace: PluginWorkspaceGateway = { + execTool: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "ok\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + durationMs: 4, + })), readText: vi.fn(async ({ path }) => ({ path, content: "file text" })), readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })), writeText: vi.fn(), @@ -332,6 +342,21 @@ describe("createPluginServices", () => { expect(g.project.updateProjectContext).toHaveBeenCalledWith("project-1", "next context"); }); + it("injects plugin and project identity into workspace execTool", async () => { + const g = gateways(); + const services = createPluginServices(g, { pluginId: "dev.acme.plugin" }); + + await expect( + services.workspace.execTool({ command: "tool", args: ["status"] }), + ).resolves.toMatchObject({ exitCode: 0, stdout: "ok\n", timedOut: false }); + expect(g.pluginWorkspace.execTool).toHaveBeenCalledWith(expect.objectContaining({ + pluginId: "dev.acme.plugin", + projectId: "project-1", + command: "tool", + args: ["status"], + })); + }); + it("maps background tasks through the work-state gateway and wraps output attachment", async () => { const detach = vi.fn(); const onData = vi.fn(); diff --git a/frontend/src/plugins/runtime/services.ts b/frontend/src/plugins/runtime/services.ts index 052a41db..39d0b0f5 100644 --- a/frontend/src/plugins/runtime/services.ts +++ b/frontend/src/plugins/runtime/services.ts @@ -13,6 +13,7 @@ import type { PluginConfigGateway, PluginEventGateway, PluginProjectStructureQuery, + PluginWorkspaceExecInput as HostPluginWorkspaceExecInput, PluginTaskGateway, PluginToolchainGateway, PluginWorkspaceGateway, @@ -52,6 +53,31 @@ export interface WorkspaceService { stat(path: string, projectId?: string): Promise; watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise; queryStructure(query?: WorkspaceStructureQuery): Promise; + /** Runs one manifest-allowlisted binary directly, without a visible task. */ + execTool(request: WorkspaceExecToolRequest): Promise; +} + +export interface WorkspaceExecToolRequest { + projectId?: string; + command: string; + args?: string[]; + cwd?: string; + env?: Record; + timeoutMs?: number; + maxStdoutBytes?: number; + maxStderrBytes?: number; + stdin?: string; +} + +export interface WorkspaceExecToolResult { + exitCode: number | null; + signal?: string; + timedOut: boolean; + stdout: string; + stderr: string; + stdoutTruncated: boolean; + stderrTruncated: boolean; + durationMs: number; } export interface WorkspaceResolvedPath { @@ -651,6 +677,33 @@ export function createPluginServices( }; return gateways.pluginWorkspace.queryProjectStructure(input); }, + async execTool(request) { + if (!scope.pluginId) throw new Error("pluginId is required for workspace.execTool"); + const project = await requireProject(request.projectId); + const input: HostPluginWorkspaceExecInput = { + pluginId: scope.pluginId, + projectId: project.id, + command: request.command, + args: request.args, + cwd: request.cwd, + env: request.env, + timeoutMs: request.timeoutMs, + maxStdoutBytes: request.maxStdoutBytes, + maxStderrBytes: request.maxStderrBytes, + stdin: request.stdin, + }; + const result = await gateways.pluginWorkspace.execTool(input); + return { + exitCode: result.exitCode, + ...(result.signal ? { signal: result.signal } : {}), + timedOut: result.timedOut, + stdout: result.stdout, + stderr: result.stderr, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, + durationMs: result.durationMs, + }; + }, }; async function listTasks(projectId?: string): Promise { diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index d736a6ad..31a26388 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -1746,6 +1746,30 @@ export interface PluginProjectStructureQuery { maxEntries?: number; } +export interface PluginWorkspaceExecInput { + pluginId: string; + projectId: string; + command: string; + args?: string[]; + cwd?: string; + env?: Record; + stdin?: string; + timeoutMs?: number; + maxStdoutBytes?: number; + maxStderrBytes?: number; +} + +export interface PluginWorkspaceExecResult { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + stdout: string; + stderr: string; + stdoutTruncated: boolean; + stderrTruncated: boolean; + durationMs: number; +} + export interface PluginWorkspaceGateway { readText(input: PluginWorkspacePathInput): Promise; readBinary(input: PluginWorkspacePathInput): Promise; @@ -1754,6 +1778,7 @@ export interface PluginWorkspaceGateway { listDir(input: PluginWorkspacePathInput): Promise; stat(input: PluginWorkspacePathInput): Promise; queryProjectStructure(input: PluginProjectStructureQuery): Promise; + execTool(input: PluginWorkspaceExecInput): Promise; } export interface PluginRunCommandInput { diff --git a/frontend/src/shared/ui/Tabs.tsx b/frontend/src/shared/ui/Tabs.tsx index 3615d202..7b37cf40 100644 --- a/frontend/src/shared/ui/Tabs.tsx +++ b/frontend/src/shared/ui/Tabs.tsx @@ -7,7 +7,8 @@ import { cn } from "../lib/cn"; import { IconButton } from "./IconButton"; -import type { CSSProperties, ReactNode } from "react"; +import { useState } from "react"; +import type { CSSProperties, DragEvent, KeyboardEvent, ReactNode } from "react"; import { useI18n } from "@/i18n"; interface ThemedTabStyle extends CSSProperties { @@ -37,17 +38,53 @@ export interface TabsProps { onSelect: (id: string) => void; /** When provided, each tab shows a close (×) control. */ onClose?: (id: string) => void; + /** When provided, tabs can be reordered with drag-and-drop or Alt+Arrow. */ + onReorder?: (orderedIds: string[]) => void; className?: string; } /** A themed tab strip with selection and optional per-tab close. */ -export function Tabs({ items, value, onSelect, onClose, className }: TabsProps) { +export function Tabs({ items, value, onSelect, onClose, onReorder, className }: TabsProps) { const { tr } = useI18n(); + const [draggedId, setDraggedId] = useState(null); + const [announcement, setAnnouncement] = useState(""); + + const reorder = (id: string, targetId: string, after: boolean) => { + if (!onReorder || id === targetId) return; + const orderedIds = items.map((item) => item.id).filter((itemId) => itemId !== id); + const targetIndex = orderedIds.indexOf(targetId); + if (targetIndex < 0) return; + orderedIds.splice(targetIndex + (after ? 1 : 0), 0, id); + if (orderedIds.every((itemId, index) => itemId === items[index]?.id)) return; + onReorder(orderedIds); + const moved = items.find((item) => item.id === id)?.label ?? id; + setAnnouncement(tr(`${moved} déplacé.`, `${moved} moved.`)); + }; + + const handleDrop = (event: DragEvent, targetId: string) => { + event.preventDefault(); + const id = draggedId ?? event.dataTransfer.getData("text/plain"); + const rect = event.currentTarget.getBoundingClientRect(); + reorder(id, targetId, rect.width > 0 && event.clientX >= rect.left + rect.width / 2); + setDraggedId(null); + }; + + const handleKeyDown = (event: KeyboardEvent, id: string) => { + if (!onReorder || !event.altKey || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return; + const index = items.findIndex((item) => item.id === id); + const targetIndex = index + (event.key === "ArrowLeft" ? -1 : 1); + const target = items[targetIndex]; + if (!target) return; + event.preventDefault(); + reorder(id, target.id, event.key === "ArrowRight"); + }; + return (
+ {announcement} {items.map((tab) => { const active = tab.id === value; const themedStyle: ThemedTabStyle | undefined = tab.themeColor @@ -62,9 +99,25 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps) return (
{ + if (!onReorder) return; + setDraggedId(tab.id); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", tab.id); + }} + onDragOver={(event) => { + if (!onReorder) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={(event) => handleDrop(event, tab.id)} + onDragEnd={() => setDraggedId(null)} style={themedStyle} className={cn( "group flex items-center gap-1 rounded-md border px-1 transition-colors", + onReorder && "cursor-grab active:cursor-grabbing", + draggedId === tab.id && "opacity-60", active ? cn( "border-border-strong", @@ -83,6 +136,8 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps) role="tab" aria-selected={active} onClick={() => onSelect(tab.id)} + onKeyDown={(event) => handleKeyDown(event, tab.id)} + aria-keyshortcuts={onReorder ? "Alt+ArrowLeft Alt+ArrowRight" : undefined} className={cn( "flex items-center gap-1.5 px-2 py-1 text-sm focus-visible:outline-none", active ? "font-semibold text-content" : "text-muted hover:text-content", @@ -96,6 +151,8 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps) size="sm" aria-label={tr(`fermer ${tab.label}`, `close ${tab.label}`)} onClick={() => onClose(tab.id)} + draggable={false} + onDragStart={(event) => event.stopPropagation()} className="opacity-60 group-hover:opacity-100" > × diff --git a/sdk/IdeaSDK b/sdk/IdeaSDK index 720cd539..8ff0f475 160000 --- a/sdk/IdeaSDK +++ b/sdk/IdeaSDK @@ -1 +1 @@ -Subproject commit 720cd539247aa4e6b93b4d0675afd0146301e0dc +Subproject commit 8ff0f475dedc406f52abbb2be36101b3b0bd0d95