diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 040fc1c8e..4017a5b44 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -565,6 +565,10 @@ impl Shared { self.closed_clients.lock().remove(client); } + pub fn client_closed(&self, client: &str) -> bool { + self.closed_clients.lock().contains(client) + } + /// Withdraws a plugin's run-time state (it stopped or failed). Its /// subscriptions go only with `subscriptions`, as they survive restarts. pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) { diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index b055c0eb4..e83aadb03 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -326,6 +326,7 @@ pub struct PluginHost { providers: Arc, navigation: Mutex, endpoints: Mutex, + web_ptys: Mutex>>, /// Validated UI declarations of installed packages (start-up reuses them). index: Arc, ledger: Mutex, @@ -417,6 +418,7 @@ impl PluginHost { providers, navigation: Mutex::default(), endpoints: Mutex::default(), + web_ptys: Mutex::default(), index, plugins: RwLock::default(), state: Mutex::new(state), @@ -1370,6 +1372,7 @@ impl PluginHost { pub fn forget_client(&self, label: &str) { self.queues.lock().retain(|(_, client), _| client.as_deref() != Some(label)); self.shared.forget_client(label); + self.forget_web_ptys(label); } /// The user cancelled a plugin task (`task_id` from `TaskStarted`). @@ -1762,6 +1765,7 @@ impl PluginHost { let actors: Vec<_> = self.plugins.read().values().filter_map(|p| p.runtime.lock().take()).collect(); for id in self.plugins.read().keys() { self.shared.block_prompts(id); + self.stop_web_ptys(id); } for a in &actors { a.stop(false); @@ -2178,6 +2182,7 @@ impl PluginHost { } fn stop(&self, plugin: &LoadedPlugin) { + self.stop_web_ptys(plugin.id()); self.clear_plugin_navigation(plugin.id()); self.clear_plugin_endpoints(plugin.id()); // A call waiting on the user would keep the join below waiting. @@ -2198,6 +2203,8 @@ impl PluginHost { let mut meta = plugin.meta.lock(); meta.status = PluginStatus::Failed; meta.error = Some(error); + drop(meta); + self.stop_web_ptys(plugin.id()); } fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { diff --git a/crates/ide-host/src/plugins/web.rs b/crates/ide-host/src/plugins/web.rs index 11a109f8e..eb67a1a98 100644 --- a/crates/ide-host/src/plugins/web.rs +++ b/crates/ide-host/src/plugins/web.rs @@ -100,6 +100,23 @@ pub struct WebPage { pub path: String, } +/// A web frame's API 1.3 PTY request. The frame never supplies an executable path or cwd. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct WebPtyCommand { + pub program: String, + pub args: Vec, + pub env: std::collections::BTreeMap, + pub cols: u16, + pub rows: u16, +} + +pub(super) struct WebPtySession { + id: ide_core::TerminalId, + host: std::sync::Weak, + window: String, +} + /// A file of a web view. pub struct WebFile { pub bytes: Vec, @@ -107,6 +124,138 @@ pub struct WebFile { } impl PluginHost { + /// Uses the normal PTY backend, without user shell fallback or startup commands. + #[allow(clippy::too_many_arguments)] + pub fn spawn_web_pty( + &self, + host: &std::sync::Arc, + plugin_id: &str, + view: &str, + window: &str, + command: WebPtyCommand, + output: crate::ops::Emit, + ) -> IdeResult { + let _guard = self.lifecycle.lock(); + let mut sessions = self.web_ptys.lock(); + if self.shared.client_closed(window) { + return Err(IdeError::cancelled()); + } + let options = self.web_pty_options(plugin_id, view, window, command)?; + let entries = sessions.entry(plugin_id.to_owned()).or_default(); + entries.retain(|s| s.host.upgrade().is_some_and(|h| h.terminals.contains(s.id))); + if entries.len() >= 8 { + return Err(IdeError::invalid("a plugin may own at most 8 PTYs")); + } + let cwd = options.cwd.0.clone(); + let info = host.terminals.spawn_program( + options, + host.owner_window(window).to_owned(), + host.window_sink(window), + Box::new(move |data| output(data)), + )?; + if !self + .shared + .workspace() + .is_some_and(|workspace| workspace.project_of(window).as_ref() == Some(&cwd) && workspace.trusted(&cwd)) + { + let _ = host.terminals.kill(info.id); + return Err(IdeError::cancelled()); + } + entries.push(WebPtySession { id: info.id, host: std::sync::Arc::downgrade(host), window: window.to_owned() }); + Ok(info) + } + + pub(super) fn forget_web_ptys(&self, window: &str) { + for sessions in self.web_ptys.lock().values_mut() { + sessions.retain(|session| { + if session.window != window { + return true; + } + if let Some(host) = session.host.upgrade() { + let _ = host.terminals.kill(session.id); + } + false + }); + } + } + + pub(super) fn stop_web_ptys(&self, plugin: &str) { + if let Some(sessions) = self.web_ptys.lock().remove(plugin) { + for session in sessions { + if let Some(host) = session.host.upgrade() { + let _ = host.terminals.kill(session.id); + } + } + } + } + + /// Authorizes a PTY immediately before spawning through the existing terminal backend. + pub fn web_pty_options( + &self, + plugin_id: &str, + view: &str, + window: &str, + command: WebPtyCommand, + ) -> IdeResult { + self.web_page(plugin_id, view)?; + let plugin = self.get(plugin_id)?; + plugin.pkg.manifest.needs_api("web view PTYs", 3).map_err(IdeError::invalid)?; + let cap = ide_extension::Capability::Process(command.program.clone()); + if !plugin.meta.lock().granted.contains(&cap) { + return Err(IdeError::new( + ide_core::error::ErrorKind::PermissionDenied, + format!("{cap} capability not granted"), + )); + } + if command.program.is_empty() + || command.program.len() > 256 + || command.program.trim() != command.program + || command.program.contains(['\0', '\r', '\n']) + || command.args.len() > 256 + || command.args.iter().any(|a| a.len() > 4096 || a.contains('\0')) + || !(2..=500).contains(&command.cols) + || !(1..=500).contains(&command.rows) + { + return Err(IdeError::invalid("invalid PTY program, arguments or dimensions")); + } + if command.env.len() > 64 + || command.env.iter().any(|(k, v)| { + k.is_empty() + || k.len() > 256 + || k.contains(['=', '\0']) + || v.len() > 4096 + || v.contains('\0') + || std::env::vars_os().any(|(inherited, _)| { + if cfg!(windows) { + inherited.to_string_lossy().eq_ignore_ascii_case(k) + } else { + inherited == k.as_str() + } + }) + }) + { + return Err(IdeError::invalid("PTY environment may only add variables, within the size limits")); + } + let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?; + let cwd = workspace.project_of(window).ok_or_else(|| IdeError::invalid("no project in this window"))?; + if !cwd.is_absolute() || !cwd.is_dir() { + return Err(IdeError::invalid("the project directory no longer exists")); + } + if !workspace.trusted(&cwd) { + return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted")); + } + let program = self.program_for(plugin_id, &command.program)?; + Ok(ide_terminal::TerminalOptions { + cwd: cwd.into(), + shell: Some(program.to_string_lossy().into_owned()), + args: command.args, + env: command.env, + cols: command.cols, + rows: command.rows, + shell_integration: false, + }) + } + /// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated). fn web_dirs(&self, plugin: &str) -> Vec { [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS] diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 1d5991252..1c6dc6e44 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -22,6 +22,7 @@ struct TestWorkspace { other_roots: Mutex>, documents: Arc, trusted: bool, + project_queries_left: Mutex>, /// (producer, path) → published diagnostics. diagnostics: Mutex>>, /// plugin → its settings as the user set them. @@ -39,6 +40,7 @@ impl TestWorkspace { other_roots: Mutex::default(), documents: Arc::new(documents), trusted: true, + project_queries_left: Mutex::new(None), diagnostics: Mutex::default(), settings: Mutex::default(), }) @@ -93,6 +95,12 @@ impl PluginWorkspace for TestWorkspace { self.trusted } fn project_of(&self, _window: &str) -> Option { + if let Some(left) = self.project_queries_left.lock().as_mut() { + if *left == 0 { + return None; + } + *left -= 1; + } Some(self.root.clone()) } fn publish_diagnostics( @@ -1947,6 +1955,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() { git(&f.project, &["init", "-b", "main"]); f.install(&official("git-toolbox")); let untrusted = Arc::new(TestWorkspace { + project_queries_left: Mutex::new(None), root: f.project.clone(), other_roots: Mutex::default(), documents: f.workspace.documents.clone(), @@ -4434,6 +4443,169 @@ fn view_messages_accept_multi_megabyte_snapshots_and_cap_at_8_mib() { assert!(err.message.contains("limited to"), "{}", err.message); } +#[test] +fn web_ptys_require_a_current_exact_program_grant_and_project() { + use ide_host::plugins::web::WebPtyCommand; + let f = fixture(Limits::default()); + let info = f.host.install_from_dir(&sample("web-pty"), &Consent::default()).unwrap(); + assert!(info.capabilities.iter().any(|c| c.id == "process:git" && !c.granted)); + let command = || WebPtyCommand { + program: "git".into(), + args: vec!["--version".into()], + env: Default::default(), + cols: 80, + rows: 24, + }; + let options = |cmd| f.host.web_pty_options("web-pty", "terminal", "main", cmd); + assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied); + f.host.set_capability("web-pty", "process:git", true).unwrap(); + let approved = f.host.program_for("web-pty", "git").unwrap(); + let got = options(command()).unwrap(); + assert_eq!(got.shell.as_deref(), approved.to_str()); + assert!(approved.is_absolute()); + assert_eq!(got.cwd.0, f.project); + assert!(!got.shell_integration); + assert_eq!(f.run("web-pty", "ping", None).unwrap().as_deref(), Some("Web PTY ready")); + for program in ["sh", "bash", "git-other", "./git", "/tmp/git"] { + let cmd = WebPtyCommand { program: program.into(), ..command() }; + assert!(options(cmd).is_err(), "{program}"); + } + for cmd in [ + WebPtyCommand { cols: 0, ..command() }, + WebPtyCommand { rows: 501, ..command() }, + WebPtyCommand { args: vec!["x".repeat(4097)], ..command() }, + WebPtyCommand { args: vec!["x".into(); 257], ..command() }, + WebPtyCommand { args: vec!["\0".into()], ..command() }, + WebPtyCommand { env: [("PATH".into(), "/tmp".into())].into(), ..command() }, + WebPtyCommand { env: [("a=b".into(), "x".into())].into(), ..command() }, + WebPtyCommand { env: [("a".into(), "x".repeat(4097))].into(), ..command() }, + ] { + assert!(options(cmd).is_err()); + } + let got = options(WebPtyCommand { env: [("OPENIDEA_WEB_PTY_TEST_NEW".into(), "yes".into())].into(), ..command() }) + .unwrap(); + assert_eq!(got.env["OPENIDEA_WEB_PTY_TEST_NEW"], "yes"); + assert!(f.host.web_pty_options("web-pty", "missing", "main", command()).is_err()); + f.host.set_workspace(None); + assert!(options(command()).is_err()); + let mut untrusted = TestWorkspace::new(&f.project); + Arc::get_mut(&mut untrusted).unwrap().trusted = false; + f.host.set_workspace(Some(untrusted)); + assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied); + f.host.set_workspace(Some(f.workspace.clone())); + std::fs::rename(&f.project, f.project.with_extension("moved")).unwrap(); + assert!(options(command()).unwrap_err().message.contains("no longer exists")); + std::fs::rename(f.project.with_extension("moved"), &f.project).unwrap(); + f.host.set_capability("web-pty", "process:git", false).unwrap(); + assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied); + f.host.set_enabled("web-pty", false).unwrap(); + assert!(options(command()).is_err()); + f.host.uninstall("web-pty").unwrap(); + assert!(options(command()).is_err()); +} + +#[cfg(unix)] +#[test] +fn web_ptys_keep_the_approved_file_and_require_api_1_3() { + use ide_host::plugins::web::WebPtyCommand; + use std::os::unix::fs::PermissionsExt; + let f = fixture(Limits::default()); + let bin = f.plugins_dir.parent().unwrap().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let executable = bin.join("git"); + std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.clone(); + f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string()))); + f.install(&sample("web-pty")); + let command = || WebPtyCommand { program: "git".into(), args: vec![], env: Default::default(), cols: 80, rows: 24 }; + f.host.set_program_path(Arc::new(|| Some("/unapproved-path".into()))); + assert_eq!( + f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap().shell.unwrap(), + executable.canonicalize().unwrap().to_string_lossy() + ); + std::fs::write(&executable, "#!/bin/sh\nexit 42\n").unwrap(); + assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap_err().message.contains("changed")); + std::fs::remove_file(&executable).unwrap(); + assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).is_err()); + + let old = tempfile::tempdir().unwrap(); + let pkg = old.path().join("old"); + copy_dir(&sample("web-pty"), &pkg); + let manifest = pkg.join("plugin.toml"); + let text = std::fs::read_to_string(&manifest).unwrap().replace("^1.3", "^1.2"); + std::fs::write(manifest, text).unwrap(); + let f = fixture(Limits::default()); + f.install(&pkg); + assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap_err().message.contains("1.3")); +} + +#[tokio::test] +async fn web_ptys_are_capped_across_windows_and_end_with_the_plugin() { + use ide_host::{Host, HostConfig, plugins::web::WebPtyCommand}; + let f = fixture(Limits::default()); + f.install(&sample("web-pty")); + let root = tempfile::tempdir().unwrap(); + let host = Arc::new(Host::new(HostConfig { + config_dir: root.path().join("config"), + data_dir: root.path().join("data"), + local_data_dir: root.path().join("local"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(ide_core::NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + let spawn = |window: &str| { + f.host.spawn_web_pty( + &host, + "web-pty", + "terminal", + window, + WebPtyCommand { + program: "git".into(), + args: vec!["hash-object".into(), "--stdin".into()], + env: Default::default(), + cols: 80, + rows: 24, + }, + Arc::new(|_| {}), + ) + }; + *f.workspace.project_queries_left.lock() = Some(1); + assert!(spawn("main").is_err(), "project closed while spawning"); + assert!(host.terminals.list().is_empty()); + *f.workspace.project_queries_left.lock() = None; + let mut ids = Vec::new(); + for i in 0..8 { + ids.push(spawn(&format!("window-{i}")).unwrap().id); + } + assert!(spawn("ninth-window").unwrap_err().message.contains("8 PTYs")); + host.terminals.kill(ids[0]).unwrap(); + let replacement = spawn("another-window").unwrap(); + f.host.reload("web-pty").unwrap(); + assert!(!host.terminals.contains(replacement.id)); + assert!(ids.into_iter().all(|id| !host.terminals.contains(id))); + let id = spawn("main").unwrap().id; + f.host.set_capability("web-pty", "process:git", false).unwrap(); + assert!(!host.terminals.contains(id)); + assert!(spawn("main").is_err()); + f.host.set_capability("web-pty", "process:git", true).unwrap(); + let id = spawn("main").unwrap().id; + f.host.set_enabled("web-pty", false).unwrap(); + assert!(!host.terminals.contains(id)); + f.host.set_enabled("web-pty", true).unwrap(); + let id = spawn("main").unwrap().id; + f.host.forget_client("main"); + assert!(!host.terminals.contains(id)); + assert!(spawn("main").is_err()); + f.host.reopen_client("main"); + let id = spawn("main").unwrap().id; + f.host.uninstall("web-pty").unwrap(); + assert!(!host.terminals.contains(id)); +} + #[test] fn marker_icons_ship_sanitized_light_and_dark_assets() { let f = fixture(Limits::default()); diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index ef2855849..5b8ede3d6 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm index 101301198..20743313d 100644 Binary files a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm index 7692e5b06..752d3d658 100644 Binary files a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm index 892fa6b6e..4e214ba73 100644 Binary files a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm index 112bfdbd8..5db3f2dc7 100644 Binary files a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 612cf46bb..046be4056 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index baee49a89..70d531824 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index 0238937a4..f4c49c9cb 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index 943a63cef..4b3f14ace 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 8bbe87f6d..2400a6300 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm index 84a342d63..85c41c477 100644 Binary files a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index ccbdb011f..126b88723 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index eaaa37edb..acb7a86ff 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm index 255d942b8..f1ad28ffa 100644 Binary files a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index a25719642..d54eaa4eb 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/web-pty/Cargo.lock b/crates/ide-plugin/tests/fixtures/web-pty/Cargo.lock new file mode 100644 index 000000000..e3a599c86 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-pty/Cargo.lock @@ -0,0 +1,333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "macro-string" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "openidea-plugin" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "openidea-test-web-pty" +version = "0.1.0" +dependencies = [ + "openidea-plugin", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/ide-plugin/tests/fixtures/web-pty/Cargo.toml b/crates/ide-plugin/tests/fixtures/web-pty/Cargo.toml new file mode 100644 index 000000000..ef56da599 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-pty/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openidea-test-web-pty" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" } + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm new file mode 100644 index 000000000..2255d5579 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources new file mode 100644 index 000000000..c1cf5b704 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources @@ -0,0 +1 @@ +bb6e1eba6b9f28ca769bee0c6dad9ceab536d486efcbe0ad56bbfe621e2d7679 diff --git a/crates/ide-plugin/tests/fixtures/web-pty/plugin.toml b/crates/ide-plugin/tests/fixtures/web-pty/plugin.toml new file mode 100644 index 000000000..3209898b7 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-pty/plugin.toml @@ -0,0 +1,21 @@ +id = "web-pty" +name = "Web PTY Probe" +version = "0.1.0" +description = "Test fixture for capability-gated web frame PTYs." +engines.openidea = "^1.3" +wasm = "dist/plugin.wasm" +activation = ["on-command:ping"] + +[capabilities] +optional = ["process:bash", "process:git"] + +[[contributes.views]] +id = "terminal" +title = "PTY Probe" +icon = "toolwindow-terminal" +location = "right" +web = "web/index.html" + +[[contributes.commands]] +id = "ping" +title = "PTY Probe: Ping" diff --git a/crates/ide-plugin/tests/fixtures/web-pty/src/lib.rs b/crates/ide-plugin/tests/fixtures/web-pty/src/lib.rs new file mode 100644 index 000000000..91eefdb01 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-pty/src/lib.rs @@ -0,0 +1,15 @@ +use openidea_plugin::{Error, Invocation, Plugin, register_plugin}; + +#[derive(Default)] +struct WebPty; + +impl Plugin for WebPty { + fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + match id { + "ping" => Ok(Some("Web PTY ready".into())), + _ => Err(Error::NotFound(id.into())), + } + } +} + +register_plugin!(WebPty); diff --git a/crates/ide-plugin/tests/fixtures/web-pty/web/index.html b/crates/ide-plugin/tests/fixtures/web-pty/web/index.html new file mode 100644 index 000000000..4040b0847 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/web-pty/web/index.html @@ -0,0 +1,27 @@ + +PTY Probe + +

