feat(backend,frontend): lot utilisateur #346 #347 #348 — exécution workspace plugin contrôlée par l'hôte, réordonnancement des onglets projet par glisser-déposer, Final restauré après stop d'agent CLI custom (QA verte)
#346 — capacités plugin SCM: le manifeste permissions.workspaceExec (contrat SDK 8ff0f47) est appliqué côté hôte — domain PluginWorkspaceExec Rule/Effect + audit sink, port WorkspaceToolRunner, adapter LocalWorkspaceToolRunner sandboxé (concurrence 4, timeout, bornes stdout/stderr, résolution bare executable sans shell), service application + DTO backend + commandes app-tauri, gateway frontend execTool exposée au runtime plugin — fin des lectures ad hoc du store interne .git par les plugins. #347 — onglets projet réordonnables par glisser-déposer (Tabs DnD, onReorder, persistance de l'ordre via useProjects). #348 — stop d'agent CLI custom: le message Final vert réapparaît au tour suivant (override terminal stop armé correctement, reset de l'interaction, refresh de la délégation requester). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -448,6 +448,7 @@ pub fn run() {
|
|||||||
plugins::plugin_storage_delete,
|
plugins::plugin_storage_delete,
|
||||||
plugins::plugin_workspace_list_dir,
|
plugins::plugin_workspace_list_dir,
|
||||||
plugins::plugin_workspace_stat,
|
plugins::plugin_workspace_stat,
|
||||||
|
plugins::plugin_workspace_exec_tool,
|
||||||
plugins::plugin_query_project_structure,
|
plugins::plugin_query_project_structure,
|
||||||
plugins::plugin_config_read_document,
|
plugins::plugin_config_read_document,
|
||||||
plugins::plugin_config_update_document,
|
plugins::plugin_config_update_document,
|
||||||
@ -488,6 +489,7 @@ fn plugin_workspace_invoke_handler<R: tauri::Runtime>(
|
|||||||
plugins::plugin_storage_delete,
|
plugins::plugin_storage_delete,
|
||||||
plugins::plugin_workspace_list_dir,
|
plugins::plugin_workspace_list_dir,
|
||||||
plugins::plugin_workspace_stat,
|
plugins::plugin_workspace_stat,
|
||||||
|
plugins::plugin_workspace_exec_tool,
|
||||||
plugins::plugin_query_project_structure,
|
plugins::plugin_query_project_structure,
|
||||||
plugins::plugin_config_read_document,
|
plugins::plugin_config_read_document,
|
||||||
plugins::plugin_config_update_document,
|
plugins::plugin_config_update_document,
|
||||||
|
|||||||
@ -17,10 +17,10 @@ use backend::dto::{
|
|||||||
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
|
PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
|
||||||
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
|
PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
|
||||||
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
|
PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
|
||||||
PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
|
PluginWorkspaceExecDto, PluginWorkspaceExecResultDto, PluginWorkspacePathDto,
|
||||||
PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ProjectPluginSettingsDto,
|
PluginWorkspaceStatDto, PluginWorkspaceTextFileDto, PluginWorkspaceWriteBinaryDto,
|
||||||
ProjectPluginSettingsViewDto, ReloadPluginDto, ReviewPluginPackageDto,
|
PluginWorkspaceWriteTextDto, ProjectPluginSettingsDto, ProjectPluginSettingsViewDto,
|
||||||
SetProjectPluginEnabledRequestDto,
|
ReloadPluginDto, ReviewPluginPackageDto, SetProjectPluginEnabledRequestDto,
|
||||||
};
|
};
|
||||||
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
|
||||||
use domain::{PluginId, RelativePath};
|
use domain::{PluginId, RelativePath};
|
||||||
@ -414,6 +414,19 @@ pub async fn plugin_workspace_stat(
|
|||||||
.map_err(ErrorDto::from)
|
.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<PluginWorkspaceExecResultDto, ErrorDto> {
|
||||||
|
state
|
||||||
|
.plugin_workspace_exec
|
||||||
|
.execute(input.into())
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
/// Queries a bounded generic project structure for the public plugin API.
|
/// Queries a bounded generic project structure for the public plugin API.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn plugin_query_project_structure(
|
pub async fn plugin_query_project_structure(
|
||||||
@ -888,6 +901,7 @@ mod tests {
|
|||||||
trust_level: PluginTrustLevel::Full,
|
trust_level: PluginTrustLevel::Full,
|
||||||
capabilities: Vec::new(),
|
capabilities: Vec::new(),
|
||||||
activation_scope: domain::PluginActivationScope::default(),
|
activation_scope: domain::PluginActivationScope::default(),
|
||||||
|
permissions: domain::PluginPermissions::default(),
|
||||||
contributes: PluginContributionSet::default(),
|
contributes: PluginContributionSet::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -196,12 +196,17 @@ pub use plugin::{
|
|||||||
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
|
PluginToolDiagnostic, PluginToolRequirement, PluginToolchainDiagnostic,
|
||||||
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
|
PluginToolchainDiagnosticInput, PluginToolchainDiagnostics, PluginWorkspaceAccess,
|
||||||
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
|
PluginWorkspaceBinaryFile, PluginWorkspaceDirEntry, PluginWorkspaceDirectoryListing,
|
||||||
|
PluginWorkspaceExecInput, PluginWorkspaceExecResult, PluginWorkspaceExecTool,
|
||||||
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
|
PluginWorkspacePathInput, PluginWorkspaceStat, PluginWorkspaceTextFile,
|
||||||
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
|
PluginWorkspaceWriteBinaryInput, PluginWorkspaceWriteTextInput, ProjectConvention,
|
||||||
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
|
ProjectModule, ProjectStructureEntry, ProjectStructureQuery, QueryProjectStructure,
|
||||||
QueryProjectStructureInput, ReconcilePluginMcpServers, ReloadPlugin, ReloadPluginInput,
|
QueryProjectStructureInput, ReconcilePluginMcpServers, ReloadPlugin, ReloadPluginInput,
|
||||||
ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput,
|
ReviewPluginPackage, ReviewPluginPackageInput, SetPluginEnabled, SetPluginEnabledInput,
|
||||||
UninstallPlugin, UninstallPluginInput, UninstallPluginResult,
|
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::{
|
pub use project::{
|
||||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
//! Plugin application use cases.
|
//! 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 std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use domain::ports::EnvironmentReader;
|
use domain::ports::EnvironmentReader;
|
||||||
@ -8,19 +9,22 @@ use domain::ports::{
|
|||||||
BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator,
|
BackgroundTaskStore, Clock, DirEntry, EventBus, FileMetadata, FileSystem, IdGenerator,
|
||||||
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
|
LocalPath, Output, PluginManifestBytes, PluginManifestError, PluginManifestValidator,
|
||||||
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
|
PluginMcpError, PluginMcpSupervisor, PluginPackageStore, PluginRegistryError,
|
||||||
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError, ProcessError,
|
PluginRegistryStore, PluginStorageError, PluginStorageStore, PluginStoreError,
|
||||||
ProcessSpawner, ProjectStore, RemotePath, SpawnSpec,
|
PluginWorkspaceExecAuditSink, ProcessError, ProcessSpawner, ProjectStore, RemotePath,
|
||||||
|
SpawnSpec, WorkspaceToolExecError, WorkspaceToolExecSpec, WorkspaceToolRunner,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
|
AgentId, BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, ContentHash,
|
||||||
DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource,
|
DomainEvent, PluginContributionSet, PluginDescriptor, PluginId, PluginInstallSource,
|
||||||
PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerSpec,
|
PluginLayoutType, PluginLifecycleState, PluginManifest, PluginMcpServerSpec,
|
||||||
PluginRegistryEntry, PluginTrustLevel, Project, ProjectId, ProjectPath,
|
PluginRegistryEntry, PluginTrustLevel, PluginWorkspaceExecAuditEntry,
|
||||||
|
PluginWorkspaceExecAuditOutcome, PluginWorkspaceExecEffect, Project, ProjectId, ProjectPath,
|
||||||
ProjectPluginSettingsStore, RemovalOutcome, StagedPluginPackage, TaskId,
|
ProjectPluginSettingsStore, RemovalOutcome, StagedPluginPackage, TaskId,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::ProjectPluginResolver;
|
||||||
use crate::{AppError, SpawnBackgroundCommand, SpawnBackgroundCommandInput};
|
use crate::{AppError, SpawnBackgroundCommand, SpawnBackgroundCommandInput};
|
||||||
|
|
||||||
/// Contribution counts for admin display.
|
/// 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<String>,
|
||||||
|
/// Project-relative cwd, defaulting to the root.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
/// Host-allowlisted environment overlay.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
|
/// Optional UTF-8 stdin.
|
||||||
|
#[serde(default)]
|
||||||
|
pub stdin: Option<String>,
|
||||||
|
/// Requested deadline, clamped by the host maximum.
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
|
/// Requested retained stdout size, clamped by the host maximum.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_stdout_bytes: Option<usize>,
|
||||||
|
/// Requested retained stderr size, clamped by the host maximum.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_stderr_bytes: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i32>,
|
||||||
|
/// Platform signal when available.
|
||||||
|
pub signal: Option<String>,
|
||||||
|
/// 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<dyn ProjectStore>,
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
plugins: Arc<ProjectPluginResolver>,
|
||||||
|
runner: Arc<dyn WorkspaceToolRunner>,
|
||||||
|
env: Arc<dyn EnvironmentReader>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
audit: Arc<dyn PluginWorkspaceExecAuditSink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginWorkspaceExecTool {
|
||||||
|
/// Builds the use case from replaceable ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
projects: Arc<dyn ProjectStore>,
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
plugins: Arc<ProjectPluginResolver>,
|
||||||
|
runner: Arc<dyn WorkspaceToolRunner>,
|
||||||
|
env: Arc<dyn EnvironmentReader>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
audit: Arc<dyn PluginWorkspaceExecAuditSink>,
|
||||||
|
) -> 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<PluginWorkspaceExecResult, AppError> {
|
||||||
|
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<String, AppError> {
|
||||||
|
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<ProjectPath, AppError> {
|
||||||
|
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<Vec<(String, String)>, 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<u64>,
|
||||||
|
default: u64,
|
||||||
|
max: u64,
|
||||||
|
) -> Result<u64, AppError> {
|
||||||
|
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<usize>,
|
||||||
|
default: usize,
|
||||||
|
max: usize,
|
||||||
|
) -> Result<usize, AppError> {
|
||||||
|
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<PluginWorkspaceExecEffect>,
|
||||||
|
permission_rule_index: Option<usize>,
|
||||||
|
outcome: PluginWorkspaceExecAuditOutcome,
|
||||||
|
exit_code: Option<i32>,
|
||||||
|
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<AgentId, AppError> {
|
fn parse_agent_id(raw: &str) -> Result<AgentId, AppError> {
|
||||||
Uuid::parse_str(raw)
|
Uuid::parse_str(raw)
|
||||||
.map(AgentId::from_uuid)
|
.map(AgentId::from_uuid)
|
||||||
@ -2267,6 +2686,7 @@ impl ListPlugins {
|
|||||||
trust_level: PluginTrustLevel::Full,
|
trust_level: PluginTrustLevel::Full,
|
||||||
capabilities: Vec::new(),
|
capabilities: Vec::new(),
|
||||||
activation_scope: domain::PluginActivationScope::default(),
|
activation_scope: domain::PluginActivationScope::default(),
|
||||||
|
permissions: domain::PluginPermissions::default(),
|
||||||
contributes: PluginContributionSet::default(),
|
contributes: PluginContributionSet::default(),
|
||||||
};
|
};
|
||||||
out.push(admin_from_descriptor(
|
out.push(admin_from_descriptor(
|
||||||
@ -3334,9 +3754,39 @@ struct RawManifest {
|
|||||||
capabilities: Vec<String>,
|
capabilities: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
activation_scope: domain::PluginActivationScope,
|
activation_scope: domain::PluginActivationScope,
|
||||||
|
#[serde(default)]
|
||||||
|
permissions: RawPluginPermissions,
|
||||||
contributes: RawContributes,
|
contributes: RawContributes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RawPluginPermissions {
|
||||||
|
#[serde(default)]
|
||||||
|
workspace_exec: Option<RawWorkspaceExecPermission>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct RawWorkspaceExecPermission {
|
||||||
|
#[serde(default)]
|
||||||
|
allow: Vec<RawWorkspaceExecRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RawWorkspaceExecRule {
|
||||||
|
command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
args: Vec<String>,
|
||||||
|
#[serde(default = "default_workspace_exec_cwd")]
|
||||||
|
cwd: String,
|
||||||
|
effect: domain::PluginWorkspaceExecEffect,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_workspace_exec_cwd() -> String {
|
||||||
|
".".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct RawEngines {
|
struct RawEngines {
|
||||||
@ -3510,6 +3960,7 @@ impl PluginManifestValidator for JsonPluginManifestValidator {
|
|||||||
))),
|
))),
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let permissions = validate_plugin_permissions(raw.permissions)?;
|
||||||
let contributes = validate_contributes(raw.contributes)?;
|
let contributes = validate_contributes(raw.contributes)?;
|
||||||
Ok(PluginManifest {
|
Ok(PluginManifest {
|
||||||
idea_plugin_manifest_version: 1,
|
idea_plugin_manifest_version: 1,
|
||||||
@ -3524,11 +3975,82 @@ impl PluginManifestValidator for JsonPluginManifestValidator {
|
|||||||
trust_level: PluginTrustLevel::Full,
|
trust_level: PluginTrustLevel::Full,
|
||||||
capabilities,
|
capabilities,
|
||||||
activation_scope: raw.activation_scope,
|
activation_scope: raw.activation_scope,
|
||||||
|
permissions,
|
||||||
contributes,
|
contributes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_plugin_permissions(
|
||||||
|
raw: RawPluginPermissions,
|
||||||
|
) -> Result<domain::PluginPermissions, PluginManifestError> {
|
||||||
|
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::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(domain::PluginWorkspaceExecPermission { allow })
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
Ok(domain::PluginPermissions { workspace_exec })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_workspace_exec_rule(
|
||||||
|
index: usize,
|
||||||
|
rule: RawWorkspaceExecRule,
|
||||||
|
) -> Result<domain::PluginWorkspaceExecRule, PluginManifestError> {
|
||||||
|
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<PluginContributionSet, PluginManifestError> {
|
fn validate_contributes(raw: RawContributes) -> Result<PluginContributionSet, PluginManifestError> {
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
let mut insert = |id: &str| {
|
let mut insert = |id: &str| {
|
||||||
@ -3808,6 +4330,13 @@ mod tests {
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"trustLevel": "full",
|
"trustLevel": "full",
|
||||||
"capabilities": ["ui", "mcp", "tooling"],
|
"capabilities": ["ui", "mcp", "tooling"],
|
||||||
|
"permissions": {
|
||||||
|
"workspaceExec": {
|
||||||
|
"allow": [
|
||||||
|
{"command":"tool","args":["status","*"],"cwd":".","effect":"read"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"skills": [{"id":"build-debug","name":"build-debug","description":"Diagnose build failures.","kind":"workflow","path":"skills/build-debug/SKILL.md"}],
|
"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}],
|
"menus": [{"id":"dev.acme.menu","label":"Graph","topLevel":true}],
|
||||||
@ -4187,6 +4716,13 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(m.id.as_str(), "dev.acme.gitgraph");
|
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!(
|
assert_eq!(
|
||||||
m.capabilities,
|
m.capabilities,
|
||||||
vec![
|
vec![
|
||||||
@ -4224,6 +4760,29 @@ mod tests {
|
|||||||
assert_eq!(m.contributes.slash_commands.len(), 1);
|
assert_eq!(m.contributes.slash_commands.len(), 1);
|
||||||
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
|
assert_eq!(m.contributes.slash_commands[0].name, "/graph");
|
||||||
assert!(m.contributes.slash_commands[0].requires_confirmation);
|
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]
|
#[test]
|
||||||
@ -5267,6 +5826,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct FakeWorkspaceToolRunner {
|
||||||
|
output: Mutex<Result<domain::WorkspaceToolExecOutput, WorkspaceToolExecError>>,
|
||||||
|
specs: Mutex<Vec<WorkspaceToolExecSpec>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl WorkspaceToolRunner for FakeWorkspaceToolRunner {
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
spec: WorkspaceToolExecSpec,
|
||||||
|
) -> Result<domain::WorkspaceToolExecOutput, WorkspaceToolExecError> {
|
||||||
|
self.specs.lock().unwrap().push(spec);
|
||||||
|
self.output.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeWorkspaceExecAudit {
|
||||||
|
entries: Mutex<Vec<PluginWorkspaceExecAuditEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginWorkspaceExecAuditSink for FakeWorkspaceExecAudit {
|
||||||
|
fn record(&self, entry: PluginWorkspaceExecAuditEntry) {
|
||||||
|
self.entries.lock().unwrap().push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct FakeEnvironmentReader {
|
struct FakeEnvironmentReader {
|
||||||
values: Mutex<HashMap<String, String>>,
|
values: Mutex<HashMap<String, String>>,
|
||||||
@ -5405,6 +5991,140 @@ mod tests {
|
|||||||
(project_id, store, Arc::new(FakeWorkspaceFs::default()))
|
(project_id, store, Arc::new(FakeWorkspaceFs::default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn workspace_exec_fixture() -> (
|
||||||
|
ProjectId,
|
||||||
|
PluginWorkspaceExecTool,
|
||||||
|
Arc<FakeWorkspaceToolRunner>,
|
||||||
|
Arc<FakeWorkspaceExecAudit>,
|
||||||
|
) {
|
||||||
|
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]
|
#[tokio::test]
|
||||||
async fn plugin_workspace_rejects_paths_outside_project_root() {
|
async fn plugin_workspace_rejects_paths_outside_project_root() {
|
||||||
let (project_id, projects, fs) = workspace_fixture();
|
let (project_id, projects, fs) = workspace_fixture();
|
||||||
|
|||||||
@ -803,6 +803,7 @@ mod tests {
|
|||||||
trust_level: PluginTrustLevel::Full,
|
trust_level: PluginTrustLevel::Full,
|
||||||
capabilities: vec![PluginCapability::Mcp],
|
capabilities: vec![PluginCapability::Mcp],
|
||||||
activation_scope: PluginActivationScope::Project,
|
activation_scope: PluginActivationScope::Project,
|
||||||
|
permissions: domain::PluginPermissions::default(),
|
||||||
contributes: PluginContributionSet {
|
contributes: PluginContributionSet {
|
||||||
mcp_servers: vec![
|
mcp_servers: vec![
|
||||||
PluginMcpServerContribution {
|
PluginMcpServerContribution {
|
||||||
|
|||||||
@ -449,6 +449,59 @@ impl From<PluginWorkspacePathDto> 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<String>,
|
||||||
|
/// Project-relative working directory.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
/// Host-allowlisted environment overlay.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: std::collections::BTreeMap<String, String>,
|
||||||
|
/// Optional UTF-8 stdin.
|
||||||
|
#[serde(default)]
|
||||||
|
pub stdin: Option<String>,
|
||||||
|
/// Requested timeout.
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
|
/// Requested retained stdout size.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_stdout_bytes: Option<usize>,
|
||||||
|
/// Requested retained stderr size.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_stderr_bytes: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginWorkspaceExecDto> 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.
|
/// Plugin workspace text write request DTO.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[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]
|
#[test]
|
||||||
fn dto_plugins_project_structure_query_maps_to_application_input() {
|
fn dto_plugins_project_structure_query_maps_to_application_input() {
|
||||||
let dto = PluginProjectStructureQueryDto {
|
let dto = PluginProjectStructureQueryDto {
|
||||||
|
|||||||
@ -41,16 +41,16 @@ use application::{
|
|||||||
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
||||||
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
||||||
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, ProjectPluginResolver, ProposeContext,
|
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceExecTool,
|
||||||
PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext,
|
ProjectPluginResolver, ProposeContext, PurgeManagedTemp, QueryProjectStructure,
|
||||||
ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions,
|
ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue,
|
||||||
ReadMemory, ReadMemoryIndex, ReadOpenProjectRestoreState, ReadProjectContext,
|
ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
|
||||||
ReadProjectSettings, ReadSkill, ReadTemplate, ReadUserSettings, RecallMemory, ReconcileLayouts,
|
ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectSettings, ReadSkill, ReadTemplate,
|
||||||
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers,
|
ReadUserSettings, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput,
|
||||||
RecordProjectClosedForRestore, RecordProjectOpenedForRestore, RecordTurn, RecordTurnProvider,
|
ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
|
||||||
ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, ReloadPluginInput,
|
RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin,
|
||||||
RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint, ReorderSprints,
|
ReloadPluginInput, RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint,
|
||||||
ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
|
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
|
||||||
ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, RestartCellAgentRuntime,
|
ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, RestartCellAgentRuntime,
|
||||||
RestoreOpenProjects, RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask,
|
RestoreOpenProjects, RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask,
|
||||||
RetryOpenProjectRestore, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
|
RetryOpenProjectRestore, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
|
||||||
@ -102,20 +102,21 @@ use infrastructure::{
|
|||||||
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
||||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
||||||
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
DiagnosticPluginWorkspaceExecAudit, EmbeddedCompatibilityMatrix, EmbedderEnvProbe,
|
||||||
FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsChatDraftStore,
|
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore,
|
||||||
FsConversationLog, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsDeviceSessionStore,
|
||||||
FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore,
|
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||||
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
|
FsIssueStore, FsLiveStateStore, FsManagedTempStore, FsMcpToolPermissionStore, FsMemoryStore,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
FsModelServerRegistry, FsOpenProjectRestoreStore, FsOrchestratorWatcher, FsPermissionStore,
|
||||||
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
|
FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore,
|
||||||
FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore,
|
FsProjectPluginSettingsStore, FsProjectSettingsStore, FsProjectStore, FsProviderSessionStore,
|
||||||
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
|
FsScheduledProjectTaskStore, FsSecretStore, FsSkillStore, FsSprintStore,
|
||||||
FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer,
|
FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore,
|
||||||
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue,
|
FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||||
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter,
|
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
||||||
LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess,
|
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||||
LocalProcessSpawner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall,
|
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
|
||||||
|
LocalWorkspaceToolRunner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||||
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||||
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||||
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||||
@ -2319,6 +2320,8 @@ pub struct BackendCore {
|
|||||||
pub reconcile_plugin_mcp_servers: Arc<ReconcilePluginMcpServers>,
|
pub reconcile_plugin_mcp_servers: Arc<ReconcilePluginMcpServers>,
|
||||||
/// Public plugin workspace/file access facade.
|
/// Public plugin workspace/file access facade.
|
||||||
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
|
pub plugin_workspace_access: Arc<PluginWorkspaceAccess>,
|
||||||
|
/// Permission-gated short workspace tool execution facade.
|
||||||
|
pub plugin_workspace_exec: Arc<PluginWorkspaceExecTool>,
|
||||||
/// Public plugin structured config document facade.
|
/// Public plugin structured config document facade.
|
||||||
pub plugin_config_documents: Arc<PluginConfigDocuments>,
|
pub plugin_config_documents: Arc<PluginConfigDocuments>,
|
||||||
/// Public plugin-owned storage facade.
|
/// Public plugin-owned storage facade.
|
||||||
@ -3855,6 +3858,17 @@ impl BackendCore {
|
|||||||
PluginWorkspaceAccess::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
PluginWorkspaceAccess::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
||||||
.with_events(Arc::clone(&events_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<dyn Clock>,
|
||||||
|
Arc::new(DiagnosticPluginWorkspaceExecAudit),
|
||||||
|
));
|
||||||
let plugin_config_documents = Arc::new(
|
let plugin_config_documents = Arc::new(
|
||||||
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
PluginConfigDocuments::new(Arc::clone(&store_port), Arc::clone(&fs_port))
|
||||||
.with_events(Arc::clone(&events_port)),
|
.with_events(Arc::clone(&events_port)),
|
||||||
@ -4565,6 +4579,7 @@ impl BackendCore {
|
|||||||
list_plugin_runtime_contributions,
|
list_plugin_runtime_contributions,
|
||||||
reconcile_plugin_mcp_servers,
|
reconcile_plugin_mcp_servers,
|
||||||
plugin_workspace_access,
|
plugin_workspace_access,
|
||||||
|
plugin_workspace_exec,
|
||||||
plugin_config_documents,
|
plugin_config_documents,
|
||||||
plugin_storage_access,
|
plugin_storage_access,
|
||||||
query_project_structure,
|
query_project_structure,
|
||||||
|
|||||||
@ -249,11 +249,13 @@ pub use plugin::{
|
|||||||
PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType,
|
PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType,
|
||||||
PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId,
|
PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId,
|
||||||
PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMcpToolMetadata,
|
PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMcpToolMetadata,
|
||||||
PluginMenuItemContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
|
PluginMenuItemContribution, PluginPackageRef, PluginPermissions, PluginRegistry,
|
||||||
PluginSkillContribution, PluginSkillId, PluginSkillRef, PluginSlashCommandContribution,
|
PluginRegistryEntry, PluginSkillContribution, PluginSkillId, PluginSkillRef,
|
||||||
PluginToolRef, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion,
|
PluginSlashCommandContribution, PluginToolRef, PluginTopLevelMenuContribution,
|
||||||
ProjectPluginActivation, ProjectPluginSettings, RelativePath, RemovalOutcome,
|
PluginTrustLevel, PluginVersion, PluginWorkspaceExecAuditEntry,
|
||||||
StagedPluginPackage,
|
PluginWorkspaceExecAuditOutcome, PluginWorkspaceExecEffect, PluginWorkspaceExecPermission,
|
||||||
|
PluginWorkspaceExecRule, ProjectPluginActivation, ProjectPluginSettings, RelativePath,
|
||||||
|
RemovalOutcome, StagedPluginPackage,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use sandbox::{
|
pub use sandbox::{
|
||||||
@ -290,11 +292,12 @@ pub use ports::{
|
|||||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
||||||
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
|
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
|
||||||
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
|
PluginStorageStore, PluginStoreError, PluginWorkspaceExecAuditSink, PreparedContext,
|
||||||
ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore,
|
ProcessError, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore,
|
||||||
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost,
|
||||||
RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask,
|
RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask,
|
||||||
ScheduledTaskStoreError, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
ScheduledTaskStoreError, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
||||||
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
|
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
|
||||||
TemplateStore, UserSettingsStore, WindowStateStore,
|
TemplateStore, UserSettingsStore, WindowStateStore, WorkspaceToolExecError,
|
||||||
|
WorkspaceToolExecOutput, WorkspaceToolExecSpec, WorkspaceToolRunner,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -289,6 +289,132 @@ pub enum PluginCapability {
|
|||||||
Tooling,
|
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<String>,
|
||||||
|
/// 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<PluginWorkspaceExecRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<PluginWorkspaceExecPermission>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<PluginWorkspaceExecEffect>,
|
||||||
|
/// Index of the manifest allow rule used, when allowed.
|
||||||
|
pub permission_rule_index: Option<usize>,
|
||||||
|
/// Terminal audit outcome.
|
||||||
|
pub outcome: PluginWorkspaceExecAuditOutcome,
|
||||||
|
/// Exit code when available.
|
||||||
|
pub exit_code: Option<i32>,
|
||||||
|
/// 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.
|
/// Manifest-declared runtime activation scope.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@ -677,6 +803,9 @@ pub struct PluginManifest {
|
|||||||
/// Activation scope. Missing in older manifests means app-level activation.
|
/// Activation scope. Missing in older manifests means app-level activation.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub activation_scope: PluginActivationScope,
|
pub activation_scope: PluginActivationScope,
|
||||||
|
/// Security-sensitive host permissions requested by the plugin.
|
||||||
|
#[serde(default)]
|
||||||
|
pub permissions: PluginPermissions,
|
||||||
/// Contributions.
|
/// Contributions.
|
||||||
pub contributes: PluginContributionSet,
|
pub contributes: PluginContributionSet,
|
||||||
}
|
}
|
||||||
@ -982,4 +1111,19 @@ mod tests {
|
|||||||
serde_json::json!("project")
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1760,6 +1760,74 @@ pub trait ProcessSpawner: Send + Sync {
|
|||||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<i32>,
|
||||||
|
/// Platform signal name/number when available.
|
||||||
|
pub signal: Option<String>,
|
||||||
|
/// Whether the host deadline killed the process.
|
||||||
|
pub timed_out: bool,
|
||||||
|
/// Retained stdout prefix.
|
||||||
|
pub stdout: Vec<u8>,
|
||||||
|
/// Retained stderr prefix.
|
||||||
|
pub stderr: Vec<u8>,
|
||||||
|
/// 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<WorkspaceToolExecOutput, WorkspaceToolExecError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Reads host environment variables through an injected adapter.
|
||||||
pub trait EnvironmentReader: Send + Sync {
|
pub trait EnvironmentReader: Send + Sync {
|
||||||
/// Returns one environment variable value, if present.
|
/// Returns one environment variable value, if present.
|
||||||
|
|||||||
@ -48,6 +48,7 @@ pub mod session;
|
|||||||
pub mod sprints;
|
pub mod sprints;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod timeparse;
|
pub mod timeparse;
|
||||||
|
pub mod workspace_tool;
|
||||||
|
|
||||||
pub use assistant::{FsAssistantContextStore, TicketAssistantEnvironmentPreparer};
|
pub use assistant::{FsAssistantContextStore, TicketAssistantEnvironmentPreparer};
|
||||||
pub use background_task::{
|
pub use background_task::{
|
||||||
@ -126,3 +127,4 @@ pub use store::{
|
|||||||
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
pub use workspace_tool::{DiagnosticPluginWorkspaceExecAudit, LocalWorkspaceToolRunner};
|
||||||
|
|||||||
266
crates/infrastructure/src/workspace_tool.rs
Normal file
266
crates/infrastructure/src/workspace_tool.rs
Normal file
@ -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<dyn SandboxEnforcer>,
|
||||||
|
permits: Arc<tokio::sync::Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalWorkspaceToolRunner {
|
||||||
|
/// Builds the adapter with the selected platform sandbox enforcer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(sandbox: Arc<dyn SandboxEnforcer>) -> 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<WorkspaceToolExecOutput, WorkspaceToolExecError> {
|
||||||
|
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<WorkspaceToolExecOutput, WorkspaceToolExecError> {
|
||||||
|
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<u8>, 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<String> {
|
||||||
|
use std::os::unix::process::ExitStatusExt;
|
||||||
|
status.signal().map(|signal| signal.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn exit_signal(_status: &std::process::ExitStatus) -> Option<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -59,6 +59,8 @@ import type {
|
|||||||
PluginToolchainDiagnosticRequest,
|
PluginToolchainDiagnosticRequest,
|
||||||
PluginToolchainGateway,
|
PluginToolchainGateway,
|
||||||
PluginWorkspaceGateway,
|
PluginWorkspaceGateway,
|
||||||
|
PluginWorkspaceExecInput,
|
||||||
|
PluginWorkspaceExecResult,
|
||||||
PluginWorkspacePathInput,
|
PluginWorkspacePathInput,
|
||||||
PluginWorkspaceWriteBinaryInput,
|
PluginWorkspaceWriteBinaryInput,
|
||||||
PluginWorkspaceWriteTextInput,
|
PluginWorkspaceWriteTextInput,
|
||||||
@ -221,6 +223,9 @@ export class WebPluginGateway implements PluginGateway {
|
|||||||
|
|
||||||
/** Web stub: public plugin workspace services are only meaningful where plugins run. */
|
/** Web stub: public plugin workspace services are only meaningful where plugins run. */
|
||||||
export class WebPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
export class WebPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||||
|
async execTool(_input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult> {
|
||||||
|
return unsupportedOnWeb("Plugin workspace tool execution");
|
||||||
|
}
|
||||||
async readText(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
|
async readText(_input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
|
||||||
return unsupportedOnWeb("Plugin workspace access");
|
return unsupportedOnWeb("Plugin workspace access");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -160,6 +160,8 @@ import type {
|
|||||||
PluginToolchainDiagnosticRequest,
|
PluginToolchainDiagnosticRequest,
|
||||||
PluginToolchainGateway,
|
PluginToolchainGateway,
|
||||||
PluginWorkspaceGateway,
|
PluginWorkspaceGateway,
|
||||||
|
PluginWorkspaceExecInput,
|
||||||
|
PluginWorkspaceExecResult,
|
||||||
PluginWorkspacePathInput,
|
PluginWorkspacePathInput,
|
||||||
PluginWorkspaceWriteBinaryInput,
|
PluginWorkspaceWriteBinaryInput,
|
||||||
PluginWorkspaceWriteTextInput,
|
PluginWorkspaceWriteTextInput,
|
||||||
@ -4553,6 +4555,10 @@ function basename(path: string): string {
|
|||||||
export class MockPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
export class MockPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
||||||
private readonly files = new Map<string, Map<string, Uint8Array>>();
|
private readonly files = new Map<string, Map<string, Uint8Array>>();
|
||||||
|
|
||||||
|
async execTool(_input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult> {
|
||||||
|
throw new Error("Plugin workspace tool execution is unavailable in the mock adapter");
|
||||||
|
}
|
||||||
|
|
||||||
private bucket(projectId: string): Map<string, Uint8Array> {
|
private bucket(projectId: string): Map<string, Uint8Array> {
|
||||||
let files = this.files.get(projectId);
|
let files = this.files.get(projectId);
|
||||||
if (!files) {
|
if (!files) {
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
PluginProjectStructureQuery,
|
PluginProjectStructureQuery,
|
||||||
PluginWorkspaceGateway,
|
PluginWorkspaceGateway,
|
||||||
|
PluginWorkspaceExecInput,
|
||||||
|
PluginWorkspaceExecResult,
|
||||||
PluginWorkspacePathInput,
|
PluginWorkspacePathInput,
|
||||||
PluginWorkspaceWriteBinaryInput,
|
PluginWorkspaceWriteBinaryInput,
|
||||||
PluginWorkspaceWriteTextInput,
|
PluginWorkspaceWriteTextInput,
|
||||||
@ -74,4 +76,8 @@ export class TauriPluginWorkspaceGateway implements PluginWorkspaceGateway {
|
|||||||
queryProjectStructure(input: PluginProjectStructureQuery): Promise<PluginProjectStructure> {
|
queryProjectStructure(input: PluginProjectStructureQuery): Promise<PluginProjectStructure> {
|
||||||
return invoke<PluginProjectStructure>("plugin_query_project_structure", { input });
|
return invoke<PluginProjectStructure>("plugin_query_project_structure", { input });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
execTool(input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult> {
|
||||||
|
return invoke<PluginWorkspaceExecResult>("plugin_workspace_exec_tool", { input });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1564,6 +1564,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
it("cancels only the current request and keeps the structured session usable", async () => {
|
it("cancels only the current request and keeps the structured session usable", async () => {
|
||||||
let resolveCancel: (result: CancelAgentChatResult) => void = () => {};
|
let resolveCancel: (result: CancelAgentChatResult) => void = () => {};
|
||||||
let firstTurnChunk: (chunk: ReplyChunk) => void = () => {};
|
let firstTurnChunk: (chunk: ReplyChunk) => void = () => {};
|
||||||
|
let reattachedChunk: (chunk: ReplyChunk) => void = () => {};
|
||||||
let rejectFirstTurn: (reason: unknown) => void = () => {};
|
let rejectFirstTurn: (reason: unknown) => void = () => {};
|
||||||
const system = new MockSystemGateway();
|
const system = new MockSystemGateway();
|
||||||
const workState = new MockWorkStateGateway();
|
const workState = new MockWorkStateGateway();
|
||||||
@ -1581,10 +1582,12 @@ describe("CustomAgentChatView", () => {
|
|||||||
});
|
});
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
reattachAgentChat: vi.fn(
|
||||||
sessionId,
|
async (sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||||||
scrollback: [],
|
reattachedChunk = onChunk;
|
||||||
})),
|
return { sessionId, scrollback: [] };
|
||||||
|
},
|
||||||
|
),
|
||||||
sendAgentChat: vi
|
sendAgentChat: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementationOnce((_sessionId: string, _prompt: string, onChunk) => {
|
.mockImplementationOnce((_sessionId: string, _prompt: string, onChunk) => {
|
||||||
@ -1594,6 +1597,41 @@ describe("CustomAgentChatView", () => {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
|
.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" });
|
onChunk({ kind: "final", content: "Next answer" });
|
||||||
}),
|
}),
|
||||||
stopLiveAgent: vi.fn(),
|
stopLiveAgent: vi.fn(),
|
||||||
@ -1729,7 +1767,11 @@ describe("CustomAgentChatView", () => {
|
|||||||
"next turn",
|
"next turn",
|
||||||
expect.any(Function),
|
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(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)(
|
it.each(["alreadyCancelled", "alreadyCompleted"] as const)(
|
||||||
|
|||||||
@ -1503,10 +1503,17 @@ export function CustomAgentChatView({
|
|||||||
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
|
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
|
||||||
activeInteractionIdRef.current = null;
|
activeInteractionIdRef.current = null;
|
||||||
}
|
}
|
||||||
|
stopTerminalOverrideRef.current = true;
|
||||||
|
setStopTerminalOverride(true);
|
||||||
|
setRequesterDelegation({
|
||||||
|
currentAgentBusy: false,
|
||||||
|
delegationTargetName: null,
|
||||||
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
void refreshRequesterDelegation();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId, updateChatViewState],
|
[projectId, refreshRequesterDelegation, updateChatViewState],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -2315,7 +2322,6 @@ export function CustomAgentChatView({
|
|||||||
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
||||||
const displayText = text || "Pièce jointe";
|
const displayText = text || "Pièce jointe";
|
||||||
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
|
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
|
||||||
ignoreUnscopedChunksAfterStopRef.current = false;
|
|
||||||
stopTerminalOverrideRef.current = false;
|
stopTerminalOverrideRef.current = false;
|
||||||
setStopTerminalOverride(false);
|
setStopTerminalOverride(false);
|
||||||
activeInteractionIdRef.current = interactionId;
|
activeInteractionIdRef.current = interactionId;
|
||||||
|
|||||||
@ -25,6 +25,7 @@ function renderTabs(props: Partial<React.ComponentProps<typeof ProjectTabs>> = {
|
|||||||
activeTabId={props.activeTabId ?? "a"}
|
activeTabId={props.activeTabId ?? "a"}
|
||||||
onSelect={props.onSelect ?? vi.fn()}
|
onSelect={props.onSelect ?? vi.fn()}
|
||||||
onClose={props.onClose ?? vi.fn()}
|
onClose={props.onClose ?? vi.fn()}
|
||||||
|
onReorder={props.onReorder}
|
||||||
projectsPanelOpen={props.projectsPanelOpen ?? false}
|
projectsPanelOpen={props.projectsPanelOpen ?? false}
|
||||||
onOpenProjectsPanel={onOpenProjectsPanel}
|
onOpenProjectsPanel={onOpenProjectsPanel}
|
||||||
activityByProjectId={props.activityByProjectId}
|
activityByProjectId={props.activityByProjectId}
|
||||||
@ -94,3 +95,44 @@ describe("ProjectTabs project activity dots (#159)", () => {
|
|||||||
expect(screen.getByTitle(/beta.*échange en cours/i)).toBeTruthy();
|
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<string, string>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export interface ProjectTabsProps {
|
|||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onClose: (id: string) => void;
|
onClose: (id: string) => void;
|
||||||
|
onReorder?: (orderedIds: string[]) => void;
|
||||||
/** Whether the Projects panel is currently open (in any placement). */
|
/** Whether the Projects panel is currently open (in any placement). */
|
||||||
projectsPanelOpen: boolean;
|
projectsPanelOpen: boolean;
|
||||||
/** Open the Projects panel (floating) — create/switch project. */
|
/** Open the Projects panel (floating) — create/switch project. */
|
||||||
@ -36,6 +37,7 @@ export function ProjectTabs({
|
|||||||
activeTabId,
|
activeTabId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
|
onReorder,
|
||||||
projectsPanelOpen,
|
projectsPanelOpen,
|
||||||
onOpenProjectsPanel,
|
onOpenProjectsPanel,
|
||||||
activityByProjectId = {},
|
activityByProjectId = {},
|
||||||
@ -72,6 +74,7 @@ export function ProjectTabs({
|
|||||||
value={activeTabId}
|
value={activeTabId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
onReorder={onReorder}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Add-project affordance (#46): always a bordered `secondary` button so
|
{/* Add-project affordance (#46): always a bordered `secondary` button so
|
||||||
|
|||||||
@ -1071,6 +1071,7 @@ export function ProjectsView() {
|
|||||||
activeTabId={vm.activeTabId}
|
activeTabId={vm.activeTabId}
|
||||||
onSelect={(id) => vm.activateTab(id)}
|
onSelect={(id) => vm.activateTab(id)}
|
||||||
onClose={(id) => void vm.closeTab(id)}
|
onClose={(id) => void vm.closeTab(id)}
|
||||||
|
onReorder={(orderedIds) => void vm.reorderTabs(orderedIds)}
|
||||||
activityByProjectId={projectActivity}
|
activityByProjectId={projectActivity}
|
||||||
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
|
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
|
||||||
onOpenProjectsPanel={() => {
|
onOpenProjectsPanel={() => {
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
* real `DIProvider`. Covers create → list/tab, open → tab, close → remove,
|
* real `DIProvider`. Covers create → list/tab, open → tab, close → remove,
|
||||||
* duplicate root (INVALID) and unknown id (NOT_FOUND) error handling.
|
* duplicate root (INVALID) and unknown id (NOT_FOUND) error handling.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
@ -76,6 +76,10 @@ function tablist() {
|
|||||||
return screen.getByRole("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,
|
* Opens a panel floating via the single « Panneaux » menu (#26): click the menu,
|
||||||
* click the panel entry (opens its placement submenu), then pick « Flottant ».
|
* click the panel entry (opens its placement submenu), then pick « Flottant ».
|
||||||
@ -165,6 +169,56 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
expect(screen.queryByRole("status")).toBeNull();
|
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 () => {
|
it("shows one restore warning, details its cause, and retries at the retained position", async () => {
|
||||||
const project = new MockProjectGateway();
|
const project = new MockProjectGateway();
|
||||||
const alpha = await project.createProject("alpha", "/p/a");
|
const alpha = await project.createProject("alpha", "/p/a");
|
||||||
|
|||||||
@ -40,6 +40,8 @@ export interface ProjectsViewModel {
|
|||||||
openProject: (projectId: string) => Promise<void>;
|
openProject: (projectId: string) => Promise<void>;
|
||||||
/** Closes a tab (does not delete the project from the registry). */
|
/** Closes a tab (does not delete the project from the registry). */
|
||||||
closeTab: (projectId: string) => Promise<void>;
|
closeTab: (projectId: string) => Promise<void>;
|
||||||
|
/** Reorders open tabs optimistically and persists their restore order. */
|
||||||
|
reorderTabs: (orderedProjectIds: string[]) => Promise<void>;
|
||||||
/** Removes a project from IdeA's registry without deleting its files. */
|
/** Removes a project from IdeA's registry without deleting its files. */
|
||||||
removeProject: (projectId: string) => Promise<void>;
|
removeProject: (projectId: string) => Promise<void>;
|
||||||
/** Retries one failed restore, or every failure when omitted. */
|
/** Retries one failed restore, or every failure when omitted. */
|
||||||
@ -68,6 +70,9 @@ export function useProjects(): ProjectsViewModel {
|
|||||||
const [restoreFailures, setRestoreFailures] = useState<OpenProjectRestoreFailure[]>([]);
|
const [restoreFailures, setRestoreFailures] = useState<OpenProjectRestoreFailure[]>([]);
|
||||||
const [restoreNotificationVisible, setRestoreNotificationVisible] = useState(false);
|
const [restoreNotificationVisible, setRestoreNotificationVisible] = useState(false);
|
||||||
const initializationStarted = useRef(false);
|
const initializationStarted = useRef(false);
|
||||||
|
const openTabsRef = useRef<Project[]>([]);
|
||||||
|
const reorderInFlight = useRef(false);
|
||||||
|
openTabsRef.current = openTabs;
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -174,6 +179,43 @@ export function useProjects(): ProjectsViewModel {
|
|||||||
[project],
|
[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(
|
const reconcileRemovedProject = useCallback(
|
||||||
(projectId: string, nextProjects: Project[]) => {
|
(projectId: string, nextProjects: Project[]) => {
|
||||||
setProjects(nextProjects);
|
setProjects(nextProjects);
|
||||||
@ -290,6 +332,7 @@ export function useProjects(): ProjectsViewModel {
|
|||||||
createProject,
|
createProject,
|
||||||
openProject,
|
openProject,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
reorderTabs,
|
||||||
removeProject,
|
removeProject,
|
||||||
retryRestore,
|
retryRestore,
|
||||||
removeFromRestore,
|
removeFromRestore,
|
||||||
|
|||||||
@ -64,6 +64,8 @@ export {
|
|||||||
type WorkspaceBinaryFile,
|
type WorkspaceBinaryFile,
|
||||||
type WorkspaceDirEntry,
|
type WorkspaceDirEntry,
|
||||||
type WorkspaceDirectoryListing,
|
type WorkspaceDirectoryListing,
|
||||||
|
type WorkspaceExecToolRequest,
|
||||||
|
type WorkspaceExecToolResult,
|
||||||
type WorkspaceResolvedPath,
|
type WorkspaceResolvedPath,
|
||||||
type WorkspaceStat,
|
type WorkspaceStat,
|
||||||
type WorkspaceStructureQuery,
|
type WorkspaceStructureQuery,
|
||||||
|
|||||||
@ -268,6 +268,7 @@ describe("loadPlugins", () => {
|
|||||||
"workspace",
|
"workspace",
|
||||||
]);
|
]);
|
||||||
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
|
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
|
||||||
|
"execTool",
|
||||||
"getCurrentProject",
|
"getCurrentProject",
|
||||||
"getProjectRoot",
|
"getProjectRoot",
|
||||||
"listDirectory",
|
"listDirectory",
|
||||||
|
|||||||
@ -125,6 +125,16 @@ function gateways(overrides: {
|
|||||||
...overrides.window,
|
...overrides.window,
|
||||||
};
|
};
|
||||||
const pluginWorkspace: PluginWorkspaceGateway = {
|
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" })),
|
readText: vi.fn(async ({ path }) => ({ path, content: "file text" })),
|
||||||
readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })),
|
readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })),
|
||||||
writeText: vi.fn(),
|
writeText: vi.fn(),
|
||||||
@ -332,6 +342,21 @@ describe("createPluginServices", () => {
|
|||||||
expect(g.project.updateProjectContext).toHaveBeenCalledWith("project-1", "next context");
|
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 () => {
|
it("maps background tasks through the work-state gateway and wraps output attachment", async () => {
|
||||||
const detach = vi.fn();
|
const detach = vi.fn();
|
||||||
const onData = vi.fn();
|
const onData = vi.fn();
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import type {
|
|||||||
PluginConfigGateway,
|
PluginConfigGateway,
|
||||||
PluginEventGateway,
|
PluginEventGateway,
|
||||||
PluginProjectStructureQuery,
|
PluginProjectStructureQuery,
|
||||||
|
PluginWorkspaceExecInput as HostPluginWorkspaceExecInput,
|
||||||
PluginTaskGateway,
|
PluginTaskGateway,
|
||||||
PluginToolchainGateway,
|
PluginToolchainGateway,
|
||||||
PluginWorkspaceGateway,
|
PluginWorkspaceGateway,
|
||||||
@ -52,6 +53,31 @@ export interface WorkspaceService {
|
|||||||
stat(path: string, projectId?: string): Promise<WorkspaceStat>;
|
stat(path: string, projectId?: string): Promise<WorkspaceStat>;
|
||||||
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
|
watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise<WorkspaceWatch>;
|
||||||
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>;
|
queryStructure(query?: WorkspaceStructureQuery): Promise<ProjectStructure>;
|
||||||
|
/** Runs one manifest-allowlisted binary directly, without a visible task. */
|
||||||
|
execTool(request: WorkspaceExecToolRequest): Promise<WorkspaceExecToolResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceExecToolRequest {
|
||||||
|
projectId?: string;
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
cwd?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
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 {
|
export interface WorkspaceResolvedPath {
|
||||||
@ -651,6 +677,33 @@ export function createPluginServices(
|
|||||||
};
|
};
|
||||||
return gateways.pluginWorkspace.queryProjectStructure(input);
|
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<BackgroundTaskStatus[]> {
|
async function listTasks(projectId?: string): Promise<BackgroundTaskStatus[]> {
|
||||||
|
|||||||
@ -1746,6 +1746,30 @@ export interface PluginProjectStructureQuery {
|
|||||||
maxEntries?: number;
|
maxEntries?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginWorkspaceExecInput {
|
||||||
|
pluginId: string;
|
||||||
|
projectId: string;
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
cwd?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
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 {
|
export interface PluginWorkspaceGateway {
|
||||||
readText(input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile>;
|
readText(input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile>;
|
||||||
readBinary(input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile>;
|
readBinary(input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile>;
|
||||||
@ -1754,6 +1778,7 @@ export interface PluginWorkspaceGateway {
|
|||||||
listDir(input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing>;
|
listDir(input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing>;
|
||||||
stat(input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat>;
|
stat(input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat>;
|
||||||
queryProjectStructure(input: PluginProjectStructureQuery): Promise<PluginProjectStructure>;
|
queryProjectStructure(input: PluginProjectStructureQuery): Promise<PluginProjectStructure>;
|
||||||
|
execTool(input: PluginWorkspaceExecInput): Promise<PluginWorkspaceExecResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginRunCommandInput {
|
export interface PluginRunCommandInput {
|
||||||
|
|||||||
@ -7,7 +7,8 @@
|
|||||||
|
|
||||||
import { cn } from "../lib/cn";
|
import { cn } from "../lib/cn";
|
||||||
import { IconButton } from "./IconButton";
|
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";
|
import { useI18n } from "@/i18n";
|
||||||
|
|
||||||
interface ThemedTabStyle extends CSSProperties {
|
interface ThemedTabStyle extends CSSProperties {
|
||||||
@ -37,17 +38,53 @@ export interface TabsProps {
|
|||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
/** When provided, each tab shows a close (×) control. */
|
/** When provided, each tab shows a close (×) control. */
|
||||||
onClose?: (id: string) => void;
|
onClose?: (id: string) => void;
|
||||||
|
/** When provided, tabs can be reordered with drag-and-drop or Alt+Arrow. */
|
||||||
|
onReorder?: (orderedIds: string[]) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A themed tab strip with selection and optional per-tab close. */
|
/** 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 { tr } = useI18n();
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(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<HTMLDivElement>, 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<HTMLButtonElement>, 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
className={cn("flex flex-wrap items-stretch gap-1", className)}
|
className={cn("flex flex-wrap items-stretch gap-1", className)}
|
||||||
>
|
>
|
||||||
|
<span aria-live="polite" className="sr-only">{announcement}</span>
|
||||||
{items.map((tab) => {
|
{items.map((tab) => {
|
||||||
const active = tab.id === value;
|
const active = tab.id === value;
|
||||||
const themedStyle: ThemedTabStyle | undefined = tab.themeColor
|
const themedStyle: ThemedTabStyle | undefined = tab.themeColor
|
||||||
@ -62,9 +99,25 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
|
draggable={Boolean(onReorder)}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
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}
|
style={themedStyle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
|
"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
|
active
|
||||||
? cn(
|
? cn(
|
||||||
"border-border-strong",
|
"border-border-strong",
|
||||||
@ -83,6 +136,8 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
onClick={() => onSelect(tab.id)}
|
onClick={() => onSelect(tab.id)}
|
||||||
|
onKeyDown={(event) => handleKeyDown(event, tab.id)}
|
||||||
|
aria-keyshortcuts={onReorder ? "Alt+ArrowLeft Alt+ArrowRight" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-2 py-1 text-sm focus-visible:outline-none",
|
"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",
|
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"
|
size="sm"
|
||||||
aria-label={tr(`fermer ${tab.label}`, `close ${tab.label}`)}
|
aria-label={tr(`fermer ${tab.label}`, `close ${tab.label}`)}
|
||||||
onClick={() => onClose(tab.id)}
|
onClick={() => onClose(tab.id)}
|
||||||
|
draggable={false}
|
||||||
|
onDragStart={(event) => event.stopPropagation()}
|
||||||
className="opacity-60 group-hover:opacity-100"
|
className="opacity-60 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
Reference in New Issue
Block a user