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:
@ -48,6 +48,7 @@ pub mod session;
|
||||
pub mod sprints;
|
||||
pub mod store;
|
||||
pub mod timeparse;
|
||||
pub mod workspace_tool;
|
||||
|
||||
pub use assistant::{FsAssistantContextStore, TicketAssistantEnvironmentPreparer};
|
||||
pub use background_task::{
|
||||
@ -126,3 +127,4 @@ pub use store::{
|
||||
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
pub use workspace_tool::{DiagnosticPluginWorkspaceExecAudit, LocalWorkspaceToolRunner};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user