дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
Rework the plugin system per docs/plugin_plan.md (P0–P8): - Extension-point registry (ide-extension) so new contribution types register without touching the kernel; languages, run detectors, providers, views and UI contributions go through it. - Per-plugin capability consent with a risk-graded ledger; process capabilities are pinned to resolved binary paths and hashes. - Async, actor-per-plugin Wasm runtime with lazy activation, cancellation, events, long-running tasks and idle reclamation. - Plugin host moved into ide-host, shared by local and remote projects. - Distribution: install/package tooling (openidea-plugin bin) and a static market index. - WIT frozen as openidea:[email protected]; Rust SDK with mock host. - Ten official validation plugins under plugins/official.
312 рядки
14 KiB
Rust
312 рядки
14 KiB
Rust
//! Plan P6 remote acceptance: code plugins of a remote project run on the
|
|
//! server (docs/plugin_plan.md 3). The desktop's plugin host syncs its live
|
|
//! code plugins over the remote protocol; Checkstyle then checks the file on
|
|
//! the server's disk when it is saved, and secrets come from the desktop.
|
|
|
|
#![cfg(unix)]
|
|
|
|
use ide_core::IdeEvent;
|
|
use ide_host::plugins::remote_host::{
|
|
EVENT_NOTIFICATION, PackageSyncReport, PluginEventMessage, RemotePlugins, SECRET_REQUEST, SYNC_PACKAGES,
|
|
SecretRequest,
|
|
};
|
|
use ide_host::plugins::{Consent, PluginEvent, PluginHost, PluginStatus, WorkbenchEvent};
|
|
use ide_host::rpc::{self, Dispatcher, EventMessage, Invoke};
|
|
use ide_host::{Host, HostConfig};
|
|
use ide_jsonrpc::{Handler, Peer};
|
|
use ide_plugin::Limits;
|
|
use parking_lot::Mutex;
|
|
use serde_json::{Value, json};
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
#[derive(Default)]
|
|
struct Client {
|
|
events: Mutex<Vec<EventMessage>>,
|
|
plugin_events: Mutex<Vec<PluginEventMessage>>,
|
|
secret_requests: Mutex<Vec<SecretRequest>>,
|
|
}
|
|
|
|
impl Handler for Client {
|
|
fn on_notification(&self, method: &str, params: Value) {
|
|
match method {
|
|
"event" => self.events.lock().push(serde_json::from_value(params).unwrap()),
|
|
EVENT_NOTIFICATION => self.plugin_events.lock().push(serde_json::from_value(params).unwrap()),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
|
if method == SECRET_REQUEST {
|
|
let r: SecretRequest = serde_json::from_value(params).unwrap();
|
|
let value = (r.name == "api-key").then(|| "sk-desktop".to_owned());
|
|
self.secret_requests.lock().push(r);
|
|
return Ok(serde_json::to_value(value).unwrap());
|
|
}
|
|
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
|
|
}
|
|
}
|
|
|
|
async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result<Value, ide_core::IdeError> {
|
|
let call = Invoke { window: "main".into(), cmd: cmd.into(), args };
|
|
peer.request::<_, Value>("invoke", call, None).await.map_err(rpc::error_from_reply)
|
|
}
|
|
|
|
async fn sync(peer: &Peer, desktop: &PluginHost, files: &[&str]) -> PackageSyncReport {
|
|
let files: serde_json::Map<String, Value> = files
|
|
.iter()
|
|
.map(|id| ((*id).to_owned(), serde_json::to_value(desktop.remote_package_files(id).unwrap()).unwrap()))
|
|
.collect();
|
|
let args = json!({ "packages": desktop.remote_packages(), "files": files });
|
|
serde_json::from_value(invoke(peer, SYNC_PACKAGES, args).await.unwrap()).unwrap()
|
|
}
|
|
|
|
fn official(name: &str) -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official").join(name)
|
|
}
|
|
|
|
/// A `checkstyle` that reports a tab on line 2 of the file it is given.
|
|
fn fake_checkstyle(bin: &Path) {
|
|
std::fs::create_dir_all(bin).unwrap();
|
|
std::fs::write(
|
|
bin.join("checkstyle"),
|
|
"#!/bin/sh\nfor a; do file=\"$a\"; done\ngrep -q '\t' \"$file\" || { echo '<checkstyle/>'; exit 0; }\n\
|
|
echo '<?xml version=\"1.0\"?><checkstyle><file name=\"'\"$file\"'\">'\n\
|
|
echo '<error line=\"2\" column=\"1\" severity=\"warning\" message=\"Line contains a tab character.\" source=\"com.puppycrawl.tools.checkstyle.checks.whitespace.FileTabCharacterCheck\"/>'\n\
|
|
echo '</file></checkstyle>'\nexit 1\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(bin.join("checkstyle"), std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
}
|
|
|
|
async fn until(what: &str, secs: u64, mut done: impl FnMut() -> bool) {
|
|
for _ in 0..secs * 50 {
|
|
if done() {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
panic!("timed out waiting for {what}");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let bin = tmp.path().join("bin");
|
|
fake_checkstyle(&bin);
|
|
let path_var = {
|
|
let bin = bin.clone();
|
|
Arc::new(move || Some(bin.clone().into_os_string()))
|
|
};
|
|
|
|
// The desktop: Checkstyle and AI Completion installed and granted.
|
|
let desktop =
|
|
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
|
.unwrap();
|
|
desktop.set_program_path(path_var.clone());
|
|
for name in ["checkstyle", "ai-completion"] {
|
|
let info = desktop.install_from_dir(&official(name), &Consent::all()).unwrap();
|
|
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
|
}
|
|
|
|
// The server, with the project on "its" disk.
|
|
// Canonical, as the UI reports paths (macOS: /var → /private/var).
|
|
let project = tmp.path().join("remote/demo");
|
|
std::fs::create_dir_all(project.join("src")).unwrap();
|
|
let project = std::fs::canonicalize(project).unwrap();
|
|
let base = tmp.path().join("server");
|
|
let peer_slot = Arc::new(OnceLock::new());
|
|
let sink_peer = peer_slot.clone();
|
|
let host = Arc::new(Host::new(HostConfig {
|
|
config_dir: base.join("config"),
|
|
data_dir: base.join("data"),
|
|
local_data_dir: base.join("data"),
|
|
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
|
syntax: Arc::default(),
|
|
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
|
|
owner: |w| w,
|
|
runtime: tokio::runtime::Handle::current(),
|
|
project_environment: false,
|
|
}));
|
|
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
|
|
plugins.host().set_program_path(path_var);
|
|
let mut dispatcher = Dispatcher::standard();
|
|
plugins.register(&mut dispatcher);
|
|
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
|
let (sr, sw) = tokio::io::split(server_io);
|
|
tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
|
|
let (cr, cw) = tokio::io::split(client_io);
|
|
let client = Arc::new(Client::default());
|
|
let peer = Peer::connect(cr, cw, client.clone());
|
|
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
|
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
|
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
|
|
|
|
// Digests first; then the files of what the server lacks; then nothing to do.
|
|
let first = sync(&peer, &desktop, &[]).await;
|
|
assert_eq!(first.missing, ["openidea.ai-completion", "openidea.checkstyle"], "{first:?}");
|
|
let second = sync(&peer, &desktop, &["openidea.ai-completion", "openidea.checkstyle"]).await;
|
|
assert_eq!(second.installed.len(), 2, "{second:?}");
|
|
assert!(second.failed.is_empty(), "{second:?}");
|
|
assert_eq!(sync(&peer, &desktop, &[]).await, PackageSyncReport::default());
|
|
let remote = plugins.host().info_of("openidea.checkstyle").unwrap();
|
|
assert_eq!(remote.status, PluginStatus::Inactive, "{:?}", remote.error);
|
|
assert!(remote.capabilities.iter().any(|c| c.id == "process:checkstyle" && c.granted));
|
|
|
|
// The remote window's UI routes the run-time commands to the server.
|
|
let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap();
|
|
assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}");
|
|
|
|
// Saving a file that exists only on the server: Checkstyle reads that file.
|
|
let java = project.join("src/A.java");
|
|
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
|
|
let saved = WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned());
|
|
invoke(&peer, "plugin_post_events", json!({ "events": [saved] })).await.unwrap();
|
|
let published = || {
|
|
client.events.lock().iter().find_map(|m| match &m.event {
|
|
IdeEvent::Diagnostics { path, source, items } if source == "plugin:openidea.checkstyle" => {
|
|
Some((path.0.clone(), items.clone()))
|
|
}
|
|
_ => None,
|
|
})
|
|
};
|
|
until("remote checkstyle diagnostics", 20, || published().is_some()).await;
|
|
let (path, items) = published().unwrap();
|
|
assert_eq!(path, java);
|
|
assert_eq!((items.len(), items[0].range.start.line), (1, 1));
|
|
|
|
// Secrets stay on the desktop: the server asks for them when used.
|
|
let secrets = plugins.host().secrets("openidea.ai-completion").unwrap();
|
|
assert_eq!(secrets, [("api-key".to_owned(), true)]);
|
|
assert_eq!(client.secret_requests.lock()[0].plugin, "openidea.ai-completion");
|
|
|
|
// A command of a remote plugin, with this window as its client.
|
|
invoke(
|
|
&peer,
|
|
"plugin_run_command",
|
|
json!({ "pluginId": "openidea.ai-completion", "commandId": "toggle", "arg": null, "editor": null }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
until("the command's notification", 5, || {
|
|
client
|
|
.plugin_events
|
|
.lock()
|
|
.iter()
|
|
.any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::Notification { .. }))
|
|
})
|
|
.await;
|
|
|
|
// Disabled on the desktop: gone from the server.
|
|
desktop.set_enabled("openidea.checkstyle", false).unwrap();
|
|
let removed = sync(&peer, &desktop, &[]).await;
|
|
assert_eq!(removed.removed, ["openidea.checkstyle"]);
|
|
assert!(plugins.host().info_of("openidea.checkstyle").is_err());
|
|
plugins.host().shutdown();
|
|
}
|
|
|
|
/// Plan P7 remote acceptance: CSV Editor and Kubernetes in a remote project.
|
|
/// The table edits the document on the server; the cluster is the server's
|
|
/// `kubectl`'s, and its log terminal opens in the remote window.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn remote_projects_get_plugin_views_and_custom_editors() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let bin = tmp.path().join("bin");
|
|
std::fs::create_dir_all(&bin).unwrap();
|
|
std::fs::write(
|
|
bin.join("kubectl"),
|
|
"#!/bin/sh\ncase \"$*\" in\n\
|
|
*'get namespaces'*) echo '{\"items\":[{\"metadata\":{\"name\":\"remote-ns\"}}]}' ;;\n\
|
|
*) echo '{\"items\":[]}' ;;\nesac\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(bin.join("kubectl"), std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
let path_var = {
|
|
let bin = bin.clone();
|
|
Arc::new(move || Some(bin.clone().into_os_string()))
|
|
};
|
|
let desktop =
|
|
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
|
.unwrap();
|
|
desktop.set_program_path(path_var.clone());
|
|
for name in ["csv-editor", "kubernetes"] {
|
|
let info = desktop.install_from_dir(&official(name), &Consent::all()).unwrap();
|
|
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
|
}
|
|
|
|
let project = tmp.path().join("remote/demo");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
let project = std::fs::canonicalize(project).unwrap();
|
|
std::fs::write(project.join("people.csv"), "name,city\nAda,London\n").unwrap();
|
|
let base = tmp.path().join("server");
|
|
let peer_slot = Arc::new(OnceLock::new());
|
|
let sink_peer = peer_slot.clone();
|
|
let host = Arc::new(Host::new(HostConfig {
|
|
config_dir: base.join("config"),
|
|
data_dir: base.join("data"),
|
|
local_data_dir: base.join("data"),
|
|
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
|
syntax: Arc::default(),
|
|
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
|
|
owner: |w| w,
|
|
runtime: tokio::runtime::Handle::current(),
|
|
project_environment: false,
|
|
}));
|
|
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
|
|
plugins.host().set_program_path(path_var);
|
|
let mut dispatcher = Dispatcher::standard();
|
|
plugins.register(&mut dispatcher);
|
|
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
|
let (sr, sw) = tokio::io::split(server_io);
|
|
tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
|
|
let (cr, cw) = tokio::io::split(client_io);
|
|
let client = Arc::new(Client::default());
|
|
let peer = Peer::connect(cr, cw, client.clone());
|
|
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
|
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
|
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
|
|
let report = sync(&peer, &desktop, &["openidea.csv-editor", "openidea.kubernetes"]).await;
|
|
assert_eq!(report.installed.len(), 2, "{report:?}");
|
|
|
|
// CSV Editor: the document is the server's.
|
|
let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap();
|
|
let doc = opened["id"].as_u64().expect("doc id");
|
|
let args = |extra: Value| {
|
|
let mut a = json!({ "pluginId": "openidea.csv-editor", "view": "table", "doc": doc });
|
|
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
|
a
|
|
};
|
|
let view =
|
|
invoke(&peer, "plugin_view_render", args(json!({ "viewport": { "firstRow": 0, "rows": 50 } }))).await.unwrap();
|
|
assert_eq!(view["body"]["kind"], "table");
|
|
assert_eq!(view["body"]["value"]["rows"], json!([["Ada", "London"]]));
|
|
let cell = json!({ "event": { "kind": "cell", "row": 0, "column": 1, "value": "Oslo" } });
|
|
invoke(&peer, "plugin_view_event", args(cell)).await.unwrap();
|
|
let snap = invoke(&peer, "doc_snapshot", json!({ "doc": doc })).await.unwrap();
|
|
assert_eq!(snap["text"], "name,city\nAda,Oslo\n");
|
|
|
|
// Kubernetes: the server's kubectl; the terminal opens in this window.
|
|
let k8s =
|
|
|event: Value| json!({ "pluginId": "openidea.kubernetes", "view": "cluster", "doc": null, "event": event });
|
|
let view = invoke(
|
|
&peer,
|
|
"plugin_view_render",
|
|
json!({ "pluginId": "openidea.kubernetes", "view": "cluster", "doc": null }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(view["body"]["value"][0]["label"], "remote-ns", "{view}");
|
|
invoke(&peer, "plugin_view_event", k8s(json!({ "kind": "select", "key": "remote-ns/pods/api-0" }))).await.unwrap();
|
|
invoke(&peer, "plugin_view_event", k8s(json!({ "kind": "button", "id": "logs" }))).await.unwrap();
|
|
until("the log terminal", 5, || {
|
|
client.plugin_events.lock().iter().any(|m| {
|
|
m.window.as_deref() == Some("main")
|
|
&& matches!(&m.event, PluginEvent::OpenTerminal { program, .. } if program.0.starts_with(bin.canonicalize().unwrap()))
|
|
})
|
|
})
|
|
.await;
|
|
plugins.host().shutdown();
|
|
}
|