Web PTY Probe

+ +

+
+ diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 26d7fe3c9..098ffcab8 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -91,12 +91,45 @@ impl TerminalManager { key: String, sink: SharedSink, on_output: Box, + ) -> IdeResult { + self.spawn_impl(options, key, sink, on_output, false) + } + + /// Runs an approved absolute executable with exact arguments and cwd. + /// A missing project directory fails instead of falling back to the home directory. + pub fn spawn_program( + &self, + options: TerminalOptions, + key: String, + sink: SharedSink, + on_output: Box, + ) -> IdeResult { + if !options.shell.as_deref().is_some_and(|p| Path::new(p).is_absolute()) + || !options.cwd.is_absolute() + || !options.cwd.is_dir() + { + return Err(IdeError::invalid("a program PTY requires an absolute executable and an existing cwd")); + } + self.spawn_impl(options, key, sink, on_output, true) + } + + fn spawn_impl( + &self, + options: TerminalOptions, + key: String, + sink: SharedSink, + on_output: Box, + exact: bool, ) -> IdeResult { let size = pty_size(options.cols, options.rows); let pair = native_pty_system().openpty(size).map_err(|e| IdeError::internal(format!("openpty failed: {e}")))?; - let wsl = if cfg!(windows) { wsl_shell(&options) } else { None }; - let (shell, mut args) = wsl.clone().unwrap_or_else(|| resolve_shell(options.shell.as_deref(), &options.args)); + let wsl = if cfg!(windows) && !exact { wsl_shell(&options) } else { None }; + let (shell, mut args) = if exact { + (options.shell.clone().expect("spawn_program checked the executable"), options.args.clone()) + } else { + wsl.clone().unwrap_or_else(|| resolve_shell(options.shell.as_deref(), &options.args)) + }; let mut env = terminal_env(&options.env); if let Some(msystem) = default_msystem(&shell, &options.env, std::env::var("MSYSTEM").ok().as_deref()) { env.insert("MSYSTEM".to_owned(), msystem.to_owned()); @@ -104,13 +137,19 @@ impl TerminalManager { if default_chere_invoking(&shell, &options.env, std::env::var("CHERE_INVOKING").ok().as_deref()) { env.insert("CHERE_INVOKING".to_owned(), "1".to_owned()); } - if options.shell_integration { + if options.shell_integration && !exact { integration::apply(&shell_title(&shell), &mut args, &mut env); } let mut cmd = CommandBuilder::new(&shell); cmd.args(&args); // wsl.exe takes the directory through `--cd`; a UNC share is no valid process cwd. - cmd.cwd(if wsl.is_some() { resolve_cwd(Path::new("")) } else { resolve_cwd(&options.cwd) }); + cmd.cwd(if exact { + options.cwd.0.clone() + } else if wsl.is_some() { + resolve_cwd(Path::new("")) + } else { + resolve_cwd(&options.cwd) + }); for (k, v) in ide_core::process::host_env_fixes() { match v { Some(v) => cmd.env(k, v), @@ -770,6 +809,22 @@ mod tests { all } + #[test] + fn program_sessions_never_fall_back_to_another_executable_or_cwd() { + let h = harness(); + let mut options = sh("exit 0"); + options.shell = None; + assert!(h.manager.spawn_program(options, "test".into(), h.sink.clone(), Box::new(|_| {})).is_err()); + let mut options = sh("exit 0"); + options.shell = Some("sh".into()); + assert!(h.manager.spawn_program(options, "test".into(), h.sink.clone(), Box::new(|_| {})).is_err()); + let mut options = sh("exit 0"); + options.cwd = std::env::temp_dir().join(format!("openidea-missing-{}", std::process::id())).into(); + assert!(!options.cwd.exists()); + assert!(h.manager.spawn_program(options, "test".into(), h.sink.clone(), Box::new(|_| {})).is_err()); + assert!(h.manager.list().is_empty()); + } + #[test] fn output_and_exit_code() { let h = harness(); diff --git a/plugins/README.md b/plugins/README.md index 4695c798c..0b9f307e1 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -962,6 +962,45 @@ sends what Graphviz reports to the plugin, which publishes it as problems. The b checked in (it is tens of MB): run `plugins/build.sh official/excalidraw` before installing the plugin from the repository. +### Web frame PTYs (API 1.3) + +Packages requiring `engines.openidea = "^1.3"` can exchange JSON strings through +`openidea.postMessage` / `onMessage` to own an interactive PTY. Declare +`process:` in the manifest; the install dialog shows its high-risk +permission and the user must grant it. The host runs the exact approved file, +never a fresh PATH lookup or an implicit fallback shell. A shell requires its +own grant. The cwd is always this window's trusted project root; a frame cannot +supply a cwd. Environment entries only add variables: replacing an inherited +variable (including PATH) is refused. + +| Message | Fields beyond `op` and `sessionId` | +| ------------ | ------------------------------------------------------------------------------------------ | +| `pty-spawn` | `program`, optional `args` (default `[]`), `env` (default `{}`), `cols` (100), `rows` (28) | +| `pty-input` | `data`, at most 64 Ki UTF-16 code units pending or in flight | +| `pty-resize` | Integer `cols` (2–500), `rows` (1–500) | +| `pty-kill` | None | +| `pty-ack` | `sequence` from the last `pty-data`, after consuming its data | + +`sessionId` is 1–64 ASCII letters, digits, underscores, dots or hyphens. Duplicate +live session ids are refused. There are at most 8 sessions per plugin across its +frames, including pending spawns. A frame controls only its own sessions. Limits: +256 arguments of 4096 UTF-8 bytes each, 64 new environment entries (names 256 +bytes, values 4096), no NUL. Bridge messages are at most 2 Mi UTF-16 code units. + +The host sends `pty-data { sessionId, data, sequence }`, +`pty-exit { sessionId, exitCode }` (nullable), or +`pty-error { sessionId, error }`. Only one output chunk is unacknowledged at a +time. Outstanding output, including that chunk, is capped at 256 Ki UTF-16 code +units; overflowing input or output terminates the session and reports an error. +Exit follows consumption of the remaining output. Unmounting, reloading, +disabling, unloading, changing grants, switching projects or closing the window +kills its PTYs, including spawns that complete after cleanup. Pages may start fresh sessions after a reload or grant change. + +`crates/ide-plugin/tests/fixtures/web-pty` is a minimal built Wasm package with a +web page: start bash without the optional grant to see refusal, grant +`process:bash` in Settings → Plugins, then send commands and resize or stop it. +This API is a web bridge extension; existing WIT signatures are unchanged. + ### Translations A package translates its own text with `locales/.json`, one file per language diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index a628301c0..867eac614 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index c3a69de28..bbd70c1ae 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 5fdecfbed..0212da992 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 16d6d4449..74356cd81 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 2fc3e6e28..b2bbb9817 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 89cf26099..9baa571db 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index a73f82c97..3a78025b9 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 56953a376..b17ee3ee3 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index c741af6be..276daf1e8 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 0cfff4ffa..a9fc62c52 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 68c504165..647a81967 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/sdk/rust/openidea-plugin/README.md b/sdk/rust/openidea-plugin/README.md new file mode 100644 index 000000000..3ca2a409f --- /dev/null +++ b/sdk/rust/openidea-plugin/README.md @@ -0,0 +1,15 @@ +# Rust plugin SDK + +The Rust API is documented by `cargo doc`. Web pages shipped alongside a Rust +component use `window.openidea` rather than Rust imports. + +API 1.3 adds capability-gated interactive PTYs to that web bridge. Declare +`engines.openidea = "^1.3"` and `process:` in `plugin.toml`, then send +`pty-spawn`, `pty-input`, `pty-resize` and `pty-kill` JSON strings using +`openidea.postMessage`. Consume `pty-data` and acknowledge its `sequence` using +`pty-ack`; handle `pty-exit` and `pty-error` through `openidea.onMessage`. + +See [the protocol, permission and resource limits](../../../plugins/README.md#web-frame-ptys-api-13) +and the [minimal fixture](../../../crates/ide-plugin/tests/fixtures/web-pty). +The frame cannot access IPC directly. Its sessions belong to that frame and +end with its view or plugin. There is no new WIT import for this API. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 454c924b1..c715d8a40 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -499,6 +499,7 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_service_detail, plugin::plugin_service_log, plugin::plugin_web_page, + plugin::plugin_web_pty_spawn, plugin::plugin_web_save_file, plugin::plugin_web_read_file, plugin::plugin_line_markers, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 2d69c0edd..39d22a488 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -23,6 +23,7 @@ use tauri::{AppHandle, Manager, State, Window}; /// How long `plugin_list` waits for the startup load before answering with what it has. const INITIAL_LOAD_WAIT: Duration = Duration::from_secs(5); + /// `on-startup-finished` fires this long after the installed plugins are loaded. const STARTUP_FINISHED_DELAY: Duration = Duration::from_millis(1500); /// How often the marketplace index is read again (revocations). @@ -531,6 +532,35 @@ pub fn plugin_web_page( state.plugins.web_page(&plugin_id, &view) } +#[tauri::command] +#[specta::specta] +pub async fn plugin_web_pty_spawn( + window: Window, + state: State<'_, AppState>, + plugin_id: String, + view: String, + command: ide_host::plugins::web::WebPtyCommand, + on_output: Channel, +) -> IdeResult { + let plugins = state.plugins.clone(); + let host = state.host.clone(); + let window = window.label().to_owned(); + tauri::async_runtime::spawn_blocking(move || { + plugins.spawn_web_pty( + &host, + &plugin_id, + &view, + &window, + command, + Arc::new(move |chunk| { + let _ = on_output.send(chunk); + }), + ) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + /// Largest file a web view saves or opens (`openidea.saveFile` / `openFile`). const MAX_WEB_VIEW_FILE: u64 = 64 << 20; diff --git a/src/bindings.ts b/src/bindings.ts index 6e6bdbc58..12dd4034a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1128,6 +1128,7 @@ export const commands = { pluginServiceLog: (request: RequestId, pluginId: string, view: string, key: string, tab: string, on: string[], onLine: Channel) => typedError(__TAURI_INVOKE("plugin_service_log", { request, pluginId, view, key, tab, on, onLine })), /** The page of a plugin's web view or custom editor (`web = …`). */ pluginWebPage: (pluginId: string, view: string) => typedError(__TAURI_INVOKE("plugin_web_page", { pluginId, view })), + pluginWebPtySpawn: (pluginId: string, view: string, command: WebPtyCommand, onOutput: Channel) => typedError(__TAURI_INVOKE("plugin_web_pty_spawn", { pluginId, view, command, onOutput })), /** * Writes what a web view saves (`openidea.saveFile`) to the path the user * chose in the save dialog, on this machine. @@ -8277,6 +8278,15 @@ export type WebPage = { path: string, }; +/** A web frame's API 1.3 PTY request. The frame never supplies an executable path or cwd. */ +export type WebPtyCommand = { + program: string, + args: string[], + env: { [key in string]: string }, + cols: number, + rows: number, +}; + export type WebReference = { kind: WebReferenceKind, /** Without sigil: `card`, `main`, `--accent`. */ diff --git a/src/features/plugins/__tests__/ptyBridge.test.ts b/src/features/plugins/__tests__/ptyBridge.test.ts new file mode 100644 index 000000000..556a06474 --- /dev/null +++ b/src/features/plugins/__tests__/ptyBridge.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TerminalId, TerminalInfo } from "@/bindings"; +import { ptyBridge, type PtyBackend } from "../views/ptyBridge"; + +const cleanup: Array<() => void> = []; +afterEach(async () => { + for (const close of cleanup.splice(0)) close(); + await tick(); +}); +const tick = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); +}; +function setup(plugin = "acme.pty") { + let output: (data: string) => void = () => {}; + let exit: (id: TerminalId, code: number | null) => void = () => {}; + let serial = 0; + const backend: PtyBackend = { + spawn: vi.fn(async (_command, listener) => { + output = listener; + return { id: ++serial as TerminalId } as TerminalInfo; + }), + write: vi.fn(async () => {}), + resize: vi.fn(async () => {}), + kill: vi.fn(async () => {}), + onExit: (listener) => { + exit = listener; + return vi.fn(); + }, + }; + const sent: Record[] = []; + const bridge = ptyBridge(plugin, backend, (m) => sent.push(m as Record)); + cleanup.push(bridge.close); + const send = (m: object) => bridge.handle(JSON.stringify({ sessionId: "s", ...m })); + const spawn = () => send({ op: "pty-spawn", program: "bash", args: ["-i"] }); + return { + backend, + sent, + bridge, + send, + spawn, + output: (data: string) => output(data), + exit: (id: number, code: number | null) => exit(id as TerminalId, code), + }; +} + +describe("web frame PTY ownership and limits", () => { + it("rejects malformed types, sizes, paths and dimensions without starting a process", () => { + const f = setup(); + for (const bad of [ + { program: "\0" }, + { program: " bash" }, + { args: [1] }, + { args: ["x".repeat(4097)] }, + { args: Array(257).fill("x") }, + { args: ["\0"] }, + { cols: -1 }, + { rows: 501 }, + { cols: 2.5 }, + { cols: "80" }, + { env: [] }, + { env: { PATH: 1 } }, + { env: { "a=b": "v" } }, + { env: { a: "\0" } }, + { cwd: "/" }, + { sessionId: "x".repeat(65) }, + ]) + f.send({ op: "pty-spawn", program: "bash", ...bad }); + expect(f.backend.spawn).not.toHaveBeenCalled(); + expect(f.bridge.handle("null")).toBe(false); + expect(f.bridge.handle("[1]")).toBe(false); + expect(f.bridge.handle("garbage")).toBe(false); + expect(f.bridge.handle(JSON.stringify({ op: "plugin-state", data: "x".repeat(3 << 20) }))).toBe(false); + f.send({ op: "pty-spawn", program: "bash", padding: "x".repeat(3 << 20) }); + expect(f.sent.every((m) => m.op === "pty-error")).toBe(true); + }); + + it("caps sessions across frames of one plugin, including cancelled pending starts", async () => { + const a = setup(); + const b = setup(); + const other = setup("other.pty"); + const resolves: Array<(info: TerminalInfo) => void> = []; + vi.mocked(a.backend.spawn).mockImplementation( + () => + new Promise((r) => { + resolves.push(r); + }), + ); + for (let i = 0; i < 8; i++) a.send({ op: "pty-spawn", program: "bash", sessionId: `s${i}` }); + b.spawn(); + expect(b.backend.spawn).not.toHaveBeenCalled(); + a.send({ op: "pty-kill", sessionId: "s7" }); + b.spawn(); + expect(b.backend.spawn).not.toHaveBeenCalled(); + other.spawn(); + expect(other.backend.spawn).toHaveBeenCalledOnce(); + resolves[7]!({ id: 77 as TerminalId } as TerminalInfo); + await tick(); + expect(a.backend.kill).toHaveBeenCalledWith(77); + b.spawn(); + await tick(); + expect(b.backend.spawn).toHaveBeenCalledOnce(); + // Finish the other pending starts so their reservations cannot leak between tests. + a.bridge.close(); + for (let i = 0; i < 7; i++) resolves[i]!({ id: (80 + i) as TerminalId } as TerminalInfo); + await tick(); + }); + + it("serializes bounded pending and in-flight input and coalesces resizes", async () => { + const f = setup(); + let resolve!: (info: TerminalInfo) => void; + vi.mocked(f.backend.spawn).mockImplementation( + () => + new Promise((r) => { + resolve = r; + }), + ); + f.spawn(); + f.send({ op: "pty-input", data: "hello" }); + f.send({ op: "pty-resize", cols: 90, rows: 30 }); + f.send({ op: "pty-resize", cols: 100, rows: 40 }); + resolve({ id: 1 as TerminalId } as TerminalInfo); + await tick(); + expect(f.backend.write).toHaveBeenCalledWith(1, "hello"); + expect(f.backend.resize).toHaveBeenCalledExactlyOnceWith(1, 100, 40); + vi.mocked(f.backend.write).mockImplementation(() => new Promise(() => {})); + f.send({ op: "pty-input", data: "x".repeat(64 << 10) }); + f.send({ op: "pty-input", data: "x" }); + await tick(); + expect(f.backend.kill).toHaveBeenCalledWith(1); + expect(f.sent.at(-1)?.error).toBe("PTY input limit exceeded"); + }); + + it("never lets one frame write, resize or kill another frame's terminals", async () => { + const a = setup("ownership.pty"); + const b = setup("ownership.pty"); + a.spawn(); + await tick(); + b.send({ op: "pty-input", data: "bad" }); + b.send({ op: "pty-resize", cols: 80, rows: 24 }); + b.send({ op: "pty-kill" }); + expect(a.backend.write).not.toHaveBeenCalled(); + expect(a.backend.resize).not.toHaveBeenCalled(); + expect(a.backend.kill).not.toHaveBeenCalled(); + a.send({ op: "pty-input", data: "yes" }); + await tick(); + expect(a.backend.write).toHaveBeenCalledWith(1, "yes"); + }); + + it("allows one unacknowledged chunk and caps all outstanding output", async () => { + const f = setup("output.pty"); + f.spawn(); + await tick(); + f.output("hello"); + f.output("world"); + expect(f.sent).toHaveLength(1); + f.send({ op: "pty-ack", sequence: 999 }); + expect(f.sent).toHaveLength(1); + f.send({ op: "pty-ack", sequence: 1 }); + expect(f.sent.at(-1)).toMatchObject({ data: "world", sequence: 2 }); + f.output("x".repeat(256 << 10)); + await tick(); + expect(f.backend.kill).toHaveBeenCalledWith(1); + expect(f.sent.at(-1)?.error).toBe("PTY output limit exceeded"); + }); + + it("handles output and exit before spawn resolves and drains before reporting exit", async () => { + const f = setup("early.pty"); + vi.mocked(f.backend.spawn).mockImplementation(async (_command, output) => { + output("early"); + f.exit(42, 7); + return { id: 42 as TerminalId } as TerminalInfo; + }); + f.spawn(); + await tick(); + expect(f.sent).toHaveLength(1); + f.send({ op: "pty-ack", sequence: 1 }); + expect(f.sent.at(-1)).toEqual({ op: "pty-exit", sessionId: "s", exitCode: 7 }); + f.spawn(); + await tick(); + expect(f.backend.spawn).toHaveBeenCalledTimes(2); + }); + + it("cleans failed and late spawns, catches operation failures and closes idempotently", async () => { + const f = setup("cleanup.pty"); + vi.mocked(f.backend.spawn).mockRejectedValueOnce(new Error("denied")); + f.spawn(); + await tick(); + f.spawn(); + await tick(); + expect(f.backend.spawn).toHaveBeenCalledTimes(2); + vi.mocked(f.backend.resize).mockRejectedValueOnce(new Error("gone")); + f.send({ op: "pty-resize", cols: 80, rows: 24 }); + await tick(); + expect(f.backend.kill).toHaveBeenCalledWith(1); + let resolve!: (info: TerminalInfo) => void; + vi.mocked(f.backend.spawn).mockImplementation( + () => + new Promise((r) => { + resolve = r; + }), + ); + f.spawn(); + f.bridge.close(); + f.bridge.close(); + resolve({ id: 2 as TerminalId } as TerminalInfo); + await tick(); + expect(f.backend.kill).toHaveBeenCalledWith(2); + f.spawn(); + expect(f.backend.spawn).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/features/plugins/__tests__/webViews.test.tsx b/src/features/plugins/__tests__/webViews.test.tsx index 9ee8f0b93..51c47fb20 100644 --- a/src/features/plugins/__tests__/webViews.test.tsx +++ b/src/features/plugins/__tests__/webViews.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { render, waitFor } from "@testing-library/react"; +import { act, render, waitFor } from "@testing-library/react"; import type { PluginInfo, ViewEvent } from "@/bindings"; import { addFileIconProvider, fileIconName, registerExternalIcons } from "@/ui"; import { @@ -12,6 +12,7 @@ import { createMemoryPluginsBackend, setPluginsBackend, webSchemeUrl } from "../ import { defineViewUiPoints } from "../uiPoints"; import { clearUiContributions, syncUiContributions } from "../projection"; import { usePlugins } from "../store"; +import * as ptyModule from "../views/ptyBridge"; import { FRAME_TAG, frameDocument } from "../views/frame"; import { WebView, @@ -153,7 +154,7 @@ describe("WebView", () => { viewEvent: vi.fn(async (_p: string, _v: string, e: ViewEvent) => void events.push(e)), }); usePlugins.setState({ - plugins: [{ id: "acme.draw", hasCode, capabilities: [] } as unknown as PluginInfo], + plugins: [{ id: "acme.draw", hasCode, enabled: true, capabilities: [] } as unknown as PluginInfo], }); const doc = memoryDocument("hello world"); const view = render(); @@ -251,6 +252,71 @@ describe("WebView", () => { view.unmount(); } }); + + it("closes frame PTYs on plugin changes, navigation, window teardown and unmount", async () => { + const original = ptyModule.ptyBridge; + const closes: ReturnType[] = []; + const spy = vi.spyOn(ptyModule, "ptyBridge").mockImplementation((...args) => { + const bridge = original(...args); + const close = vi.fn(bridge.close); + closes.push(close); + return { ...bridge, close }; + }); + try { + const { view } = setup(true); + const { frame, send, posted } = await frameOf(view.container); + for (const change of [ + (p: PluginInfo) => ({ ...p, enabled: false }), + (p: PluginInfo) => ({ ...p, enabled: true, capabilities: [] }), + (p: PluginInfo) => ({ ...p, version: "2.0.0" }), + ]) { + const close = closes.at(-1)!; + act(() => usePlugins.setState((s) => ({ plugins: s.plugins.map(change) }))); + expect(close).toHaveBeenCalled(); + } + const close = closes.at(-1)!; + act(() => { + frame.dispatchEvent(new Event("load")); + frame.dispatchEvent(new Event("load")); + }); + expect(close).toHaveBeenCalled(); + const windowClose = closes.at(-1)!; + act(() => window.dispatchEvent(new Event("pagehide"))); + expect(windowClose).toHaveBeenCalled(); + // Another window cannot request a PTY through this frame's listener. + window.dispatchEvent( + new MessageEvent("message", { + source: window, + data: { tag: FRAME_TAG, text: JSON.stringify({ op: "pty-spawn", sessionId: "s", program: "sh" }) }, + }), + ); + await new Promise((r) => setTimeout(r, 0)); + expect(posted.filter((m) => m.text)).toEqual([]); + send({ text: "message" }); + view.unmount(); + expect(closes.every((close) => close.mock.calls.length > 0)).toBe(true); + } finally { + spy.mockRestore(); + } + }); + + it("refuses pty-spawn messages when process: capability is not granted", async () => { + const { view, events } = setup(true); + const { posted, send } = await frameOf(view.container); + send({ text: JSON.stringify({ op: "pty-spawn", sessionId: "s1", program: "sh", args: ["-i"] }) }); + await waitFor(() => + expect( + posted.some( + (m) => + typeof m.text === "string" && + m.text.includes('"op":"pty-error"') && + m.text.includes("process:sh capability not granted"), + ), + ).toBe(true), + ); + expect(events).toEqual([]); + view.unmount(); + }); }); describe("contributed file icons and templates", () => { diff --git a/src/features/plugins/views/WebView.tsx b/src/features/plugins/views/WebView.tsx index b59d7269f..d1a57a25e 100644 --- a/src/features/plugins/views/WebView.tsx +++ b/src/features/plugins/views/WebView.tsx @@ -16,13 +16,17 @@ * - passes on IDE shortcuts it doesn't handle itself (Save, Close…). */ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { Channel } from "@tauri-apps/api/core"; import { tx } from "@/platform/i18n"; +import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; import { subscribeTheme, themeKey } from "@/platform/themeWatch"; import { EmptyState, Spinner } from "@/ui"; import { pluginsBackend, type FileFilter } from "../backend"; import { errorText, usePlugins } from "../store"; import { FRAME_TAG, frameTheme, frameThemeMessage } from "./frame"; import { onViewMessage } from "./viewStore"; +import { ptyBridge } from "./ptyBridge"; import styles from "./PluginView.module.css"; /** One change of a document, in UTF-16 offsets of the text it applies to. */ @@ -152,6 +156,55 @@ export function WebView({ pluginId, view, label, document = null, doc = null }: useEffect(() => { let dialog = false; + const createPtys = () => + ptyBridge( + pluginId, + { + spawn: async (command, output) => { + const current = usePlugins.getState().plugins.find((p) => p.id === pluginId); + if ( + !isTauri() || + !current?.enabled || + !current.capabilities.some((c) => c.id === `process:${command.program}` && c.granted) + ) + throw new Error(tx`Cannot start PTY: process:${command.program} capability not granted.`); + const channel = new Channel(); + channel.onmessage = output; + return call(commands.pluginWebPtySpawn(pluginId, view, command, channel)); + }, + write: (id, data) => call(commands.termWrite(id, data)), + resize: (id, cols, rows) => call(commands.termResize(id, cols, rows)), + kill: (id) => call(commands.termKill(id)), + onExit: (listener) => onIdeEvent("terminalExited", (ev) => listener(ev.terminal, ev.code)), + }, + (message) => post({ text: JSON.stringify(message) }), + ); + let ptys = createPtys(); + const resetPtys = () => { + ptys.close(); + ptys = createPtys(); + }; + const offProject = useProject.subscribe((state, previous) => { + if (state.project !== previous.project) resetPtys(); + }); + const pluginKey = (plugins: ReturnType["plugins"]) => { + const p = plugins.find((p) => p.id === pluginId); + return p && JSON.stringify([p.path, p.version, p.enabled, p.status, p.capabilities]); + }; + const offPlugins = usePlugins.subscribe((state, previous) => { + if (pluginKey(state.plugins) !== pluginKey(previous.plugins)) resetPtys(); + }); + const onUnload = () => ptys.close(); + window.addEventListener("pagehide", onUnload); + const currentFrame = frame.current; + // A navigation keeps the iframe's WindowProxy, but must drop its old sessions. + let loaded = false; + const onLoad = () => { + if (loaded) resetPtys(); + loaded = true; + }; + currentFrame?.addEventListener("load", onLoad); + const run = async (op: string, args: Record): Promise<[unknown, Transferable[]?]> => { switch (op) { case "document.get": { @@ -218,10 +271,13 @@ export function WebView({ pluginId, view, label, document = null, doc = null }: ([value, transfer]) => post({ reply: id, value }, transfer), (err: unknown) => post({ reply: id, error: errorText(err) }), ); - } else if (typeof m.text === "string" && hasCode) { - pluginsBackend() - .viewEvent(pluginId, view, { kind: "message", text: m.text }, doc) - .catch((err: unknown) => console.warn(`plugin ${pluginId}: ${errorText(err)}`)); + } else if (typeof m.text === "string") { + if (ptys.handle(m.text)) return; + if (hasCode) { + pluginsBackend() + .viewEvent(pluginId, view, { kind: "message", text: m.text }, doc) + .catch((err: unknown) => console.warn(`plugin ${pluginId}: ${errorText(err)}`)); + } } }; window.addEventListener("message", onMessage); @@ -229,8 +285,13 @@ export function WebView({ pluginId, view, label, document = null, doc = null }: return () => { window.removeEventListener("message", onMessage); off(); + ptys.close(); + offPlugins(); + offProject(); + window.removeEventListener("pagehide", onUnload); + currentFrame?.removeEventListener("load", onLoad); }; - }, [pluginId, view, document, doc, hasCode, post]); + }, [pluginId, view, document, doc, hasCode, post, page]); if (!page) return ( diff --git a/src/features/plugins/views/ptyBridge.ts b/src/features/plugins/views/ptyBridge.ts new file mode 100644 index 000000000..f2dc53734 --- /dev/null +++ b/src/features/plugins/views/ptyBridge.ts @@ -0,0 +1,286 @@ +import type { TerminalId, TerminalInfo, WebPtyCommand } from "@/bindings"; +import { tx } from "@/platform/i18n"; + +const MAX_SESSIONS = 8; +const MAX_INPUT = 64 << 10; +const MAX_OUTPUT = 256 << 10; +const MAX_MESSAGE = 2 << 20; +const counts = new Map(); + +export interface PtyBackend { + spawn(command: WebPtyCommand, output: (data: string) => void): Promise; + write(id: TerminalId, data: string): Promise; + resize(id: TerminalId, cols: number, rows: number): Promise; + kill(id: TerminalId): Promise; + onExit(listener: (id: TerminalId, code: number | null) => void): () => void; +} + +interface Session { + id?: TerminalId; + stopped: boolean; + release(): void; + inputs: string[]; + inputSize: number; + writing: boolean; + size?: [number, number]; + resizing: boolean; + output: string[]; + outputSize: number; + awaiting?: number; + sentSize?: number; + sequence: number; + exit?: { code: number | null }; +} + +function dimensions(cols: unknown, rows: unknown): cols is number { + return ( + Number.isInteger(cols) && + Number.isInteger(rows) && + Number(cols) >= 2 && + Number(cols) <= 500 && + Number(rows) >= 1 && + Number(rows) <= 500 + ); +} + +/** One opaque frame owns this bridge. Terminal ids never cross the frame boundary. */ +export function ptyBridge(pluginId: string, backend: PtyBackend, send: (message: object) => void) { + const sessions = new Map(); + const earlyExits = new Map(); + let closed = false; + const error = (sessionId: string, message: string) => send({ op: "pty-error", sessionId, error: message }); + const stop = (sid: string, entry: Session) => { + if (entry.stopped) return; + entry.stopped = true; + if (sessions.get(sid) === entry) sessions.delete(sid); + entry.inputs = []; + entry.output = []; + if (entry.id !== undefined) + void backend + .kill(entry.id) + .catch(() => {}) + .finally(entry.release); + // A pending spawn keeps its slot until its returned terminal is killed. + }; + const flush = (sid: string, entry: Session) => { + if (closed || entry.stopped || entry.awaiting !== undefined) return; + const data = entry.output.shift(); + if (data !== undefined) { + entry.sentSize = data.length; + entry.awaiting = ++entry.sequence; + send({ op: "pty-data", sessionId: sid, data, sequence: entry.awaiting }); + } else if (entry.exit) { + send({ op: "pty-exit", sessionId: sid, exitCode: entry.exit.code }); + entry.stopped = true; + sessions.delete(sid); + entry.release(); + } + }; + const exited = (id: TerminalId, code: number | null) => { + const found = [...sessions].find(([, e]) => e.id === id); + if (found) { + const [sid, entry] = found; + entry.exit = { code }; + flush(sid, entry); + } else if ([...sessions.values()].some((e) => e.id === undefined) && earlyExits.size < 32) { + earlyExits.set(id, code); + } + }; + const off = backend.onExit(exited); + const write = async (sid: string, entry: Session) => { + if (entry.writing || entry.id === undefined) return; + entry.writing = true; + try { + while (!entry.stopped && entry.inputs.length) { + const data = entry.inputs.shift()!; + await backend.write(entry.id, data); + entry.inputSize -= data.length; + } + } catch { + if (!entry.stopped) error(sid, tx("Cannot write to PTY")); + stop(sid, entry); + } finally { + entry.writing = false; + } + }; + const resize = async (sid: string, entry: Session) => { + if (entry.resizing || entry.id === undefined) return; + entry.resizing = true; + try { + while (!entry.stopped && entry.size) { + const [cols, rows] = entry.size; + entry.size = undefined; + await backend.resize(entry.id, cols, rows); + } + } catch { + if (!entry.stopped) error(sid, tx("Cannot resize PTY")); + stop(sid, entry); + } finally { + entry.resizing = false; + } + }; + return { + handle(text: string): boolean { + let m: Record; + try { + const value: unknown = JSON.parse(text); + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + m = value as Record; + } catch { + return false; + } + if (typeof m.op !== "string" || !m.op.startsWith("pty-")) return false; + if (closed) return true; + const sid = typeof m.sessionId === "string" ? m.sessionId : ""; + if (text.length > MAX_MESSAGE || !/^[\w.-]{1,64}$/.test(sid)) { + error("", tx("Invalid PTY request")); + return true; + } + const invalid = () => error(sid, tx("Invalid PTY request")); + const entry = sessions.get(sid); + switch (m.op) { + case "pty-spawn": { + const { program, args = [], env = {}, cols = 100, rows = 28 } = m; + if ( + entry || + typeof program !== "string" || + !program || + program.length > 256 || + program.trim() !== program || + /[\0\r\n]/.test(program) || + !Array.isArray(args) || + args.length > 256 || + args.some((a) => typeof a !== "string" || a.length > 4096 || a.includes("\0")) || + !env || + typeof env !== "object" || + Array.isArray(env) || + Object.keys(env).length > 64 || + Object.entries(env).some( + ([k, v]) => + !k || k.length > 256 || /[=\0]/.test(k) || typeof v !== "string" || v.length > 4096 || v.includes("\0"), + ) || + !dimensions(cols, rows) || + "cwd" in m + ) { + invalid(); + return true; + } + const count = counts.get(pluginId) ?? 0; + if (count >= MAX_SESSIONS) { + error(sid, tx("Too many PTY sessions")); + return true; + } + counts.set(pluginId, count + 1); + let released = false; + const created: Session = { + stopped: false, + inputs: [], + inputSize: 0, + writing: false, + resizing: false, + output: [], + outputSize: 0, + sequence: 0, + release() { + if (released) return; + released = true; + const left = (counts.get(pluginId) ?? 1) - 1; + if (left) counts.set(pluginId, left); + else counts.delete(pluginId); + }, + }; + sessions.set(sid, created); + void backend + .spawn( + { program, args: args as string[], env: env as Record, cols, rows: Number(rows) }, + (data) => { + if (closed || created.stopped || !data) return; + created.outputSize += data.length; + if (created.outputSize > MAX_OUTPUT) { + error(sid, tx("PTY output limit exceeded")); + stop(sid, created); + return; + } + created.output.push(data); + flush(sid, created); + }, + ) + .then( + (info) => { + created.id = info.id; + if (closed || created.stopped) { + void backend + .kill(info.id) + .catch(() => {}) + .finally(created.release); + return; + } + if (earlyExits.has(info.id)) { + created.exit = { code: earlyExits.get(info.id)! }; + earlyExits.delete(info.id); + flush(sid, created); + } else { + void write(sid, created); + void resize(sid, created); + } + if (![...sessions.values()].some((e) => e.id === undefined)) earlyExits.clear(); + }, + (err: unknown) => { + if (!closed && !created.stopped) error(sid, String(err)); + created.release(); + stop(sid, created); + }, + ); + return true; + } + case "pty-input": + if (typeof m.data !== "string" || m.data.length > MAX_INPUT) { + invalid(); + break; + } + if (entry && !entry.exit && m.data.length) { + entry.inputSize += m.data.length; + if (entry.inputSize > MAX_INPUT) { + error(sid, tx("PTY input limit exceeded")); + stop(sid, entry); + break; + } + entry.inputs.push(m.data); + void write(sid, entry); + } + break; + case "pty-resize": + if (!dimensions(m.cols, m.rows)) { + invalid(); + break; + } + if (entry && !entry.exit) { + entry.size = [Number(m.cols), Number(m.rows)]; + void resize(sid, entry); + } + break; + case "pty-kill": + if (entry) stop(sid, entry); + break; + case "pty-ack": + if (entry && Number.isSafeInteger(m.sequence) && m.sequence === entry.awaiting) { + // The in-flight chunk counts against the same bound as the unsent queue. + entry.outputSize -= entry.sentSize ?? 0; + entry.awaiting = undefined; + flush(sid, entry); + } + break; + default: + invalid(); + } + return true; + }, + close() { + if (closed) return; + closed = true; + off(); + for (const [sid, entry] of sessions) stop(sid, entry); + earlyExits.clear(); + }, + }; +} diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index a17b7f7d2..5005493ac 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,11 @@ { + "Invalid PTY request": "Invalid PTY request", + "Cannot write to PTY": "Cannot write to PTY", + "Cannot resize PTY": "Cannot resize PTY", + "Too many PTY sessions": "Too many PTY sessions", + "PTY output limit exceeded": "PTY output limit exceeded", + "PTY input limit exceeded": "PTY input limit exceeded", + "Cannot start PTY: process:{program} capability not granted.": "Cannot start PTY: process:{program} capability not granted.", "Marker Icons": "Marker Icons", "this data source does not list databases": "this data source does not list databases", "commit or roll back before clearing the selected database": "commit or roll back before clearing the selected database", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 9f497af11..130ba8560 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,11 @@ { + "Invalid PTY request": "无效的 PTY 请求", + "Cannot write to PTY": "无法写入 PTY", + "Cannot resize PTY": "无法调整 PTY 大小", + "Too many PTY sessions": "PTY 会话过多", + "PTY output limit exceeded": "PTY 输出超出限制", + "PTY input limit exceeded": "PTY 输入超出限制", + "Cannot start PTY: process:{program} capability not granted.": "无法启动 PTY:未授予 process:{program} 权限。", "Marker Icons": "行标记图标", "this data source does not list databases": "此数据源不支持列出数据库", "commit or roll back before clearing the selected database": "清除所选数据库前,请先提交或回滚事务",