//! Code plugins of a remote project run on the //! server. 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>, plugin_events: Mutex>, secret_requests: Mutex>, desktop: Option>, } 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 { 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()); } if let Some(desktop) = &self.desktop { use ide_host::plugins::remote_host::{AUTH_REQUEST, SECRET_WRITE}; if method == AUTH_REQUEST { return Ok( serde_json::to_value(desktop.remote_auth("main", serde_json::from_value(params).unwrap())).unwrap() ); } if method == SECRET_WRITE { return Ok( serde_json::to_value(desktop.remote_secret_write(serde_json::from_value(params).unwrap())).unwrap() ); } } Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned())) } } async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result { 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 = 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 test fixture plugin (`crates/ide-plugin/tests/fixtures/`). fn fixture(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures").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 ''; exit 0; }\n\ echo ''\n\ echo ''\n\ echo ''\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}"); invoke(&peer, "plugin_set_ui_locale", json!({ "tag": "fr" })).await.unwrap(); assert_eq!(plugins.host().ui_locale(), "fr"); // 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: retained but stopped on the server. desktop.set_enabled("openidea.checkstyle", false).unwrap(); let removed = sync(&peer, &desktop, &[]).await; assert!(removed.removed.is_empty()); assert_eq!(plugins.host().info_of("openidea.checkstyle").unwrap().status, PluginStatus::Disabled); plugins.host().shutdown(); } /// CSV Editor and a tool window with a terminal (the `jobs` fixture) in a /// remote project. The table edits the document on the server; the jobs are /// the server's `jobs` program's, and the 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("jobs"), "#!/bin/sh\n[ \"$1\" = list ] && echo remote-job\n").unwrap(); std::fs::set_permissions(bin.join("jobs"), 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 dir in [official("csv-editor"), fixture("jobs"), fixture("settings-pages")] { let info = desktop.install_from_dir(&dir, &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", "jobs", "settings-pages"]).await; assert_eq!(report.installed.len(), 3, "{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"); // Jobs: the server's program; the terminal opens in this window. let jobs = |event: Value| json!({ "pluginId": "jobs", "view": "jobs", "doc": null, "event": event }); let view = invoke(&peer, "plugin_view_render", json!({ "pluginId": "jobs", "view": "jobs", "doc": null })).await.unwrap(); assert_eq!(view["body"]["value"][0]["label"], "remote-job", "{view}"); invoke(&peer, "plugin_view_event", jobs(json!({ "kind": "select", "key": "remote-job" }))).await.unwrap(); invoke(&peer, "plugin_view_event", jobs(json!({ "kind": "button", "id": "follow" }))).await.unwrap(); until("the job's 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; invoke( &peer, "plugin_run_command", json!({ "pluginId": "jobs", "commandId": "context", "arg": "mode=true", "editor": null }), ) .await .unwrap(); until("the context key's event", 5, || { client.plugin_events.lock().iter().any(|m| { m.window.as_deref() == Some("main") && matches!(&m.event, PluginEvent::ContextChanged { key, value, .. } if key == "mode" && value.as_deref() == Some("true")) }) }) .await; let keys = invoke(&peer, "plugin_context_keys", json!({})).await.unwrap(); assert_eq!(keys, json!([{ "pluginId": "jobs", "key": "mode", "value": "true" }]), "{keys}"); let selected = invoke( &peer, "plugin_run_command", json!({ "pluginId": "jobs", "commandId": "selection", "arg": null, "editor": null, "paths": ["/srv/src/a.txt"] }), ) .await .unwrap(); assert_eq!(selected, "/srv/src/a.txt"); for (scope, name) in [("global", "Global"), ("project", "Project")] { let values = json!({ "settings-pages": { "profiles": [{ "name": name, "provider": "local", "baseUrl": "http://localhost", "model": "test", "enabled": true, "temperature": 0.7, "limit": 100 }] } }); invoke(&peer, "settings_set", json!({ "scope": scope, "key": "plugins", "value": values.to_string() })) .await .unwrap(); } for (scope, name) in [(None, "Project"), (Some("global"), "Global"), (Some("project"), "Project"), (None, "Project")] { let mut args = json!({ "pluginId": "settings-pages", "commandId": "test", "arg": null, "editor": null }); if let Some(scope) = scope { args["settingsScope"] = json!(scope); } let result = invoke(&peer, "plugin_run_command", args).await.unwrap(); let result: Value = serde_json::from_str(result.as_str().unwrap()).unwrap(); assert_eq!(result["profiles"][0]["name"], name); assert_eq!(result["hasProject"], true); } plugins.host().shutdown(); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let tmp = tempfile::tempdir().unwrap(); let desktop = Arc::new( PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()) .unwrap(), ); desktop.install_from_dir(&fixture("api14"), &Consent::all()).unwrap(); let project = tmp.path().join("project"); std::fs::create_dir(&project).unwrap(); let project = project.canonicalize().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.into()))), owner: |w| w, runtime: tokio::runtime::Handle::current(), project_environment: false, })); let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap(); 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, dispatcher, peer_slot, sr, sw)); let (cr, cw) = tokio::io::split(client_io); let client = Arc::new(Client { desktop: Some(desktop.clone()), ..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(); sync(&peer, &desktop, &[]).await; let report = sync(&peer, &desktop, &["api14"]).await; assert!(report.failed.is_empty(), "{report:?}"); let run = |command: &str, arg: Option<&str>| json!({ "pluginId": "api14", "commandId": command, "arg": arg, "editor": null }); let remote_file = project.join("remote.txt"); std::fs::write(&remote_file, "remote text").unwrap(); invoke(&peer, "mcp_set_open_editors", json!({"paths": [remote_file]})).await.unwrap(); invoke(&peer, "mcp_set_focused_editor", json!({"editor": {"path": remote_file, "selection": {"start": {"line": 0, "col": 0}, "end": {"line": 0, "col": 0}}, "selectedText": ""}, "focused": true})).await.unwrap(); let editors = invoke(&peer, "plugin_run_command", run("open-editors", None)).await.unwrap(); assert!(editors.as_str().unwrap().contains("remote.txt")); assert!(editors.as_str().unwrap().contains("active: true")); let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(); assert_eq!(problems.as_str(), Some("[]")); invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap(); invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap(); let sets = invoke(&peer, "plugin_decoration_sets", json!({})).await.unwrap(); assert_eq!(sets["api14"][0]["root"], project.to_str().unwrap()); until("forwarded editor and decoration events", 5, || { let events = client.plugin_events.lock(); events.iter().any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::EditorOpen { .. })) && events.iter().any(|m| matches!(m.event, PluginEvent::DecorationsChanged { .. })) }) .await; invoke(&peer, "plugin_run_command", run("secret", Some("remote-token"))).await.unwrap(); assert_eq!(desktop.declared_secret("api14", "token").as_deref(), Some("remote-token")); invoke(&peer, "plugin_run_command", run("delete-secret", None)).await.unwrap(); assert_eq!(desktop.declared_secret("api14", "token"), None); invoke(&peer, "plugin_run_command", run("window-status", Some("remote"))).await.unwrap(); let status = invoke(&peer, "plugin_status_items", json!({})).await.unwrap(); assert!(status.as_array().unwrap().iter().any(|s| s["window"] == true && s["item"]["text"] == "remote")); let oauth_peer = peer.clone(); let oauth = tokio::spawn(async move { invoke( &oauth_peer, "plugin_run_command", json!({ "pluginId": "api14", "commandId": "oauth", "arg": null, "editor": null }), ) .await }); until("remote OAuth URI", 5, || { client .plugin_events .lock() .iter() .any(|m| matches!(&m.event, PluginEvent::OutputChanged { channel, .. } if channel == "oauth")) }) .await; let uri = client .plugin_events .lock() .iter() .find_map(|m| match &m.event { PluginEvent::OutputChanged { channel, text: Some(text), .. } if channel == "oauth" => Some(text.clone()), _ => None, }) .unwrap(); let authority = uri.strip_prefix("http://").unwrap().split('/').next().unwrap(); let mut stream = tokio::net::TcpStream::connect(authority).await.unwrap(); stream.write_all(b"GET /oauth/callback?code=desktop%2Bcode HTTP/1.1\r\nHost: localhost\r\n\r\n").await.unwrap(); let mut response = String::new(); stream.read_to_string(&mut response).await.unwrap(); assert!(response.contains("You can close this tab")); assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode")); plugins.host().shutdown(); desktop.shutdown(); peer.kill(); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution_host() { let tmp = tempfile::tempdir().unwrap(); let bin = tmp.path().join("bin"); std::fs::create_dir(&bin).unwrap(); let executable = bin.join("execution-tool"); std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap(); std::fs::set_permissions(&executable, 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()); desktop.install_from_dir(&fixture("execution"), &Consent::all()).unwrap(); let project = tmp.path().join("project"); std::fs::create_dir(&project).unwrap(); let project = project.canonicalize().unwrap(); std::fs::write(project.join("execution.marker"), "").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.into()))), 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, 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(); sync(&peer, &desktop, &[]).await; assert!(sync(&peer, &desktop, &["execution"]).await.failed.is_empty()); invoke( &peer, "plugin_run_command", json!({"pluginId":"execution", "commandId":"target", "arg":null, "editor":null}), ) .await .unwrap(); let targets = invoke(&peer, "plugin_run_targets", json!({})).await.unwrap(); assert_eq!(targets.as_array().unwrap().len(), 1); assert_eq!(targets[0]["pluginId"], "execution"); assert!(desktop.run_targets(&project).is_empty()); let id = targets[0]["id"].as_str().unwrap(); let settings = invoke(&peer, "plugin_answer_run_target", json!({"id":id, "useTarget":false})).await.unwrap(); assert_eq!(settings["run"]["defaultTarget"], ""); let config = json!({"id":"wrapped-php", "name":"Wrapped PHP", "kind":"php", "command":"php", "args":["x.php", "literal ; $(touch bad)"], "env":{}, "detected":false, "isTest":false, "target":id}); invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":38}})).await.unwrap(); until("remote wrapped run", 5, || project.join("wrapped.txt").exists()).await; assert_eq!( std::fs::read_to_string(project.join("wrapped.txt")).unwrap(), "\n<--cwd=/container>\n\n\n\n" ); assert!(!project.join("bad").exists()); invoke( &peer, "plugin_run_command", json!({"pluginId":"execution", "commandId":"untarget", "arg":null, "editor":null}), ) .await .unwrap(); assert_eq!(invoke(&peer, "plugin_run_targets", json!({})).await.unwrap(), json!([])); client.plugin_events.lock().clear(); client.events.lock().clear(); let runner = peer.clone(); let run = tokio::spawn(async move { invoke( &runner, "plugin_run_command", json!({"pluginId": "execution", "commandId": "terminal", "arg": null, "editor": null}), ) .await .unwrap() }); until("terminal request", 5, || { client.plugin_events.lock().iter().any(|m| matches!(m.event, PluginEvent::WindowRequest { .. })) }) .await; let (request_id, operation) = client .plugin_events .lock() .iter() .find_map(|m| match &m.event { PluginEvent::WindowRequest { request_id, request: ide_host::plugins::WindowRequest::Terminal { operation, .. }, .. } => { assert_eq!(m.window.as_deref(), Some("main")); Some((request_id.clone(), operation.clone())) } _ => None, }) .unwrap(); let terminal = invoke( &peer, "plugin_terminal_session_spawn", json!({"operation": operation, "cols": 80, "rows": 24, "onOutput": {"$channel": 35}}), ) .await .unwrap(); assert!(terminal["id"].as_u64().is_some()); assert!( invoke( &peer, "plugin_terminal_session_spawn", json!({"operation": operation, "cols": 80, "rows": 24, "onOutput": {"$channel": 36}}) ) .await .is_err() ); invoke( &peer, "plugin_answer_command", json!({"requestId": request_id, "answer": {"kind": "value", "value": null}}), ) .await .unwrap(); assert_eq!(run.await.unwrap(), operation); invoke( &peer, "plugin_run_command", json!({"pluginId": "execution", "commandId": "write", "arg": "finished\n", "editor": null}), ) .await .unwrap(); until("PTY exit", 5, || client.events.lock().iter().any(|m| matches!(m.event, IdeEvent::TerminalExited { .. }))) .await; plugins.host().flush_events_at(std::time::Instant::now() + Duration::from_secs(3)); plugins.host().wait_events(Duration::from_secs(5)); let received = invoke( &peer, "plugin_run_command", json!({"pluginId": "execution", "commandId": "events", "arg": null, "editor": null}), ) .await .unwrap(); let received: Value = serde_json::from_str(received.as_str().unwrap()).unwrap(); assert_eq!(received.as_array().unwrap().len(), 1); assert_eq!(received[0]["kind"], "terminal-exited"); let data: Value = serde_json::from_str(received[0]["data"].as_str().unwrap()).unwrap(); assert_eq!(data["id"], operation); assert_eq!(data["status"], 0); assert_eq!(data["closed"], false); client.plugin_events.lock().clear(); client.events.lock().clear(); let runner = peer.clone(); let run = tokio::spawn(async move { invoke( &runner, "plugin_run_command", json!({"pluginId": "execution", "commandId": "run", "arg": null, "editor": null}), ) .await .unwrap() }); until("run request", 5, || { client.plugin_events.lock().iter().any(|m| { matches!(&m.event, PluginEvent::WindowRequest { request: ide_host::plugins::WindowRequest::Run { .. }, .. }) }) }) .await; let (request_id, operation, config) = client .plugin_events .lock() .iter() .find_map(|m| match &m.event { PluginEvent::WindowRequest { request_id, request: ide_host::plugins::WindowRequest::Run { operation, config, .. }, .. } => Some((request_id.clone(), operation.clone(), config.clone())), _ => None, }) .unwrap(); let info = invoke(&peer, "run_start", json!({"config": config, "onOutput": {"$channel": 37}})).await.unwrap(); invoke( &peer, "plugin_answer_command", json!({"requestId": request_id, "answer": {"kind": "value", "value": null}}), ) .await .unwrap(); assert_eq!(run.await.unwrap(), operation); invoke(&peer, "run_stdin", json!({"id": info["id"], "data": "finished\n"})).await.unwrap(); until("run exit", 5, || client.events.lock().iter().any(|m| matches!(&m.event, IdeEvent::RunExited { .. }))).await; plugins.host().flush_events_at(std::time::Instant::now() + Duration::from_secs(3)); plugins.host().wait_events(Duration::from_secs(5)); let received = invoke( &peer, "plugin_run_command", json!({"pluginId": "execution", "commandId": "events", "arg": null, "editor": null}), ) .await .unwrap(); let received: Value = serde_json::from_str(received.as_str().unwrap()).unwrap(); assert_eq!(received.as_array().unwrap().len(), 2); assert_eq!(received[1]["kind"], "run-exited"); let data: Value = serde_json::from_str(received[1]["data"].as_str().unwrap()).unwrap(); assert_eq!(data["id"], operation); assert_eq!(data["status"], 0); assert_eq!(data["killed"], false); plugins.host().shutdown(); } #[tokio::test(flavor = "multi_thread")] async fn api14_database_sources_credentials_and_disable_lifetime_run_on_the_server() { ide_ssh::secrets::testing::use_memory_store(); let tmp = tempfile::tempdir().unwrap(); let id = "test.data-tools"; let desktop = PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()) .unwrap(); desktop.install_from_dir(&fixture("data-tools"), &Consent::all()).unwrap(); let project = tmp.path().join("project"); std::fs::create_dir(&project).unwrap(); let project = project.canonicalize().unwrap(); std::fs::write(project.join("data.sqlite"), "").unwrap(); let base = tmp.path().join("server"); let slot = Arc::new(OnceLock::new()); let sink = slot.clone(); let host = Arc::new(Host::new(HostConfig { config_dir: base.join("config"), data_dir: base.join("data"), local_data_dir: base.join("local"), languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), syntax: Arc::default(), sink_for: Box::new(move |w| rpc::event_sink(sink.clone(), Some(w.into()))), owner: |w| w, runtime: tokio::runtime::Handle::current(), project_environment: false, })); let plugins = RemotePlugins::start(&base.join("data"), &host, slot.clone()).unwrap(); 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, 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(); assert!(sync(&peer, &desktop, &[id]).await.failed.is_empty()); let source = json!({"id":"local","name":"Remote local","driver":"sqlite","database":"data.sqlite","options":[]}).to_string(); invoke(&peer, "plugin_run_command", json!({"pluginId":id,"commandId":"upsert","arg":source,"editor":null})) .await .unwrap(); invoke( &peer, "plugin_run_command", json!({"pluginId":id,"commandId":"credential","arg":"server-only-secret","editor":null}), ) .await .unwrap(); let sources = invoke(&peer, "db_sources", json!({})).await.unwrap(); assert_eq!(sources[0]["pluginOwner"], id); let internal = sources[0]["id"].as_str().unwrap(); let store = ide_db::secrets::SecretStore::new(base.join("data/db-passwords.json"), &project); assert_eq!(store.get(internal).as_deref(), Some("server-only-secret")); until("database source change", 5, || { client.events.lock().iter().any(|m| matches!(m.event, IdeEvent::DbSourcesChanged)) }) .await; desktop.set_enabled(id, false).unwrap(); let report = sync(&peer, &desktop, &[]).await; assert!(report.removed.is_empty()); assert!(!invoke(&peer, "db_sources", json!({})).await.unwrap().as_array().unwrap().is_empty()); desktop.set_enabled(id, true).unwrap(); assert!(sync(&peer, &desktop, &[]).await.failed.is_empty()); let runner = peer.clone(); let connection = tokio::spawn(async move { invoke( &runner, "plugin_run_command", json!({"pluginId":"test.data-tools","commandId":"connect","arg":null,"editor":null}), ) .await }); until("database connect request", 5, || { client.plugin_events.lock().iter().any(|m| { matches!( m.event, PluginEvent::WindowRequest { request: ide_host::plugins::WindowRequest::DatabaseConnect { .. }, .. } ) }) }) .await; let request = client .plugin_events .lock() .iter() .find_map(|m| match &m.event { PluginEvent::WindowRequest { request_id, request: ide_host::plugins::WindowRequest::DatabaseConnect { .. }, .. } => Some(request_id.clone()), _ => None, }) .unwrap(); invoke(&peer, "db_connect", json!({"id":internal})).await.unwrap(); invoke(&peer, "plugin_answer_command", json!({"requestId":request,"answer":{"kind":"value","value":null}})) .await .unwrap(); connection.await.unwrap().unwrap(); assert!(project.join("data.sqlite").exists()); desktop.uninstall(id).unwrap(); assert_eq!(sync(&peer, &desktop, &[]).await.removed, [id]); assert!(invoke(&peer, "db_sources", json!({})).await.unwrap().as_array().unwrap().is_empty()); assert!(store.get(internal).is_none()); plugins.host().shutdown(); }