feat(plugins): PTY session bridge for plugin web views (#2038)

A plugin web view can open interactive PTY sessions (spawn, input, resize, kill, streamed output and exit). Spawning requires the exact granted `process:<program>` and its pinned executable, with no PATH re-resolution or fallback shell; the cwd is the trusted project, arguments, environment (append-only) and sizes are validated, and at most eight sessions run per plugin. A frame can only drive the sessions it opened; output is acknowledged chunk by chunk and input/output are bounded. Sessions end on permission change, reload, disable, uninstall, frame teardown or window close. Requires API ^1.3; adds a web-pty test fixture. Verified on macOS; Windows/Linux not run.

Co-authored-by: dengliu <[email protected]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-08 03:29:36 +08:00
1 джерело 30a0521d0d
коміт af19880390
49 files changed
+1548 -11

No files matched your search

+4
Переглянути файл
@@ -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) {
+7
Переглянути файл
@@ -326,6 +326,7 @@ pub struct PluginHost {
providers: Arc<providers::Providers>,
navigation: Mutex<navigation::NavigationIndex>,
endpoints: Mutex<endpoints::Endpoints>,
web_ptys: Mutex<BTreeMap<String, Vec<web::WebPtySession>>>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
@@ -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>) {
+149
Переглянути файл
@@ -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<String>,
pub env: std::collections::BTreeMap<String, String>,
pub cols: u16,
pub rows: u16,
}
pub(super) struct WebPtySession {
id: ide_core::TerminalId,
host: std::sync::Weak<crate::Host>,
window: String,
}
/// A file of a web view.
pub struct WebFile {
pub bytes: Vec<u8>,
@@ -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<crate::Host>,
plugin_id: &str,
view: &str,
window: &str,
command: WebPtyCommand,
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
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<ide_terminal::TerminalOptions> {
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<String> {
[super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
+172
Переглянути файл
@@ -22,6 +22,7 @@ struct TestWorkspace {
other_roots: Mutex<Vec<PathBuf>>,
documents: Arc<ide_workspace::Documents>,
trusted: bool,
project_queries_left: Mutex<Option<usize>>,
/// (producer, path) → published diagnostics.
diagnostics: Mutex<std::collections::BTreeMap<(String, PathBuf), Vec<ide_core::Diagnostic>>>,
/// 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<PathBuf> {
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());
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
+333
Переглянути файл
@@ -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"
+20
Переглянути файл
@@ -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"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
bb6e1eba6b9f28ca769bee0c6dad9ceab536d486efcbe0ad56bbfe621e2d7679
+21
Переглянути файл
@@ -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"
+15
Переглянути файл
@@ -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<Option<String>, Error> {
match id {
"ping" => Ok(Some("Web PTY ready".into())),
_ => Err(Error::NotFound(id.into())),
}
}
}
register_plugin!(WebPty);
+27
Переглянути файл
@@ -0,0 +1,27 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>PTY Probe</title>
<style>
body { background: var(--openidea-bg); color: var(--openidea-fg); font-family: var(--openidea-font-mono, monospace); font-size: var(--openidea-font-size, 13px); padding: 12px; }
button, input { font: inherit; padding: 6px; } pre { white-space: pre-wrap; overflow-wrap: anywhere; } form { display: flex; gap: 4px; } input { flex: 1; min-width: 0; }
</style></head><body>
<h2>Web PTY Probe</h2><p id="isolation"></p>
<button id="start">Start bash</button> <button id="stop">Stop</button> <button id="resize">Resize to 100 × 30</button>
<pre id="output" aria-live="polite"></pre>
<form id="input"><input id="command" aria-label="Terminal input" placeholder="Type a shell command"><button>Send</button></form>
<script>
const output = document.querySelector('#output');
const send = (op, args = {}) => openidea.postMessage(JSON.stringify({ op, sessionId: 'probe', ...args }));
document.querySelector('#isolation').textContent = `IPC globals: ${typeof window.__TAURI_INTERNALS__}; parent access: ` + (() => { try { return parent.document.title; } catch { return 'blocked'; } })();
openidea.onMessage(text => {
let m; try { m = JSON.parse(text); } catch { return; }
if (m.op === 'pty-data') {
output.textContent = (output.textContent + m.data).slice(-65536);
send('pty-ack', { sequence: m.sequence });
} else if (m.op === 'pty-error') output.textContent += '\nERROR: ' + m.error + '\n';
else if (m.op === 'pty-exit') output.textContent += '\nEXIT: ' + m.exitCode + '\n';
});
document.querySelector('#start').onclick = () => send('pty-spawn', { program: 'bash', args: ['--noprofile', '--norc', '-i'], cols: 100, rows: 30 });
document.querySelector('#stop').onclick = () => send('pty-kill');
document.querySelector('#resize').onclick = () => send('pty-resize', { cols: 100, rows: 30 });
document.querySelector('#input').onsubmit = e => { e.preventDefault(); send('pty-input', { data: document.querySelector('#command').value + '\r' }); document.querySelector('#command').value = ''; };
</script></body></html>
+59 -4
Переглянути файл
@@ -91,12 +91,45 @@ impl TerminalManager {
key: String,
sink: SharedSink,
on_output: Box<dyn Fn(String) + Send>,
) -> IdeResult<TerminalInfo> {
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<dyn Fn(String) + Send>,
) -> IdeResult<TerminalInfo> {
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<dyn Fn(String) + Send>,
exact: bool,
) -> IdeResult<TerminalInfo> {
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();
+39
Переглянути файл
@@ -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:<program>` 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/<tag>.json`, one file per language
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
Бінарний файл не відображається.
+15
Переглянути файл
@@ -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:<program>` 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.
+1
Переглянути файл
@@ -499,6 +499,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+30
Переглянути файл
@@ -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<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
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;
+10
Переглянути файл
@@ -1128,6 +1128,7 @@ export const commands = {
pluginServiceLog: (request: RequestId, pluginId: string, view: string, key: string, tab: string, on: string[], onLine: Channel<PluginOutputLine>) => typedError<number | null, IdeError>(__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<WebPage, IdeError>(__TAURI_INVOKE("plugin_web_page", { pluginId, view })),
pluginWebPtySpawn: (pluginId: string, view: string, command: WebPtyCommand, onOutput: Channel<string>) => typedError<TerminalInfo, IdeError>(__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`. */
+211
Переглянути файл
@@ -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<string, unknown>[] = [];
const bridge = ptyBridge(plugin, backend, (m) => sent.push(m as Record<string, unknown>));
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);
});
});
+68 -2
Переглянути файл
@@ -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(<WebView pluginId="acme.draw" view="draw" label="Draw" document={doc} doc={7} />);
@@ -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<typeof vi.fn>[] = [];
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:<program> 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", () => {
+66 -5
Переглянути файл
@@ -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<string>();
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<typeof usePlugins.getState>["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<string, unknown>): 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 (
+286
Переглянути файл
@@ -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<string, number>();
export interface PtyBackend {
spawn(command: WebPtyCommand, output: (data: string) => void): Promise<TerminalInfo>;
write(id: TerminalId, data: string): Promise<unknown>;
resize(id: TerminalId, cols: number, rows: number): Promise<unknown>;
kill(id: TerminalId): Promise<unknown>;
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<string, Session>();
const earlyExits = new Map<TerminalId, number | null>();
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<string, unknown>;
try {
const value: unknown = JSON.parse(text);
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
m = value as Record<string, unknown>;
} 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<string, string>, 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();
},
};
}
+7
Переглянути файл
@@ -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",
+7
Переглянути файл
@@ -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": "清除所选数据库前,请先提交或回滚事务",