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:
@ -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,
|
||||
};
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user