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:
2026-09-13 12:23:44 +02:00
parent 2b0d82efa3
commit 561c50f6ed
28 changed files with 1761 additions and 51 deletions

View File

@ -249,11 +249,13 @@ pub use plugin::{
PluginError, PluginId, PluginInstallSource, PluginLayoutContribution, PluginLayoutType,
PluginLifecycleState, PluginManifest, PluginMcpServerContribution, PluginMcpServerId,
PluginMcpServerSpec, PluginMcpStatus, PluginMcpStatusSet, PluginMcpToolMetadata,
PluginMenuItemContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
PluginSkillContribution, PluginSkillId, PluginSkillRef, PluginSlashCommandContribution,
PluginToolRef, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion,
ProjectPluginActivation, ProjectPluginSettings, RelativePath, RemovalOutcome,
StagedPluginPackage,
PluginMenuItemContribution, PluginPackageRef, PluginPermissions, PluginRegistry,
PluginRegistryEntry, PluginSkillContribution, PluginSkillId, PluginSkillRef,
PluginSlashCommandContribution, PluginToolRef, PluginTopLevelMenuContribution,
PluginTrustLevel, PluginVersion, PluginWorkspaceExecAuditEntry,
PluginWorkspaceExecAuditOutcome, PluginWorkspaceExecEffect, PluginWorkspaceExecPermission,
PluginWorkspaceExecRule, ProjectPluginActivation, ProjectPluginSettings, RelativePath,
RemovalOutcome, StagedPluginPackage,
};
pub use sandbox::{
@ -290,11 +292,12 @@ pub use ports::{
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore,
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask,
PluginStorageStore, PluginStoreError, PluginWorkspaceExecAuditSink, PreparedContext,
ProcessError, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore,
ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost,
RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask,
ScheduledTaskStoreError, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
TemplateStore, UserSettingsStore, WindowStateStore,
TemplateStore, UserSettingsStore, WindowStateStore, WorkspaceToolExecError,
WorkspaceToolExecOutput, WorkspaceToolExecSpec, WorkspaceToolRunner,
};

View File

@ -289,6 +289,132 @@ pub enum PluginCapability {
Tooling,
}
/// Declared impact of one generic workspace tool invocation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginWorkspaceExecEffect {
/// The command is expected to inspect state only.
Read,
/// The command may modify project-owned files.
Write,
/// The command may use the network.
Network,
/// The command may perform destructive project-owned mutations.
Destructive,
}
/// One manifest-declared allow rule for short, hidden workspace tool execution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspaceExecRule {
/// Bare executable name resolved by the host without a shell.
pub command: String,
/// Exact argv tokens. `*` matches one token, or the remaining suffix when last.
#[serde(default)]
pub args: Vec<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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@ -677,6 +803,9 @@ pub struct PluginManifest {
/// Activation scope. Missing in older manifests means app-level activation.
#[serde(default)]
pub activation_scope: PluginActivationScope,
/// Security-sensitive host permissions requested by the plugin.
#[serde(default)]
pub permissions: PluginPermissions,
/// Contributions.
pub contributes: PluginContributionSet,
}
@ -982,4 +1111,19 @@ mod tests {
serde_json::json!("project")
);
}
#[test]
fn workspace_exec_rule_matches_exact_tokens_and_trailing_suffix_wildcard() {
let rule = PluginWorkspaceExecRule {
command: "tool".to_owned(),
args: vec!["status".to_owned(), "*".to_owned()],
cwd: ".".to_owned(),
effect: PluginWorkspaceExecEffect::Read,
};
assert!(rule.matches("tool", &["status".to_owned()], "."));
assert!(rule.matches("tool", &["status".to_owned(), "--short".to_owned()], "."));
assert!(!rule.matches("tool", &["commit".to_owned()], "."));
assert!(!rule.matches("other", &["status".to_owned()], "."));
assert!(!rule.matches("tool", &["status".to_owned()], "nested"));
}
}

View File

@ -1760,6 +1760,74 @@ pub trait ProcessSpawner: Send + Sync {
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.
pub trait EnvironmentReader: Send + Sync {
/// Returns one environment variable value, if present.