feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139

État d'intégration confiné à la branche batch. Les tickets #119 (skills →
capacités agent découvrables), #122 (override permissions par défaut), #131
(effort par agent/presets) et #132 (outil MCP d'édition du contexte projet)
sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance
plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de
câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs,
backend/dto.rs), inséparable sans staging interactif (indisponible ici).

Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée.
NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:06:23 +02:00
parent 22c6bd803d
commit 171c6c923c
59 changed files with 3654 additions and 291 deletions

View File

@ -2,7 +2,7 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::io::Read;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex, MutexGuard};
@ -11,7 +11,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use domain::ports::{
LocalPath, PluginManifestBytes, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
PluginRegistryError, PluginRegistryStore, PluginStoreError,
PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore,
PluginStoreError,
};
use domain::{
ContentHash, PluginBundleUrl, PluginId, PluginInstallSource, PluginMcpServerSpec,
@ -23,6 +24,14 @@ use tokio::process::Child;
const REGISTRY_FILE: &str = "registry.json";
const MANIFEST_FILE: &str = "idea-plugin.json";
const STORAGE_ENTRIES_DIR: &str = "entries";
#[derive(Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginStorageEntry {
key: String,
value: serde_json::Value,
}
/// Filesystem package store under app-data `plugins/`.
#[derive(Debug, Clone)]
@ -151,18 +160,7 @@ impl PluginPackageStore for FsPluginPackageStore {
archive: &LocalPath,
) -> Result<StagedPluginPackage, PluginStoreError> {
let stage = self.stage_root()?;
let status = std::process::Command::new("unzip")
.arg("-q")
.arg(archive.as_str())
.arg("-d")
.arg(&stage)
.status()
.map_err(|e| PluginStoreError::Io(format!("failed to run unzip: {e}")))?;
if !status.success() {
return Err(PluginStoreError::Format(format!(
"unzip exited with status {status}"
)));
}
extract_archive_confined(Path::new(archive.as_str()), &stage)?;
ensure_manifest(&stage)?;
let content_hash = hash_dir(&stage)?;
Ok(StagedPluginPackage {
@ -278,6 +276,43 @@ fn ensure_manifest(root: &Path) -> Result<(), PluginStoreError> {
}
}
fn extract_archive_confined(archive: &Path, stage: &Path) -> Result<(), PluginStoreError> {
let file = fs::File::open(archive).map_err(|e| PluginStoreError::Io(e.to_string()))?;
let mut archive = zip::ZipArchive::new(file)
.map_err(|e| PluginStoreError::Format(format!("invalid zip archive: {e}")))?;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|e| PluginStoreError::Format(format!("invalid zip entry: {e}")))?;
let entry_name = entry.name().to_owned();
let enclosed = entry.enclosed_name().ok_or_else(|| {
PluginStoreError::Invalid(format!("archive entry escapes plugin root: {entry_name}"))
})?;
if entry
.unix_mode()
.is_some_and(|mode| mode & 0o170000 == 0o120000)
{
return Err(PluginStoreError::Invalid(format!(
"archive entry symlinks are not allowed: {entry_name}"
)));
}
let destination = stage.join(&enclosed);
if entry.is_dir() {
fs::create_dir_all(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?;
continue;
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|e| PluginStoreError::Io(e.to_string()))?;
}
let mut out =
fs::File::create(&destination).map_err(|e| PluginStoreError::Io(e.to_string()))?;
io::copy(&mut entry, &mut out).map_err(|e| PluginStoreError::Io(e.to_string()))?;
out.flush()
.map_err(|e| PluginStoreError::Io(e.to_string()))?;
}
Ok(())
}
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
fs::create_dir_all(target).map_err(|e| PluginStoreError::Io(e.to_string()))?;
for entry in fs::read_dir(source).map_err(|e| PluginStoreError::Io(e.to_string()))? {
@ -290,6 +325,16 @@ fn copy_dir_all(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
copy_dir_all(&entry.path(), &dest)?;
} else if ty.is_file() {
fs::copy(entry.path(), dest).map_err(|e| PluginStoreError::Io(e.to_string()))?;
} else if ty.is_symlink() {
return Err(PluginStoreError::Invalid(format!(
"plugin source contains symlink: {}",
entry.path().display()
)));
} else {
return Err(PluginStoreError::Invalid(format!(
"plugin source contains unsupported entry: {}",
entry.path().display()
)));
}
}
Ok(())
@ -329,6 +374,16 @@ fn collect_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), PluginStor
collect_files(&entry.path(), files)?;
} else if ty.is_file() {
files.push(entry.path());
} else if ty.is_symlink() {
return Err(PluginStoreError::Invalid(format!(
"plugin package contains symlink: {}",
entry.path().display()
)));
} else {
return Err(PluginStoreError::Invalid(format!(
"plugin package contains unsupported entry: {}",
entry.path().display()
)));
}
}
Ok(())
@ -354,6 +409,97 @@ impl FsPluginRegistryStore {
}
}
/// Filesystem plugin-owned storage under app-data `plugins/data/<pluginId>/`.
#[derive(Debug, Clone)]
pub struct FsPluginStorageStore {
root: PathBuf,
}
impl FsPluginStorageStore {
/// Builds the store.
#[must_use]
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
Self {
root: app_data_dir.into().join("plugins").join("data"),
}
}
fn plugin_dir(&self, plugin_id: &PluginId) -> PathBuf {
self.root.join(plugin_id.as_str())
}
fn entry_path(&self, plugin_id: &PluginId, key: &str) -> PathBuf {
let digest = Sha256::digest(key.as_bytes());
self.plugin_dir(plugin_id)
.join(STORAGE_ENTRIES_DIR)
.join(format!("{}.json", hex::encode(digest)))
}
}
#[async_trait]
impl PluginStorageStore for FsPluginStorageStore {
async fn get(
&self,
plugin_id: &PluginId,
key: &str,
) -> Result<Option<serde_json::Value>, PluginStorageError> {
let path = self.entry_path(plugin_id, key);
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path).map_err(|e| PluginStorageError::Io(e.to_string()))?;
let entry: PluginStorageEntry = serde_json::from_slice(&bytes)
.map_err(|e| PluginStorageError::Serialization(e.to_string()))?;
if entry.key == key {
Ok(Some(entry.value))
} else {
Err(PluginStorageError::Invalid(
"plugin storage key hash collision".to_owned(),
))
}
}
async fn set(
&self,
plugin_id: &PluginId,
key: &str,
value: serde_json::Value,
) -> Result<(), PluginStorageError> {
let path = self.entry_path(plugin_id, key);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| PluginStorageError::Io(e.to_string()))?;
}
let entry = PluginStorageEntry {
key: key.to_owned(),
value,
};
let bytes = serde_json::to_vec_pretty(&entry)
.map_err(|e| PluginStorageError::Serialization(e.to_string()))?;
fs::write(path, bytes).map_err(|e| PluginStorageError::Io(e.to_string()))
}
async fn delete(&self, plugin_id: &PluginId, key: &str) -> Result<bool, PluginStorageError> {
let path = self.entry_path(plugin_id, key);
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(PluginStorageError::Io(e.to_string())),
}
}
async fn purge_plugin(
&self,
plugin_id: &PluginId,
) -> Result<RemovalOutcome, PluginStorageError> {
let dir = self.plugin_dir(plugin_id);
match fs::remove_dir_all(dir) {
Ok(()) => Ok(RemovalOutcome::Removed),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RemovalOutcome::NotFound),
Err(e) => Err(PluginStorageError::Io(e.to_string())),
}
}
}
#[async_trait]
impl PluginRegistryStore for FsPluginRegistryStore {
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
@ -574,6 +720,99 @@ mod tests {
.unwrap();
}
struct ZipEntrySpec<'a> {
name: &'a str,
contents: &'a [u8],
unix_mode: Option<u32>,
}
fn write_u16(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn write_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn crc32(bytes: &[u8]) -> u32 {
let mut crc = 0xffff_ffffu32;
for &byte in bytes {
crc ^= u32::from(byte);
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xedb8_8320 & mask);
}
}
!crc
}
fn write_zip(path: &Path, entries: &[ZipEntrySpec<'_>]) {
let mut file = fs::File::create(path).unwrap();
let mut central = Vec::new();
let mut offset = 0u32;
for entry in entries {
let name = entry.name.as_bytes();
let data = entry.contents;
let crc = crc32(data);
let local_size = 30u32 + name.len() as u32 + data.len() as u32;
let mut local = Vec::new();
write_u32(&mut local, 0x0403_4b50);
write_u16(&mut local, 20);
write_u16(&mut local, 0);
write_u16(&mut local, 0);
write_u16(&mut local, 0);
write_u16(&mut local, 0);
write_u32(&mut local, crc);
write_u32(&mut local, data.len() as u32);
write_u32(&mut local, data.len() as u32);
write_u16(&mut local, name.len() as u16);
write_u16(&mut local, 0);
local.extend_from_slice(name);
local.extend_from_slice(data);
file.write_all(&local).unwrap();
let mut header = Vec::new();
let version_made_by = if entry.unix_mode.is_some() {
(3u16 << 8) | 20
} else {
20
};
let external_attributes = entry.unix_mode.unwrap_or(0) << 16;
write_u32(&mut header, 0x0201_4b50);
write_u16(&mut header, version_made_by);
write_u16(&mut header, 20);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u32(&mut header, crc);
write_u32(&mut header, data.len() as u32);
write_u32(&mut header, data.len() as u32);
write_u16(&mut header, name.len() as u16);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u16(&mut header, 0);
write_u32(&mut header, external_attributes);
write_u32(&mut header, offset);
header.extend_from_slice(name);
central.extend_from_slice(&header);
offset += local_size;
}
file.write_all(&central).unwrap();
let mut eocd = Vec::new();
write_u32(&mut eocd, 0x0605_4b50);
write_u16(&mut eocd, 0);
write_u16(&mut eocd, 0);
write_u16(&mut eocd, entries.len() as u16);
write_u16(&mut eocd, entries.len() as u16);
write_u32(&mut eocd, central.len() as u32);
write_u32(&mut eocd, offset);
write_u16(&mut eocd, 0);
file.write_all(&eocd).unwrap();
}
#[derive(Default)]
struct RecordingBridge {
started: Mutex<Vec<PluginMcpServerSpec>>,
@ -671,35 +910,23 @@ mod tests {
#[tokio::test]
async fn extracts_archive_without_path_escape() {
if std::process::Command::new("zip")
.arg("-h")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_err()
|| std::process::Command::new("unzip")
.arg("-h")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_err()
{
return;
}
let app = temp_dir("archive-app");
let source = temp_dir("archive-source");
write_plugin(&source, "bundle");
let archive_path = app.join("plugin.ideaplug");
{
let status = std::process::Command::new("zip")
.arg("-qr")
.arg(&archive_path)
.arg(".")
.current_dir(&source)
.status()
.unwrap();
assert!(status.success());
}
write_zip(
&archive_path,
&[
ZipEntrySpec {
name: "idea-plugin.json",
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
unix_mode: Some(0o100644),
},
ZipEntrySpec {
name: "dist/index.js",
contents: b"bundle",
unix_mode: Some(0o100644),
},
],
);
let store = FsPluginPackageStore::new(app.join("data"));
let staged = store
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
@ -707,9 +934,142 @@ mod tests {
.unwrap();
assert!(PathBuf::from(staged.root).join(MANIFEST_FILE).exists());
let _ = fs::remove_dir_all(app);
}
#[tokio::test]
async fn install_from_directory_rejects_source_symlink() {
let app = temp_dir("symlink-app");
let source = temp_dir("symlink-source");
write_plugin(&source, "bundle");
let outside = app.join("outside.txt");
fs::write(&outside, "secret").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, source.join("dist/escape.txt")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&outside, source.join("dist/escape.txt")).unwrap();
let store = FsPluginPackageStore::new(&app);
let err = store
.install_from_directory(&LocalPath::new(source.to_string_lossy()))
.await
.unwrap_err();
assert!(matches!(err, PluginStoreError::Invalid(_)));
assert!(err.to_string().contains("symlink"));
assert!(!app.join("plugins/installed/dev.acme.test").exists());
let _ = fs::remove_dir_all(app);
let _ = fs::remove_dir_all(source);
}
#[tokio::test]
async fn install_from_archive_rejects_parent_traversal_without_writing_outside_stage() {
let app = temp_dir("traversal-app");
let archive_path = app.join("plugin.ideaplug");
write_zip(
&archive_path,
&[
ZipEntrySpec {
name: "../../../../outside.txt",
contents: b"pwned",
unix_mode: Some(0o100644),
},
ZipEntrySpec {
name: "idea-plugin.json",
contents: br#"{"ideaPluginManifestVersion":1}"#,
unix_mode: Some(0o100644),
},
],
);
let store = FsPluginPackageStore::new(&app);
let err = store
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
.await
.unwrap_err();
assert!(matches!(err, PluginStoreError::Invalid(_)));
assert!(err.to_string().contains("escapes plugin root"));
assert!(!app.join("outside.txt").exists());
let _ = fs::remove_dir_all(app);
}
#[tokio::test]
async fn install_from_archive_rejects_symlink_entries() {
let app = temp_dir("archive-symlink-app");
let archive_path = app.join("plugin.ideaplug");
write_zip(
&archive_path,
&[
ZipEntrySpec {
name: "idea-plugin.json",
contents: br#"{"ideaPluginManifestVersion":1,"id":"dev.acme.test","displayName":"Test","version":"1.0.0","main":"dist/index.js","trustLevel":"full","contributes":{}}"#,
unix_mode: Some(0o100644),
},
ZipEntrySpec {
name: "dist/link.js",
contents: b"/tmp/outside.js",
unix_mode: Some(0o120777),
},
],
);
let store = FsPluginPackageStore::new(&app);
let err = store
.install_from_archive(&LocalPath::new(archive_path.to_string_lossy()))
.await
.unwrap_err();
assert!(matches!(err, PluginStoreError::Invalid(_)));
assert!(err.to_string().contains("symlinks are not allowed"));
assert!(!app.join("plugins/installed/dev.acme.test").exists());
let _ = fs::remove_dir_all(app);
}
#[tokio::test]
async fn plugin_storage_store_round_trips_deletes_and_purges_plugin_data() {
let app = temp_dir("storage-app");
let store = FsPluginStorageStore::new(&app);
let plugin_id = PluginId::new("dev.acme.test").unwrap();
assert_eq!(
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
None
);
store
.set(
&plugin_id,
"helloPlugin.launches",
serde_json::json!({"count": 1}),
)
.await
.unwrap();
assert_eq!(
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
Some(serde_json::json!({"count": 1}))
);
assert!(app.join("plugins/data/dev.acme.test/entries").is_dir());
assert!(store
.delete(&plugin_id, "helloPlugin.launches")
.await
.unwrap());
assert_eq!(
store.get(&plugin_id, "helloPlugin.launches").await.unwrap(),
None
);
store
.set(&plugin_id, "helloPlugin.enabled", serde_json::json!(true))
.await
.unwrap();
assert_eq!(
store.purge_plugin(&plugin_id).await.unwrap(),
RemovalOutcome::Removed
);
assert!(!app.join("plugins/data/dev.acme.test").exists());
let _ = fs::remove_dir_all(app);
}
#[tokio::test]
async fn supervisor_delegates_stdio_servers_to_external_mcp_bridge() {
let bridge = Arc::new(RecordingBridge::default());